diff --git a/js/package.json b/js/package.json index 08af16a0aa1..29bb7079131 100644 --- a/js/package.json +++ b/js/package.json @@ -43,8 +43,8 @@ "lint:css": "stylelint ./src/**/*.css", "lint:js": "eslint --ignore-path .gitignore ./src/", "lint:js:cached": "eslint --cache --ignore-path .gitignore ./src/", - "test": "NODE_ENV=test mocha 'src/**/*.spec.js'", - "test:coverage": "NODE_ENV=test istanbul cover _mocha -- 'src/**/*.spec.js'", + "test": "NODE_ENV=test mocha --compilers ejs:ejsify 'src/**/*.spec.js'", + "test:coverage": "NODE_ENV=test istanbul cover _mocha -- --compilers ejs:ejsify 'src/**/*.spec.js'", "test:e2e": "NODE_ENV=test mocha 'src/**/*.e2e.js'", "test:npm": "(cd .npmjs && npm i) && node test/npmParity && (rm -rf .npmjs/node_modules)", "prepush": "npm run lint:cached" @@ -80,6 +80,7 @@ "coveralls": "2.11.15", "css-loader": "0.26.1", "ejs-loader": "0.3.0", + "ejsify": "1.0.0", "enzyme": "2.7.0", "eslint": "3.11.1", "eslint-config-semistandard": "7.0.0", diff --git a/js/src/3rdparty/etherscan/account.js b/js/src/3rdparty/etherscan/account.js index 7b8c431a0ce..52a08ef4b6b 100644 --- a/js/src/3rdparty/etherscan/account.js +++ b/js/src/3rdparty/etherscan/account.js @@ -49,17 +49,17 @@ function transactions (address, page, test = false) { // page offset from 0 return _call('txlist', { address: address, - page: (page || 0) + 1, offset: PAGE_SIZE, + page: (page || 0) + 1, sort: 'desc' }, test).then((transactions) => { return transactions.map((tx) => { return { + blockNumber: new BigNumber(tx.blockNumber || 0), from: util.toChecksumAddress(tx.from), - to: util.toChecksumAddress(tx.to), hash: tx.hash, - blockNumber: new BigNumber(tx.blockNumber), timeStamp: tx.timeStamp, + to: util.toChecksumAddress(tx.to), value: tx.value }; }); diff --git a/js/src/3rdparty/etherscan/helpers.spec.js b/js/src/3rdparty/etherscan/helpers.spec.js new file mode 100644 index 00000000000..508a7b47a96 --- /dev/null +++ b/js/src/3rdparty/etherscan/helpers.spec.js @@ -0,0 +1,38 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import nock from 'nock'; +import { stringify } from 'qs'; + +import { url } from './links'; + +function mockget (requests, test) { + let scope = nock(url(test)); + + requests.forEach((request) => { + scope = scope + .get(`/api?${stringify(request.query)}`) + .reply(request.code || 200, () => { + return { result: request.reply }; + }); + }); + + return scope; +} + +export { + mockget +}; diff --git a/js/src/api/subscriptions/eth.spec.js b/js/src/api/subscriptions/eth.spec.js index 87cc76d0346..680ff881e2a 100644 --- a/js/src/api/subscriptions/eth.spec.js +++ b/js/src/api/subscriptions/eth.spec.js @@ -16,7 +16,6 @@ import BigNumber from 'bignumber.js'; import sinon from 'sinon'; -import 'sinon-as-promised'; import Eth from './eth'; diff --git a/js/src/api/subscriptions/personal.spec.js b/js/src/api/subscriptions/personal.spec.js index b00354f6493..2359192f09e 100644 --- a/js/src/api/subscriptions/personal.spec.js +++ b/js/src/api/subscriptions/personal.spec.js @@ -15,7 +15,6 @@ // along with Parity. If not, see . import sinon from 'sinon'; -import 'sinon-as-promised'; import Personal from './personal'; diff --git a/js/src/ui/Actionbar/actionbar.js b/js/src/ui/Actionbar/actionbar.js index 0141016aba4..49cc77df131 100644 --- a/js/src/ui/Actionbar/actionbar.js +++ b/js/src/ui/Actionbar/actionbar.js @@ -50,8 +50,7 @@ export default class Actionbar extends Component { } return ( - + { buttons } ); diff --git a/js/src/ui/Certifications/certifications.js b/js/src/ui/Certifications/certifications.js index bafd06f35bb..5604ab90afa 100644 --- a/js/src/ui/Certifications/certifications.js +++ b/js/src/ui/Certifications/certifications.js @@ -25,7 +25,7 @@ import styles from './certifications.css'; class Certifications extends Component { static propTypes = { - account: PropTypes.string.isRequired, + address: PropTypes.string.isRequired, certifications: PropTypes.array.isRequired, dappsUrl: PropTypes.string.isRequired } @@ -60,10 +60,10 @@ class Certifications extends Component { } function mapStateToProps (_, initProps) { - const { account } = initProps; + const { address } = initProps; return (state) => { - const certifications = state.certifications[account] || []; + const certifications = state.certifications[address] || []; const dappsUrl = state.api.dappsUrl; return { certifications, dappsUrl }; diff --git a/js/src/ui/Icons/index.js b/js/src/ui/Icons/index.js index 4cf5a2d7d26..1e0f938096e 100644 --- a/js/src/ui/Icons/index.js +++ b/js/src/ui/Icons/index.js @@ -22,13 +22,16 @@ import CompareIcon from 'material-ui/svg-icons/action/compare-arrows'; import ComputerIcon from 'material-ui/svg-icons/hardware/desktop-mac'; import ContractIcon from 'material-ui/svg-icons/action/code'; import DashboardIcon from 'material-ui/svg-icons/action/dashboard'; +import DeleteIcon from 'material-ui/svg-icons/action/delete'; import DoneIcon from 'material-ui/svg-icons/action/done-all'; -import LockedIcon from 'material-ui/svg-icons/action/lock-outline'; +import EditIcon from 'material-ui/svg-icons/content/create'; +import LockedIcon from 'material-ui/svg-icons/action/lock'; import NextIcon from 'material-ui/svg-icons/navigation/arrow-forward'; import PrevIcon from 'material-ui/svg-icons/navigation/arrow-back'; import SaveIcon from 'material-ui/svg-icons/content/save'; import SendIcon from 'material-ui/svg-icons/content/send'; import SnoozeIcon from 'material-ui/svg-icons/av/snooze'; +import VerifyIcon from 'material-ui/svg-icons/action/verified-user'; import VisibleIcon from 'material-ui/svg-icons/image/remove-red-eye'; import VpnIcon from 'material-ui/svg-icons/notification/vpn-lock'; @@ -41,13 +44,16 @@ export { ComputerIcon, ContractIcon, DashboardIcon, + DeleteIcon, DoneIcon, + EditIcon, LockedIcon, NextIcon, PrevIcon, SaveIcon, SendIcon, SnoozeIcon, + VerifyIcon, VisibleIcon, VpnIcon }; diff --git a/js/src/views/Account/Header/header.js b/js/src/views/Account/Header/header.js index 6e508d05ee5..f5694177aef 100644 --- a/js/src/views/Account/Header/header.js +++ b/js/src/views/Account/Header/header.js @@ -15,6 +15,7 @@ // along with Parity. If not, see . import React, { Component, PropTypes } from 'react'; +import { FormattedMessage } from 'react-intl'; import { Balance, Container, ContainerTitle, IdentityIcon, IdentityName, Tags } from '~/ui'; import CopyToClipboard from '~/ui/CopyToClipboard'; @@ -26,50 +27,45 @@ export default class Header extends Component { static propTypes = { account: PropTypes.object, balance: PropTypes.object, - className: PropTypes.string, children: PropTypes.node, - isContract: PropTypes.bool, - hideName: PropTypes.bool + className: PropTypes.string, + hideName: PropTypes.bool, + isContract: PropTypes.bool }; static defaultProps = { - className: '', children: null, - isContract: false, - hideName: false + className: '', + hideName: false, + isContract: false }; render () { - const { account, balance, className, children, hideName } = this.props; - const { address, meta, uuid } = account; + const { account, balance, children, className, hideName } = this.props; + if (!account) { return null; } - const uuidText = !uuid - ? null - : uuid: { uuid }; + const { address } = account; + const meta = account.meta || {}; return ( - + - { this.renderName(address) } - + { this.renderName() } { address } - - { uuidText } + { this.renderUuid() } { meta.description } { this.renderTxCount() } - @@ -77,9 +73,7 @@ export default class Header extends Component { - + { children } @@ -87,15 +81,22 @@ export default class Header extends Component { ); } - renderName (address) { + renderName () { const { hideName } = this.props; if (hideName) { return null; } + const { address } = this.props.account; + return ( - } /> + + } /> ); } @@ -114,7 +115,31 @@ export default class Header extends Component { return ( - { txCount.toFormat() } outgoing transactions + + + ); + } + + renderUuid () { + const { uuid } = this.props.account; + + if (!uuid) { + return null; + } + + return ( + + ); } diff --git a/js/src/views/Account/Header/header.spec.js b/js/src/views/Account/Header/header.spec.js new file mode 100644 index 00000000000..5ae5104d2e4 --- /dev/null +++ b/js/src/views/Account/Header/header.spec.js @@ -0,0 +1,156 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import BigNumber from 'bignumber.js'; +import { shallow } from 'enzyme'; +import React from 'react'; + +import Header from './'; + +const ACCOUNT = { + address: '0x0123456789012345678901234567890123456789', + meta: { + description: 'the description', + tags: ['taga', 'tagb'] + }, + uuid: '0xabcdef' +}; + +let component; +let instance; + +function render (props = {}) { + if (props && !props.account) { + props.account = ACCOUNT; + } + + component = shallow( + + ); + instance = component.instance(); + + return component; +} + +describe('views/Account/Header', () => { + describe('rendering', () => { + it('renders defaults', () => { + expect(render()).to.be.ok; + }); + + it('renders null with no account', () => { + expect(render(null).find('div')).to.have.length(0); + }); + + it('renders when no account meta', () => { + expect(render({ account: { address: ACCOUNT.address } })).to.be.ok; + }); + + it('renders when no account description', () => { + expect(render({ account: { address: ACCOUNT.address, meta: { tags: [] } } })).to.be.ok; + }); + + it('renders when no account tags', () => { + expect(render({ account: { address: ACCOUNT.address, meta: { description: 'something' } } })).to.be.ok; + }); + + describe('sections', () => { + it('renders the Balance', () => { + render({ balance: { balance: 'testing' } }); + const balance = component.find('Connect(Balance)'); + + expect(balance).to.have.length(1); + expect(balance.props().account).to.deep.equal(ACCOUNT); + expect(balance.props().balance).to.deep.equal({ balance: 'testing' }); + }); + + it('renders the Certifications', () => { + render(); + const certs = component.find('Connect(Certifications)'); + + expect(certs).to.have.length(1); + expect(certs.props().address).to.deep.equal(ACCOUNT.address); + }); + + it('renders the IdentityIcon', () => { + render(); + const icon = component.find('Connect(IdentityIcon)'); + + expect(icon).to.have.length(1); + expect(icon.props().address).to.equal(ACCOUNT.address); + }); + + it('renders the Tags', () => { + render(); + const tags = component.find('Tags'); + + expect(tags).to.have.length(1); + expect(tags.props().tags).to.deep.equal(ACCOUNT.meta.tags); + }); + }); + }); + + describe('renderName', () => { + it('renders null with hideName', () => { + render({ hideName: true }); + expect(instance.renderName()).to.be.null; + }); + + it('renders the name', () => { + render(); + expect(instance.renderName()).not.to.be.null; + }); + + it('renders when no address specified', () => { + render({ account: {} }); + expect(instance.renderName()).to.be.ok; + }); + }); + + describe('renderTxCount', () => { + it('renders null when contract', () => { + render({ balance: { txCount: new BigNumber(1) }, isContract: true }); + expect(instance.renderTxCount()).to.be.null; + }); + + it('renders null when no balance', () => { + render({ balance: null, isContract: false }); + expect(instance.renderTxCount()).to.be.null; + }); + + it('renders null when txCount is null', () => { + render({ balance: { txCount: null }, isContract: false }); + expect(instance.renderTxCount()).to.be.null; + }); + + it('renders the tx count', () => { + render({ balance: { txCount: new BigNumber(1) }, isContract: false }); + expect(instance.renderTxCount()).not.to.be.null; + }); + }); + + describe('renderUuid', () => { + it('renders null with no uuid', () => { + render({ account: Object.assign({}, ACCOUNT, { uuid: null }) }); + expect(instance.renderUuid()).to.be.null; + }); + + it('renders the uuid', () => { + render(); + expect(instance.renderUuid()).not.to.be.null; + }); + }); +}); diff --git a/js/src/views/Account/Transactions/store.js b/js/src/views/Account/Transactions/store.js new file mode 100644 index 00000000000..d59595c441c --- /dev/null +++ b/js/src/views/Account/Transactions/store.js @@ -0,0 +1,118 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { action, observable, transaction } from 'mobx'; + +import etherscan from '~/3rdparty/etherscan'; + +export default class Store { + @observable address = null; + @observable isLoading = false; + @observable isTest = undefined; + @observable isTracing = false; + @observable txHashes = []; + + constructor (api) { + this._api = api; + } + + @action setHashes = (transactions) => { + transaction(() => { + this.setLoading(false); + this.txHashes = transactions.map((transaction) => transaction.hash); + }); + } + + @action setAddress = (address) => { + this.address = address; + } + + @action setLoading = (isLoading) => { + this.isLoading = isLoading; + } + + @action setTest = (isTest) => { + this.isTest = isTest; + } + + @action setTracing = (isTracing) => { + this.isTracing = isTracing; + } + + @action updateProps = (props) => { + transaction(() => { + this.setAddress(props.address); + this.setTest(props.isTest); + + // TODO: When tracing is enabled again, adjust to actually set + this.setTracing(false && props.traceMode); + }); + + return this.getTransactions(); + } + + getTransactions () { + if (this.isTest === undefined) { + return Promise.resolve(); + } + + this.setLoading(true); + + // TODO: When supporting other chains (eg. ETC). call to be made to other endpoints + return ( + this.isTracing + ? this.fetchTraceTransactions() + : this.fetchEtherscanTransactions() + ) + .then((transactions) => { + this.setHashes(transactions); + }) + .catch((error) => { + console.warn('getTransactions', error); + this.setLoading(false); + }); + } + + fetchEtherscanTransactions () { + return etherscan.account.transactions(this.address, 0, this.isTest); + } + + fetchTraceTransactions () { + return Promise + .all([ + this._api.trace.filter({ + fromAddress: this.address, + fromBlock: 0 + }), + this._api.trace.filter({ + fromBlock: 0, + toAddress: this.address + }) + ]) + .then(([fromTransactions, toTransactions]) => { + return fromTransactions + .concat(toTransactions) + .map((transaction) => { + return { + blockNumber: transaction.blockNumber, + from: transaction.action.from, + hash: transaction.transactionHash, + to: transaction.action.to + }; + }); + }); + } +} diff --git a/js/src/views/Account/Transactions/store.spec.js b/js/src/views/Account/Transactions/store.spec.js new file mode 100644 index 00000000000..a25b58d2982 --- /dev/null +++ b/js/src/views/Account/Transactions/store.spec.js @@ -0,0 +1,193 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import BigNumber from 'bignumber.js'; +import sinon from 'sinon'; + +import { mockget as mockEtherscan } from '~/3rdparty/etherscan/helpers.spec.js'; +import { ADDRESS, createApi } from './transactions.test.js'; + +import Store from './store'; + +let api; +let store; + +function createStore () { + api = createApi(); + store = new Store(api); + + return store; +} + +function mockQuery () { + mockEtherscan([{ + query: { + module: 'account', + action: 'txlist', + address: ADDRESS, + offset: 25, + page: 1, + sort: 'desc' + }, + reply: [{ hash: '123' }] + }], true); +} + +describe('views/Account/Transactions/store', () => { + beforeEach(() => { + mockQuery(); + createStore(); + }); + + describe('constructor', () => { + it('sets the api', () => { + expect(store._api).to.deep.equals(api); + }); + + it('starts with isLoading === false', () => { + expect(store.isLoading).to.be.false; + }); + + it('starts with isTracing === false', () => { + expect(store.isTracing).to.be.false; + }); + }); + + describe('@action', () => { + describe('setHashes', () => { + it('clears the loading state', () => { + store.setLoading(true); + store.setHashes([]); + expect(store.isLoading).to.be.false; + }); + + it('sets the hashes from the transactions', () => { + store.setHashes([{ hash: '123' }, { hash: '456' }]); + expect(store.txHashes.peek()).to.deep.equal(['123', '456']); + }); + }); + + describe('setAddress', () => { + it('sets the address', () => { + store.setAddress(ADDRESS); + expect(store.address).to.equal(ADDRESS); + }); + }); + + describe('setLoading', () => { + it('sets the isLoading flag', () => { + store.setLoading(true); + expect(store.isLoading).to.be.true; + }); + }); + + describe('setTest', () => { + it('sets the isTest flag', () => { + store.setTest(true); + expect(store.isTest).to.be.true; + }); + }); + + describe('setTracing', () => { + it('sets the isTracing flag', () => { + store.setTracing(true); + expect(store.isTracing).to.be.true; + }); + }); + + describe('updateProps', () => { + it('retrieves transactions once updated', () => { + sinon.spy(store, 'getTransactions'); + store.updateProps({}); + + expect(store.getTransactions).to.have.been.called; + store.getTransactions.restore(); + }); + }); + }); + + describe('operations', () => { + describe('getTransactions', () => { + it('retrieves the hashes via etherscan', () => { + sinon.spy(store, 'fetchEtherscanTransactions'); + store.setAddress(ADDRESS); + store.setTest(true); + store.setTracing(false); + + return store.getTransactions().then(() => { + expect(store.fetchEtherscanTransactions).to.have.been.called; + expect(store.txHashes.peek()).to.deep.equal(['123']); + store.fetchEtherscanTransactions.restore(); + }); + }); + + it('retrieves the hashes via tracing', () => { + sinon.spy(store, 'fetchTraceTransactions'); + store.setAddress(ADDRESS); + store.setTest(true); + store.setTracing(true); + + return store.getTransactions().then(() => { + expect(store.fetchTraceTransactions).to.have.been.called; + expect(store.txHashes.peek()).to.deep.equal(['123', '098']); + store.fetchTraceTransactions.restore(); + }); + }); + }); + + describe('fetchEtherscanTransactions', () => { + it('retrieves the transactions', () => { + store.setAddress(ADDRESS); + store.setTest(true); + + return store.fetchEtherscanTransactions().then((transactions) => { + expect(transactions).to.deep.equal([{ + blockNumber: new BigNumber(0), + from: '', + hash: '123', + timeStamp: undefined, + to: '', + value: undefined + }]); + }); + }); + }); + + describe('fetchTraceTransactions', () => { + it('retrieves the transactions', () => { + store.setAddress(ADDRESS); + store.setTest(true); + + return store.fetchTraceTransactions().then((transactions) => { + expect(transactions).to.deep.equal([ + { + blockNumber: undefined, + from: undefined, + hash: '123', + to: undefined + }, + { + blockNumber: undefined, + from: undefined, + hash: '098', + to: undefined + } + ]); + }); + }); + }); + }); +}); diff --git a/js/src/views/Account/Transactions/transactions.js b/js/src/views/Account/Transactions/transactions.js index eb11e8def4a..5e48d5c5c3d 100644 --- a/js/src/views/Account/Transactions/transactions.js +++ b/js/src/views/Account/Transactions/transactions.js @@ -14,15 +14,18 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . +import { observer } from 'mobx-react'; import React, { Component, PropTypes } from 'react'; +import { FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import etherscan from '~/3rdparty/etherscan'; import { Container, TxList, Loading } from '~/ui'; +import Store from './store'; import styles from './transactions.css'; +@observer class Transactions extends Component { static contextTypes = { api: PropTypes.object.isRequired @@ -34,34 +37,35 @@ class Transactions extends Component { traceMode: PropTypes.bool } - state = { - hashes: [], - loading: true, - callInfo: {} - } + store = new Store(this.context.api); - componentDidMount () { - this.getTransactions(this.props); + componentWillMount () { + this.store.updateProps(this.props); } componentWillReceiveProps (newProps) { if (this.props.traceMode === undefined && newProps.traceMode !== undefined) { - this.getTransactions(newProps); + this.store.updateProps(newProps); return; } - const hasChanged = [ 'isTest', 'address' ] + const hasChanged = ['isTest', 'address'] .map(key => newProps[key] !== this.props[key]) .reduce((truth, keyTruth) => truth || keyTruth, false); if (hasChanged) { - this.getTransactions(newProps); + this.store.updateProps(newProps); } } render () { return ( - + + }> { this.renderTransactionList() } { this.renderEtherscanFooter() } @@ -69,10 +73,9 @@ class Transactions extends Component { } renderTransactionList () { - const { address } = this.props; - const { hashes, loading } = this.state; + const { address, isLoading, txHashes } = this.store; - if (loading) { + if (isLoading) { return ( ); @@ -81,85 +84,29 @@ class Transactions extends Component { return ( ); } renderEtherscanFooter () { - const { traceMode } = this.props; + const { isTracing } = this.store; - if (traceMode) { + if (isTracing) { return null; } return ( - Transaction list powered by etherscan.io + etherscan.io + } } /> ); } - - getTransactions = (props) => { - const { isTest, address, traceMode } = props; - - // Don't fetch the transactions if we don't know in which - // network we are yet... - if (isTest === undefined) { - return; - } - - return this - .fetchTransactions(isTest, address, traceMode) - .then((transactions) => { - this.setState({ - hashes: transactions.map((transaction) => transaction.hash), - loading: false - }); - }); - } - - fetchTransactions = (isTest, address, traceMode) => { - // if (traceMode) { - // return this.fetchTraceTransactions(address); - // } - - return this.fetchEtherscanTransactions(isTest, address); - } - - fetchEtherscanTransactions = (isTest, address) => { - return etherscan.account - .transactions(address, 0, isTest) - .catch((error) => { - console.error('getTransactions', error); - }); - } - - fetchTraceTransactions = (address) => { - return Promise - .all([ - this.context.api.trace - .filter({ - fromBlock: 0, - fromAddress: address - }), - this.context.api.trace - .filter({ - fromBlock: 0, - toAddress: address - }) - ]) - .then(([fromTransactions, toTransactions]) => { - const transactions = [].concat(fromTransactions, toTransactions); - - return transactions.map(transaction => ({ - from: transaction.action.from, - to: transaction.action.to, - blockNumber: transaction.blockNumber, - hash: transaction.transactionHash - })); - }); - } } function mapStateToProps (state) { diff --git a/js/src/views/Account/Transactions/transactions.spec.js b/js/src/views/Account/Transactions/transactions.spec.js new file mode 100644 index 00000000000..53f55b524bb --- /dev/null +++ b/js/src/views/Account/Transactions/transactions.spec.js @@ -0,0 +1,55 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { shallow } from 'enzyme'; +import React from 'react'; + +import { ADDRESS, createApi, createRedux } from './transactions.test.js'; + +import Transactions from './'; + +let component; +let instance; + +function render (props) { + component = shallow( + , + { context: { store: createRedux() } } + ).find('Transactions').shallow({ context: { api: createApi() } }); + instance = component.instance(); + + return component; +} + +describe('views/Account/Transactions', () => { + it('renders defaults', () => { + expect(render()).to.be.ok; + }); + + describe('renderTransactionList', () => { + it('renders Loading when isLoading === true', () => { + instance.store.setLoading(true); + expect(instance.renderTransactionList().type).to.match(/Loading/); + }); + + it('renders TxList when isLoading === true', () => { + instance.store.setLoading(false); + expect(instance.renderTransactionList().type).to.match(/Connect/); + }); + }); +}); diff --git a/js/src/views/Account/Transactions/transactions.test.js b/js/src/views/Account/Transactions/transactions.test.js new file mode 100644 index 00000000000..4b7b679b651 --- /dev/null +++ b/js/src/views/Account/Transactions/transactions.test.js @@ -0,0 +1,31 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { ADDRESS, createRedux } from '../account.test.js'; + +function createApi () { + return { + trace: { + filter: (options) => Promise.resolve([{ transactionHash: options.fromAddress ? '123' : '098', action: {} }]) + } + }; +} + +export { + ADDRESS, + createApi, + createRedux +}; diff --git a/js/src/views/Account/account.js b/js/src/views/Account/account.js index f274d8fbe2b..e3c4d977612 100644 --- a/js/src/views/Account/account.js +++ b/js/src/views/Account/account.js @@ -14,47 +14,38 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . +import { observer } from 'mobx-react'; import React, { Component, PropTypes } from 'react'; +import { FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import ActionDelete from 'material-ui/svg-icons/action/delete'; -import ContentCreate from 'material-ui/svg-icons/content/create'; -import ContentSend from 'material-ui/svg-icons/content/send'; -import LockIcon from 'material-ui/svg-icons/action/lock'; -import VerifyIcon from 'material-ui/svg-icons/action/verified-user'; +import shapeshiftBtn from '~/../assets/images/shapeshift-btn.png'; import { EditMeta, DeleteAccount, Shapeshift, Verification, Transfer, PasswordManager } from '~/modals'; +import { setVisibleAccounts } from '~/redux/providers/personalActions'; +import { fetchCertifiers, fetchCertifications } from '~/redux/providers/certifications/actions'; import { Actionbar, Button, Page } from '~/ui'; - -import shapeshiftBtn from '~/../assets/images/shapeshift-btn.png'; +import { DeleteIcon, EditIcon, LockedIcon, SendIcon, VerifyIcon } from '~/ui/Icons'; import Header from './Header'; +import Store from './store'; import Transactions from './Transactions'; -import { setVisibleAccounts } from '~/redux/providers/personalActions'; -import { fetchCertifiers, fetchCertifications } from '~/redux/providers/certifications/actions'; - import styles from './account.css'; +@observer class Account extends Component { static propTypes = { - setVisibleAccounts: PropTypes.func.isRequired, fetchCertifiers: PropTypes.func.isRequired, fetchCertifications: PropTypes.func.isRequired, images: PropTypes.object.isRequired, + setVisibleAccounts: PropTypes.func.isRequired, - params: PropTypes.object, accounts: PropTypes.object, - balances: PropTypes.object + balances: PropTypes.object, + params: PropTypes.object } - state = { - showDeleteDialog: false, - showEditDialog: false, - showFundDialog: false, - showVerificationDialog: false, - showTransferDialog: false, - showPasswordDialog: false - } + store = new Store(); componentDidMount () { this.props.fetchCertifiers(); @@ -76,7 +67,8 @@ class Account extends Component { setVisibleAccounts (props = this.props) { const { params, setVisibleAccounts, fetchCertifications } = props; - const addresses = [ params.address ]; + const addresses = [params.address]; + setVisibleAccounts(addresses); fetchCertifications(params.address); } @@ -97,15 +89,14 @@ class Account extends Component { { this.renderDeleteDialog(account) } { this.renderEditDialog(account) } { this.renderFundDialog() } + { this.renderPasswordDialog(account) } + { this.renderTransferDialog(account, balance) } { this.renderVerificationDialog() } - { this.renderTransferDialog() } - { this.renderPasswordDialog() } - { this.renderActionbar() } + { this.renderActionbar(balance) } + balance={ balance } /> @@ -114,86 +105,108 @@ class Account extends Component { ); } - renderActionbar () { - const { address } = this.props.params; - const { balances } = this.props; - const balance = balances[address]; - + renderActionbar (balance) { const showTransferButton = !!(balance && balance.tokens); const buttons = [ } - label='transfer' disabled={ !showTransferButton } - onClick={ this.onTransferClick } />, + icon={ } + key='transferFunds' + label={ + + } + onClick={ this.store.toggleTransferDialog } />, + } key='shapeshift' - icon={ } - label='shapeshift' - onClick={ this.onShapeshiftAccountClick } />, + label={ + + } + onClick={ this.store.toggleFundDialog } />, } - label='Verify' - onClick={ this.openVerification } />, + key='sms-verification' + label={ + + } + onClick={ this.store.toggleVerificationDialog } />, } key='editmeta' - icon={ } - label='edit' - onClick={ this.onEditClick } />, + label={ + + } + onClick={ this.store.toggleEditDialog } />, } key='passwordManager' - icon={ } - label='password' - onClick={ this.onPasswordClick } />, + label={ + + } + onClick={ this.store.togglePasswordDialog } />, } key='delete' - icon={ } - label='delete account' - onClick={ this.onDeleteClick } /> + label={ + + } + onClick={ this.store.toggleDeleteDialog } /> ]; return ( + buttons={ buttons } + title={ + + } /> ); } renderDeleteDialog (account) { - const { showDeleteDialog } = this.state; - - if (!showDeleteDialog) { + if (!this.store.isDeleteVisible) { return null; } return ( + onClose={ this.store.toggleDeleteDialog } /> ); } renderEditDialog (account) { - const { showEditDialog } = this.state; - - if (!showEditDialog) { + if (!this.store.isEditVisible) { return null; } return ( + onClose={ this.store.toggleEditDialog } /> ); } renderFundDialog () { - const { showFundDialog } = this.state; - - if (!showFundDialog) { + if (!this.store.isFundVisible) { return null; } @@ -202,36 +215,28 @@ class Account extends Component { return ( + onClose={ this.store.toggleFundDialog } /> ); } - renderVerificationDialog () { - if (!this.state.showVerificationDialog) { + renderPasswordDialog (account) { + if (!this.store.isPasswordVisible) { return null; } - const { address } = this.props.params; - return ( - + ); } - renderTransferDialog () { - const { showTransferDialog } = this.state; - - if (!showTransferDialog) { + renderTransferDialog (account, balance) { + if (!this.store.isTransferVisible) { return null; } - const { address } = this.props.params; - const { accounts, balances, images } = this.props; - const account = accounts[address]; - const balance = balances[address]; + const { balances, images } = this.props; return ( + onClose={ this.store.toggleTransferDialog } /> ); } - renderPasswordDialog () { - const { showPasswordDialog } = this.state; - - if (!showPasswordDialog) { + renderVerificationDialog () { + if (!this.store.isVerificationVisible) { return null; } const { address } = this.props.params; - const { accounts } = this.props; - const account = accounts[address]; return ( - + ); } - - onDeleteClick = () => { - this.setState({ showDeleteDialog: true }); - } - - onDeleteClose = () => { - this.setState({ showDeleteDialog: false }); - } - - onEditClick = () => { - this.setState({ - showEditDialog: !this.state.showEditDialog - }); - } - - onShapeshiftAccountClick = () => { - this.setState({ - showFundDialog: !this.state.showFundDialog - }); - } - - onShapeshiftAccountClose = () => { - this.onShapeshiftAccountClick(); - } - - openVerification = () => { - this.setState({ showVerificationDialog: true }); - } - - onVerificationClose = () => { - this.setState({ showVerificationDialog: false }); - } - - onTransferClick = () => { - this.setState({ - showTransferDialog: !this.state.showTransferDialog - }); - } - - onTransferClose = () => { - this.onTransferClick(); - } - - onPasswordClick = () => { - this.setState({ - showPasswordDialog: !this.state.showPasswordDialog - }); - } - - onPasswordClose = () => { - this.onPasswordClick(); - } } function mapStateToProps (state) { @@ -328,9 +277,9 @@ function mapStateToProps (state) { function mapDispatchToProps (dispatch) { return bindActionCreators({ - setVisibleAccounts, fetchCertifiers, - fetchCertifications + fetchCertifications, + setVisibleAccounts }, dispatch); } diff --git a/js/src/views/Account/account.spec.js b/js/src/views/Account/account.spec.js new file mode 100644 index 00000000000..33ca89588a2 --- /dev/null +++ b/js/src/views/Account/account.spec.js @@ -0,0 +1,226 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { shallow } from 'enzyme'; +import React from 'react'; + +import { ADDRESS, createRedux } from './account.test.js'; + +import Account from './'; + +let component; +let instance; +let store; + +function render (props) { + component = shallow( + , + { context: { store: createRedux() } } + ).find('Account').shallow(); + instance = component.instance(); + store = instance.store; + + return component; +} + +describe('views/Account', () => { + describe('rendering', () => { + beforeEach(() => { + render(); + }); + + it('renders defaults', () => { + expect(component).to.be.ok; + }); + + describe('sections', () => { + it('renders the Actionbar', () => { + expect(component.find('Actionbar')).to.have.length(1); + }); + + it('renders the Page', () => { + expect(component.find('Page')).to.have.length(1); + }); + + it('renders the Header', () => { + expect(component.find('Header')).to.have.length(1); + }); + + it('renders the Transactions', () => { + expect(component.find('Connect(Transactions)')).to.have.length(1); + }); + + it('renders no other sections', () => { + expect(component.find('div').children()).to.have.length(2); + }); + }); + }); + + describe('sub-renderers', () => { + describe('renderActionBar', () => { + let bar; + let barShallow; + + beforeEach(() => { + render(); + + bar = instance.renderActionbar({ tokens: {} }); + barShallow = shallow(bar); + }); + + it('renders the bar', () => { + expect(bar.type).to.match(/Actionbar/); + }); + + // TODO: Finding by index is not optimal, however couldn't find a better method atm + // since we cannot find by key (prop not visible in shallow debug()) + describe('clicks', () => { + it('toggles transfer on click', () => { + barShallow.find('Button').at(0).simulate('click'); + expect(store.isTransferVisible).to.be.true; + }); + + it('toggles fund on click', () => { + barShallow.find('Button').at(1).simulate('click'); + expect(store.isFundVisible).to.be.true; + }); + + it('toggles fund on click', () => { + barShallow.find('Button').at(1).simulate('click'); + expect(store.isFundVisible).to.be.true; + }); + + it('toggles verify on click', () => { + barShallow.find('Button').at(2).simulate('click'); + expect(store.isVerificationVisible).to.be.true; + }); + + it('toggles edit on click', () => { + barShallow.find('Button').at(3).simulate('click'); + expect(store.isEditVisible).to.be.true; + }); + + it('toggles password on click', () => { + barShallow.find('Button').at(4).simulate('click'); + expect(store.isPasswordVisible).to.be.true; + }); + + it('toggles delete on click', () => { + barShallow.find('Button').at(5).simulate('click'); + expect(store.isDeleteVisible).to.be.true; + }); + }); + }); + + describe('renderDeleteDialog', () => { + it('renders null when not visible', () => { + render(); + + expect(store.isDeleteVisible).to.be.false; + expect(instance.renderDeleteDialog()).to.be.null; + }); + + it('renders the modal when visible', () => { + render(); + + store.toggleDeleteDialog(); + expect(instance.renderDeleteDialog().type).to.match(/Connect/); + }); + }); + + describe('renderEditDialog', () => { + it('renders null when not visible', () => { + render(); + + expect(store.isEditVisible).to.be.false; + expect(instance.renderEditDialog()).to.be.null; + }); + + it('renders the modal when visible', () => { + render(); + + store.toggleEditDialog(); + expect(instance.renderEditDialog({ address: ADDRESS }).type).to.match(/Connect/); + }); + }); + + describe('renderFundDialog', () => { + it('renders null when not visible', () => { + render(); + + expect(store.isFundVisible).to.be.false; + expect(instance.renderFundDialog()).to.be.null; + }); + + it('renders the modal when visible', () => { + render(); + + store.toggleFundDialog(); + expect(instance.renderFundDialog().type).to.match(/Shapeshift/); + }); + }); + + describe('renderPasswordDialog', () => { + it('renders null when not visible', () => { + render(); + + expect(store.isPasswordVisible).to.be.false; + expect(instance.renderPasswordDialog()).to.be.null; + }); + + it('renders the modal when visible', () => { + render(); + + store.togglePasswordDialog(); + expect(instance.renderPasswordDialog({ address: ADDRESS }).type).to.match(/Connect/); + }); + }); + + describe('renderTransferDialog', () => { + it('renders null when not visible', () => { + render(); + + expect(store.isTransferVisible).to.be.false; + expect(instance.renderTransferDialog()).to.be.null; + }); + + it('renders the modal when visible', () => { + render(); + + store.toggleTransferDialog(); + expect(instance.renderTransferDialog().type).to.match(/Connect/); + }); + }); + + describe('renderVerificationDialog', () => { + it('renders null when not visible', () => { + render(); + + expect(store.isVerificationVisible).to.be.false; + expect(instance.renderVerificationDialog()).to.be.null; + }); + + it('renders the modal when visible', () => { + render(); + + store.toggleVerificationDialog(); + expect(instance.renderVerificationDialog().type).to.match(/Connect/); + }); + }); + }); +}); diff --git a/js/src/views/Account/account.test.js b/js/src/views/Account/account.test.js new file mode 100644 index 00000000000..d457bf7a154 --- /dev/null +++ b/js/src/views/Account/account.test.js @@ -0,0 +1,52 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import sinon from 'sinon'; + +const ADDRESS = '0x0123456789012345678901234567890123456789'; + +function createRedux () { + return { + dispatch: sinon.stub(), + subscribe: sinon.stub(), + getState: () => { + return { + balances: { + balances: { + [ADDRESS]: {} + } + }, + images: {}, + nodeStatus: { + isTest: false, + traceMode: false + }, + personal: { + accounts: { + [ADDRESS]: { + address: ADDRESS + } + } + } + }; + } + }; +} + +export { + ADDRESS, + createRedux +}; diff --git a/js/src/views/Account/store.js b/js/src/views/Account/store.js new file mode 100644 index 00000000000..e7655e9d7bc --- /dev/null +++ b/js/src/views/Account/store.js @@ -0,0 +1,50 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { action, observable } from 'mobx'; + +export default class Store { + @observable isDeleteVisible = false; + @observable isEditVisible = false; + @observable isFundVisible = false; + @observable isPasswordVisible = false; + @observable isTransferVisible = false; + @observable isVerificationVisible = false; + + @action toggleDeleteDialog = () => { + this.isDeleteVisible = !this.isDeleteVisible; + } + + @action toggleEditDialog = () => { + this.isEditVisible = !this.isEditVisible; + } + + @action toggleFundDialog = () => { + this.isFundVisible = !this.isFundVisible; + } + + @action togglePasswordDialog = () => { + this.isPasswordVisible = !this.isPasswordVisible; + } + + @action toggleTransferDialog = () => { + this.isTransferVisible = !this.isTransferVisible; + } + + @action toggleVerificationDialog = () => { + this.isVerificationVisible = !this.isVerificationVisible; + } +} diff --git a/js/src/views/Account/store.spec.js b/js/src/views/Account/store.spec.js new file mode 100644 index 00000000000..7035b97a704 --- /dev/null +++ b/js/src/views/Account/store.spec.js @@ -0,0 +1,84 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import Store from './store'; + +let store; + +function createStore () { + store = new Store(); +} + +describe('views/Account/Store', () => { + beforeEach(() => { + createStore(); + }); + + describe('constructor', () => { + it('sets all modal visibility to false', () => { + expect(store.isDeleteVisible).to.be.false; + expect(store.isEditVisible).to.be.false; + expect(store.isFundVisible).to.be.false; + expect(store.isPasswordVisible).to.be.false; + expect(store.isTransferVisible).to.be.false; + expect(store.isVerificationVisible).to.be.false; + }); + }); + + describe('@action', () => { + describe('toggleDeleteDialog', () => { + it('toggles the visibility', () => { + store.toggleDeleteDialog(); + expect(store.isDeleteVisible).to.be.true; + }); + }); + + describe('toggleEditDialog', () => { + it('toggles the visibility', () => { + store.toggleEditDialog(); + expect(store.isEditVisible).to.be.true; + }); + }); + + describe('toggleFundDialog', () => { + it('toggles the visibility', () => { + store.toggleFundDialog(); + expect(store.isFundVisible).to.be.true; + }); + }); + + describe('togglePasswordDialog', () => { + it('toggles the visibility', () => { + store.togglePasswordDialog(); + expect(store.isPasswordVisible).to.be.true; + }); + }); + + describe('toggleTransferDialog', () => { + it('toggles the visibility', () => { + store.toggleTransferDialog(); + expect(store.isTransferVisible).to.be.true; + }); + }); + + describe('toggleVerificationDialog', () => { + it('toggles the visibility', () => { + store.toggleVerificationDialog(); + expect(store.isVerificationVisible).to.be.true; + }); + }); + }); +}); diff --git a/js/src/views/Accounts/Summary/summary.js b/js/src/views/Accounts/Summary/summary.js index 55e868c081d..8658077a5c4 100644 --- a/js/src/views/Accounts/Summary/summary.js +++ b/js/src/views/Accounts/Summary/summary.js @@ -197,7 +197,7 @@ export default class Summary extends Component { } return ( - + ); } } diff --git a/js/test/mocha.config.js b/js/test/mocha.config.js index 3201cd4ac3b..2ab58455f74 100644 --- a/js/test/mocha.config.js +++ b/js/test/mocha.config.js @@ -26,6 +26,7 @@ injectTapEventPlugin(); import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import chaiEnzyme from 'chai-enzyme'; +import 'sinon-as-promised'; import sinonChai from 'sinon-chai'; import { WebSocket } from 'mock-socket'; import jsdom from 'jsdom';