diff --git a/package.json b/package.json index 64a6707bf..8899f3840 100644 --- a/package.json +++ b/package.json @@ -78,9 +78,9 @@ "imports-loader": "=0.6.5", "js-nacl": "=1.2.2", "json-loader": "=0.5.4", - "karma": "=1.6.0", + "karma": "^1.7.0", "karma-chai": "=0.1.0", - "karma-chrome-launcher": "=2.0.0", + "karma-chrome-launcher": "^2.2.0", "karma-coverage": "=1.1.1", "karma-jenkins-reporter": "0.0.2", "karma-mocha": "=1.3.0", @@ -103,6 +103,7 @@ "should": "=11.2.0", "sinon": "=2.0.0", "sinon-chai": "=2.8.0", + "sinon-stub-promise": "^4.0.0", "style-loader": "=0.16.1", "stylelint": "=7.12.0", "url-loader": "=0.5.7", diff --git a/src/actions/voting.js b/src/actions/voting.js new file mode 100644 index 000000000..2d3eae7f7 --- /dev/null +++ b/src/actions/voting.js @@ -0,0 +1,31 @@ +import actionTypes from '../constants/actions'; + +/** + * Add data to the list of voted delegates + */ +export const addedToVoteList = data => ({ + type: actionTypes.addedToVoteList, + data, +}); + +/** + * Remove data from the list of voted delegates + */ +export const removedFromVoteList = data => ({ + type: actionTypes.removedFromVoteList, + data, +}); + +/** + * Remove all data from the list of voted delegates and list of unvoted delegates + */ +export const clearVoteLists = () => ({ + type: actionTypes.votesCleared, +}); + +/** + * Add pending variable to the list of voted delegates and list of unvoted delegates + */ +export const pendingVotesAdded = () => ({ + type: actionTypes.pendingVotesAdded, +}); diff --git a/src/actions/voting.test.js b/src/actions/voting.test.js new file mode 100644 index 000000000..1e17e99e2 --- /dev/null +++ b/src/actions/voting.test.js @@ -0,0 +1,50 @@ +import { expect } from 'chai'; +import actionTypes from '../constants/actions'; +import { + addedToVoteList, + removedFromVoteList, + clearVoteLists, + pendingVotesAdded, +} from './voting'; + +describe('actions: voting', () => { + const data = { + label: 'dummy', + }; + + describe('addedToVoteList', () => { + it('should create an action to add data to vote list', () => { + const expectedAction = { + data, + type: actionTypes.addedToVoteList, + }; + expect(addedToVoteList(data)).to.be.deep.equal(expectedAction); + }); + }); + + describe('removedFromVoteList', () => { + it('should create an action to remove data from vote list', () => { + const expectedAction = { + data, + type: actionTypes.removedFromVoteList, + }; + expect(removedFromVoteList(data)).to.be.deep.equal(expectedAction); + }); + }); + describe('clearVoteLists', () => { + it('should create an action to remove all pending rows from vote list', () => { + const expectedAction = { + type: actionTypes.votesCleared, + }; + expect(clearVoteLists()).to.be.deep.equal(expectedAction); + }); + }); + describe('pendingVotesAdded', () => { + it('should create an action to remove all pending rows from vote list', () => { + const expectedAction = { + type: actionTypes.pendingVotesAdded, + }; + expect(pendingVotesAdded()).to.be.deep.equal(expectedAction); + }); + }); +}); diff --git a/src/components/voting/confirmVotes.js b/src/components/voting/confirmVotes.js new file mode 100644 index 000000000..d13cc7d11 --- /dev/null +++ b/src/components/voting/confirmVotes.js @@ -0,0 +1,116 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Input from 'react-toolbox/lib/input'; +import { vote } from '../../utils/api/delegate'; +import { alertDialogDisplayed } from '../../actions/dialog'; +import { clearVoteLists, pendingVotesAdded } from '../../actions/voting'; +import InfoParagraph from '../infoParagraph'; +import ActionBar from '../actionBar'; +import { SYNC_ACTIVE_INTERVAL } from '../../constants/api'; +import Fees from '../../constants/fees'; + +export class ConfirmVotes extends React.Component { + constructor() { + super(); + this.state = { + secondSecret: '', + }; + } + + confirm() { + const secondSecret = this.state.secondSecret.length === 0 ? null : this.state.secondSecret; + const text = 'Your votes were successfully submitted. It can take several seconds before they are processed.'; + + vote( + this.props.activePeer, + this.props.account.passphrase, + this.props.account.publicKey, + this.props.votedList, + this.props.unvotedList, + secondSecret, + ).then((data) => { + this.props.pendingVotesAdded(); + + // add to pending transaction + this.props.addTransaction({ + id: data.transactionId, + senderPublicKey: this.props.account.publicKey, + senderId: this.props.account.address, + amount: 0, + fee: Fees.vote, + type: 3, + }); + + // remove pending votes + setTimeout(() => { + this.props.clearVoteLists(); + }, SYNC_ACTIVE_INTERVAL); + this.props.showSuccessAlert({ + title: 'Success', + type: 'success', + text, + }); + }); + } + + setSecondPass(name, value) { + this.setState({ ...this.state, [name]: value }); + } + + render() { + const secondPassphrase = this.props.account.secondSignature === 1 ? + : null; + + return ( +
+

Add vote to

+ +

Remove vote from

+ + + {secondPassphrase} + + + You can select up to 33 delegates in one voting turn. +
+ You can vote for up to 101 delegates in total. +
+ + +
+ ); + } +} + + +const mapStateToProps = state => ({ + votedList: state.voting.votedList, + unvotedList: state.voting.unvotedList, + account: state.account, + activePeer: state.peers.data, +}); + +const mapDispatchToProps = dispatch => ({ + showSuccessAlert: data => dispatch(alertDialogDisplayed(data)), + clearVoteLists: () => dispatch(clearVoteLists()), + pendingVotesAdded: () => dispatch(pendingVotesAdded()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ConfirmVotes); diff --git a/src/components/voting/confirmVotes.test.js b/src/components/voting/confirmVotes.test.js new file mode 100644 index 000000000..3b0fca9fd --- /dev/null +++ b/src/components/voting/confirmVotes.test.js @@ -0,0 +1,85 @@ +import React from 'react'; +import chai, { expect } from 'chai'; +import { mount } from 'enzyme'; +import chaiEnzyme from 'chai-enzyme'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import PropTypes from 'prop-types'; +import sinonStubPromise from 'sinon-stub-promise'; +import store from '../../store'; +import ConfrimVotesContainer, { ConfirmVotes } from './confirmVotes'; +import * as delegateApi from '../../utils/api/delegate'; + +sinonStubPromise(sinon); +chai.use(sinonChai); +chai.use(chaiEnzyme()); + +const props = { + activePeer: {}, + account: { + passphrase: 'pass', + publicKey: 'key', + secondSignature: 0, + }, + votedList: [ + { + username: 'yashar', + }, + { + username: 'tom', + }, + ], + unvotedList: [ + { + username: 'john', + }, + { + username: 'test', + }, + ], + closeDialog: sinon.spy(), + showSuccessAlert: sinon.spy(), + clearVoteLists: sinon.spy(), + pendingVotesAdded: sinon.spy(), + addTransaction: sinon.spy(), +}; +describe('ConfrimVotesContainer', () => { + it('should render ConfrimVotes', () => { + const wrapper = mount(, { + context: { store }, + childContextTypes: { store: PropTypes.object.isRequired }, + }); + expect(wrapper.find('ConfirmVotes').exists()).to.be.equal(true); + }); +}); +describe('ConfrimVotes', () => { + let wrapper; + const delegateApiMock = sinon.stub(delegateApi, 'vote'); + beforeEach(() => { + wrapper = mount(, { + context: { store }, + childContextTypes: { store: PropTypes.object.isRequired }, + }); + }); + + it('should call vote api when confirm button is pressed', () => { + const clock = sinon.useFakeTimers(); + delegateApiMock.returnsPromise().resolves({ success: true }); + wrapper.instance().confirm(); + expect(props.pendingVotesAdded).to.have.been.calledWith(); + expect(props.addTransaction).to.have.been.calledWith(); + expect(props.showSuccessAlert).to.have.been.calledWith(); + // it should triger 'props.clearVoteLists' after 10000 ms + clock.tick(10000); + expect(props.clearVoteLists).to.have.been.calledWith(); + }); + + it('should update state when "setSecondPass" is called', () => { + wrapper.setProps({ + account: Object.assign(props.account, { secondSignature: 1 }), + }); + wrapper.find('.secondSecret input').simulate('change', { target: { value: 'this is test' } }); + expect(wrapper.state('secondSecret')).to.be.equal('this is test'); + }); +}); + diff --git a/src/components/voting/index.js b/src/components/voting/index.js index 95ae660ac..34c2f6058 100644 --- a/src/components/voting/index.js +++ b/src/components/voting/index.js @@ -1,9 +1,12 @@ import { connect } from 'react-redux'; -import VotingComponent from './votingComponent'; +import Voting from './voting'; const mapStateToProps = state => ({ address: state.account.address, activePeer: state.peers.data, + votedList: state.voting.votedList, + unvotedList: state.voting.unvotedList, + refreshDelegates: state.voting.refresh, }); -export default connect(mapStateToProps)(VotingComponent); +export default connect(mapStateToProps)(Voting); diff --git a/src/components/voting/index.test.js b/src/components/voting/index.test.js index 9d385c737..6b4692676 100644 --- a/src/components/voting/index.test.js +++ b/src/components/voting/index.test.js @@ -13,6 +13,6 @@ describe('Voting', () => { }); it('should render VotingComponent', () => { - expect(wrapper.find('VotingComponent')).to.have.lengthOf(1); + expect(wrapper.find('Voting')).to.have.lengthOf(1); }); }); diff --git a/src/components/voting/voteCheckbox.js b/src/components/voting/voteCheckbox.js new file mode 100644 index 000000000..1f2f3a576 --- /dev/null +++ b/src/components/voting/voteCheckbox.js @@ -0,0 +1,39 @@ +import React from 'react'; +import Checkbox from 'react-toolbox/lib/checkbox'; +import { connect } from 'react-redux'; +import { addedToVoteList, removedFromVoteList } from '../../actions/voting'; +import Spinner from '../spinner'; + +export class VoteCheckbox extends React.Component { + /** + * change status of selected row + * @param {Number} index - index of row that we want to change status of that + * @param {Boolean} value - value of checkbox + */ + toggle(delegate, value) { + if (value) { + this.props.addToVoteList(delegate); + } else { + this.props.removeFromVoteList(delegate); + } + } + + render() { + const template = this.props.pending ? + : + ; + return template; + } +} + +const mapDispatchToProps = dispatch => ({ + addToVoteList: data => dispatch(addedToVoteList(data)), + removeFromVoteList: data => dispatch(removedFromVoteList(data)), +}); + +export default connect(null, mapDispatchToProps)(VoteCheckbox); + diff --git a/src/components/voting/voteCheckbox.test.js b/src/components/voting/voteCheckbox.test.js new file mode 100644 index 000000000..5c5f869a6 --- /dev/null +++ b/src/components/voting/voteCheckbox.test.js @@ -0,0 +1,73 @@ +import React from 'react'; +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { mount } from 'enzyme'; +import configureStore from 'redux-mock-store'; +import Checkbox, { VoteCheckbox } from './voteCheckbox'; +import styles from './voting.css'; + +chai.use(sinonChai); +const mockStore = configureStore(); + +describe('Checkbox', () => { + const props = { + store: mockStore({ runtime: {} }), + data: { + username: 'yashar', + address: 'address 1', + }, + styles, + pending: false, + value: true, + addToVoteList: () => true, + removeFromVoteList: () => true, + }; + it('it should expose onAccountUpdated as function', () => { + const wrapper = mount(); + expect(typeof wrapper.props().addToVoteList).to.equal('function'); + }); + + it('it should expose removeFromVoteList as function', () => { + const wrapper = mount(); + expect(typeof wrapper.props().removeFromVoteList).to.equal('function'); + }); +}); +describe('VoteCheckbox', () => { + let wrapper; + const props = { + store: mockStore({ runtime: {} }), + data: { + username: 'yashar', + address: 'address 1', + }, + styles, + pending: false, + value: true, + addToVoteList: sinon.spy(), + removeFromVoteList: sinon.spy(), + }; + + beforeEach(() => { + wrapper = mount(); + }); + + it('should render a Spinner When pending is true', () => { + wrapper.setProps({ pending: true }); + expect(wrapper.find('Spinner').exists()).to.be.equal(true); + }); + + it('should render a Checkbox is false', () => { + expect(wrapper.find('Checkbox').exists()).to.be.equal(true); + }); + + it('should Checkbox change event should call this.props.addToVoteList when value is true', () => { + wrapper.instance().toggle(props.data, true); + expect(props.addToVoteList).to.have.been.calledWith(props.data); + }); + + it('should Checkbox change event should call this.props.removeFromVoteList when value is false', () => { + wrapper.instance().toggle(props.data, false); + expect(props.removeFromVoteList).to.have.been.calledWith(props.data); + }); +}); diff --git a/src/components/voting/voting.css b/src/components/voting/voting.css index 5a0e23827..8541aca94 100644 --- a/src/components/voting/voting.css +++ b/src/components/voting/voting.css @@ -8,7 +8,7 @@ } .searchIcon{ position: absolute; - top: 27px; + top: 15px; right: 10px; color: rgba(0, 0, 0, .38); } @@ -32,3 +32,43 @@ .pendingRow { background-color: #eaeae9 !important; } +.actionBar{ + margin-top: 9px; + display: inline-block; +} +.votesMenuButton{ + margin-right: 16px; + margin-top: 8px; + & :global span{ + vertical-align: top; + line-height: 24px; + margin-left: 6px; + } +} +.voted { + color: #7cb342; +} +.unvoted { + color: #c62828; +} +/* react toolbar overwroght */ +.input{ + margin-top: -15px; +} +.menuItem{ + flex-direction: row-reverse; + width: 241px; +} +.icon{ + text-align: right; + width: auto +} +.menuInner{ + height: 306px; + overflow-y: auto; +} +.button{ + width: auto; + margin-top: 18px; + margin-right: 16px; +} diff --git a/src/components/voting/votingComponent.js b/src/components/voting/voting.js similarity index 50% rename from src/components/voting/votingComponent.js rename to src/components/voting/voting.js index 89182a6b9..0f5c196ce 100644 --- a/src/components/voting/votingComponent.js +++ b/src/components/voting/voting.js @@ -1,45 +1,69 @@ import React from 'react'; -import { Table, TableHead, TableRow, TableCell } from 'react-toolbox/lib/table'; +import { themr } from 'react-css-themr'; +import { TABLE } from 'react-toolbox/lib/identifiers'; +import { tableFactory } from 'react-toolbox/lib/table/Table'; +import { TableHead, TableCell } from 'react-toolbox/lib/table'; +import TableTheme from 'react-toolbox/lib/table/theme.css'; import Waypoint from 'react-waypoint'; -import Checkbox from 'react-toolbox/lib/checkbox'; import { listAccountDelegates, listDelegates } from '../../utils/api/delegate'; -import VotingHeader from './votingHeader'; -import styles from './voting.css'; +import Header from './votingHeaderWrapper'; +import VotingRow from './votingRow'; -const setRowClass = (item) => { - let className = ''; - if (item.status.selected && item.status.voted) { - className = styles.votedRow; - } else if (!item.status.selected && item.status.voted) { - className = styles.downVoteRow; - } else if (item.status.selected && !item.status.voted) { - className = styles.upVoteRow; - } - return className; -}; +// Create a new Table component injecting Head and Row +const Table = themr(TABLE, TableTheme)(tableFactory(TableHead, VotingRow)); -class VotingComponent extends React.Component { +class Voting extends React.Component { constructor() { super(); this.state = { delegates: [], + votedDelegates: [], selected: [], - query: '', offset: 0, - loadMore: true, + loadMore: false, length: 1, notFound: '', }; - this.delegates = []; this.query = ''; } + + componentWillReceiveProps() { + setTimeout(() => { + if (this.props.refreshDelegates) { + this.loadVotedDelegates(true); + } else { + const delegates = this.state.delegates.map(delegate => this.setStatus(delegate)); + this.setState({ + delegates, + }); + } + }, 1); + } + componentDidMount() { + this.loadVotedDelegates(); + } + + loadVotedDelegates(refresh) { listAccountDelegates(this.props.activePeer, this.props.address).then((res) => { - this.votedDelegates = []; - res.delegates.forEach(delegate => this.votedDelegates.push(delegate.username)); - this.loadDelegates(this.query); + const votedDelegates = res.delegates + .map(delegate => Object.assign({}, delegate, { voted: true })); + this.setState({ + votedDelegates, + }); + if (refresh) { + setTimeout(() => { + const delegates = this.state.delegates.map(delegate => this.setStatus(delegate)); + this.setState({ + delegates, + }); + }, 10); + } else { + this.loadDelegates(this.query); + } }); } + /** * Fetches a list of delegates based on the given search phrase * @param {string} query - username of a delegate @@ -54,8 +78,9 @@ class VotingComponent extends React.Component { }); setTimeout(() => { this.loadDelegates(this.query); - }, 100); + }, 1); } + /** * Fetches a list of delegates * @@ -66,16 +91,16 @@ class VotingComponent extends React.Component { */ loadDelegates(search, limit = 100) { this.setState({ loadMore: false }); + listDelegates( - this.props.activePeer, - { + this.props.activePeer, { offset: this.state.offset, limit: limit.toString(), q: search, }, ).then((res) => { const delegatesList = res.delegates - .map(delegate => this.setStatus(delegate)); + .map(delegate => this.setStatus(delegate)); this.setState({ delegates: [...this.state.delegates, ...delegatesList], offset: this.state.offset + delegatesList.length, @@ -85,29 +110,34 @@ class VotingComponent extends React.Component { }); }); } + /** - * Sets deleagte.status to be always the same object for given delegate.address + * Sets delegate.status to be always the same object for given delegate.address */ setStatus(delegate) { - let item = delegate;// eslint-disable-line - const voted = this.votedDelegates.indexOf(delegate.username) > -1; - item.status = { - voted, - selected: voted, - }; - return item; + let delegateExisted = false; + if (this.props.unvotedList.length > 0) { + this.props.unvotedList.forEach((row) => { + if (row.address === delegate.address) { + delegateExisted = row; + } + }); + } + if (this.props.votedList.length > 0) { + this.props.votedList.forEach((row) => { + if (row.address === delegate.address) { + delegateExisted = row; + } + }); + } + if (delegateExisted) { + return delegateExisted; + } + const voted = this.state.votedDelegates + .filter(row => row.username === delegate.username).length > 0; + return Object.assign(delegate, { voted }, { selected: voted }, { pending: false }); } - /** - * change status of selected row - * @param {integer} index - index of row that we want to change status of that - * @param {boolian} value - value of checkbox - */ - handleChange(index, value) { - let delegates = this.state.delegates; // eslint-disable-line - delegates[index].status.selected = value; - this.setState({ delegates }); - } /** * load more data when scroll bar reachs end of the page */ @@ -116,12 +146,15 @@ class VotingComponent extends React.Component { this.loadDelegates(this.query); } } + render() { return (
- this.search(value) }> - +
this.search(value) } + /> +
Vote Rank @@ -131,19 +164,7 @@ class VotingComponent extends React.Component { Approval {this.state.delegates.map((item, idx) => ( - - - - - {item.rank} - {item.username} - {item.address} - {item.productivity} % - {item.approval} % - + ))}
{this.state.notFound} @@ -153,4 +174,4 @@ class VotingComponent extends React.Component { } } -export default VotingComponent; +export default Voting; diff --git a/src/components/voting/voting.test.js b/src/components/voting/voting.test.js new file mode 100644 index 000000000..13159f155 --- /dev/null +++ b/src/components/voting/voting.test.js @@ -0,0 +1,123 @@ +import React from 'react'; +import chai, { expect } from 'chai'; +import { mount } from 'enzyme'; +import chaiEnzyme from 'chai-enzyme'; +import PropTypes from 'prop-types'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import sinonStubPromise from 'sinon-stub-promise'; +import Voting from './voting'; +import store from '../../store'; +import * as delegateApi from '../../utils/api/delegate'; + +sinonStubPromise(sinon); +chai.use(sinonChai); +chai.use(chaiEnzyme()); + +describe('Voting', () => { + let wrapper; + + const listAccountDelegatesMock = sinon.stub(delegateApi, 'listAccountDelegates'); + let listDelegatesMock; + listAccountDelegatesMock.returnsPromise().resolves({ + delegates: [ + { + address: 'address 1', + }, + { + address: 'address 1', + }, + ], + }); + const props = { + refreshDelegates: false, + activePeer: {}, + address: '16313739661670634666L', + votedList: [], + unvotedList: [], + }; + beforeEach(() => { + sinon.spy(Voting.prototype, 'loadVotedDelegates'); + sinon.spy(Voting.prototype, 'loadDelegates'); + sinon.spy(Voting.prototype, 'setStatus'); + listDelegatesMock = sinon.stub(delegateApi, 'listDelegates'); + listDelegatesMock.returnsPromise().resolves({ + delegates: [ + { + address: 'address 1', + }, + { + address: 'address 1', + }, + ], + totalCount: 110, + }); + wrapper = mount(, + { + context: { store }, + childContextTypes: { store: PropTypes.object.isRequired }, + }, + ); + }); + + afterEach(() => { + Voting.prototype.loadVotedDelegates.restore(); + Voting.prototype.loadDelegates.restore(); + Voting.prototype.setStatus.restore(); + listDelegatesMock.restore(); + }); + + it('should call "loadVotedDelegates" after component did mount', () => { + expect(Voting.prototype.loadVotedDelegates).to.have.property('callCount', 1); + expect(wrapper.state('votedDelegates')).to.have.lengthOf(2); + expect(Voting.prototype.loadDelegates).to.have.property('callCount', 1); + expect(Voting.prototype.setStatus).to.have.property('callCount', 2); + }); + + + it('should call "loadVotedDelegates" twice when "refreshDelegates" is changed to true', () => { + const clock = sinon.useFakeTimers(); + clock.tick(100); + wrapper.setProps({ refreshDelegates: true }); + // it should triger 'wrapper.loadDelegates' after 1 ms + clock.tick(1); + + expect(Voting.prototype.loadVotedDelegates).to.have.property('callCount', 2); + clock.tick(10); + expect(Voting.prototype.loadDelegates).to.have.property('callCount', 1); + }); + + + it('should call "loadVotedDelegates" once when "refreshDelegates" is not changed', () => { + const clock = sinon.useFakeTimers(); + clock.tick(100); + wrapper.setProps({ votedList: false }); + // it should triger 'wrapper.loadDelegates' after 1 ms + clock.tick(1); + + expect(Voting.prototype.loadVotedDelegates).to.have.property('callCount', 1); + clock.tick(10); + expect(Voting.prototype.setStatus).to.have.property('callCount', 4); + }); + + it('should "loadMore" calls "loadDelegates" when state.loadMore is true', () => { + wrapper.instance().loadMore(); + expect(Voting.prototype.loadDelegates).to.have.property('callCount', 2); + }); + + it('should "search" function call "loadDelegates"', () => { + wrapper.instance().search('query'); + const clock = sinon.useFakeTimers(); + // it should triger 'wrapper.loadDelegates' after 1 ms + clock.tick(100); + expect(wrapper.instance().query).to.be.equal('query'); + }); + it('should render VotingHeader', () => { + expect(wrapper.find('VotingHeader')).to.have.lengthOf(1); + }); + + it('should render Table', () => { + expect(wrapper.find('Table')).to.have.lengthOf(1); + }); +}); + diff --git a/src/components/voting/votingComponent.test.js b/src/components/voting/votingComponent.test.js deleted file mode 100644 index 1d875acd7..000000000 --- a/src/components/voting/votingComponent.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { expect } from 'chai'; -import { mount } from 'enzyme'; -import VotingComponent from './votingComponent'; - -describe('VotingComponent', () => { - const address = '16313739661670634666L'; - const activePeer = {}; - let wrapper; - - beforeEach(() => { - wrapper = mount(); - }); - - it('should render VotingHeader', () => { - expect(wrapper.find('VotingHeader')).to.have.lengthOf(1); - }); - - it('should render Table', () => { - expect(wrapper.find('Table')).to.have.lengthOf(1); - }); -}); diff --git a/src/components/voting/votingHeader.js b/src/components/voting/votingHeader.js index 16ec70a64..c7a29fe6a 100644 --- a/src/components/voting/votingHeader.js +++ b/src/components/voting/votingHeader.js @@ -1,7 +1,10 @@ import React from 'react'; import grid from 'flexboxgrid/dist/flexboxgrid.css'; +import { Button } from 'react-toolbox/lib/button'; +import { IconMenu, MenuItem } from 'react-toolbox/lib/menu'; import Input from 'react-toolbox/lib/input'; import styles from './voting.css'; +import Confirm from './confirmVotes'; class VotingHeader extends React.Component { constructor() { @@ -11,6 +14,7 @@ class VotingHeader extends React.Component { searchIcon: 'search', }; } + search(name, value) { const icon = value.length > 0 ? 'close' : 'search'; this.setState({ @@ -19,23 +23,63 @@ class VotingHeader extends React.Component { }); this.props.search(value); } + clearSearch() { if (this.state.searchIcon === 'close') { this.search('query', ''); } } + + confirmVoteText() { + let info = 'VOTE'; + const voted = this.props.votedList.filter(item => !item.pending).length; + const unvoted = this.props.unvotedList.filter(item => !item.pending).length; + if (voted > 0 || unvoted > 0) { + const seprator = (voted > 0 && unvoted > 0) ? ' / ' : ''; // eslint-disable-line + const votedHtml = voted > 0 ? +{voted} : ''; + const unvotedHtml = unvoted > 0 ? -{unvoted} : ''; + info = VOTE ({votedHtml}{seprator}{unvotedHtml}); + } + return info; + } + render() { + const button =
+ visibility + my votes ({this.props.votedDelegates.length}) +
; return ( -
+
- + {this.state.searchIcon}
+
+ + {this.props.votedDelegates.map(delegate => + )} + +
); } diff --git a/src/components/voting/votingHeader.test.js b/src/components/voting/votingHeader.test.js index fc1eb4edc..d30e6ef97 100644 --- a/src/components/voting/votingHeader.test.js +++ b/src/components/voting/votingHeader.test.js @@ -3,45 +3,92 @@ import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { mount } from 'enzyme'; +import configureStore from 'redux-mock-store'; +import PropTypes from 'prop-types'; import VotingHeader from './votingHeader'; chai.use(sinonChai); describe('VotingHeader', () => { - const search = sinon.spy(); let wrapper; + const mockStore = configureStore(); + const props = { + store: mockStore({ runtime: {} }), + search: sinon.spy(), + votedDelegates: [ + { + username: 'yashar', + address: 'address 1', + }, + { + username: 'tom', + address: 'address 2', + }, + ], + votedList: [ + { + username: 'yashar', + address: 'address 1', + pending: true, + }, + { + username: 'tom', + address: 'address 2', + }, + ], + unvotedList: [ + { + username: 'yashar', + address: 'address 1', + }, + { + username: 'tom', + address: 'address 2', + pending: true, + }, + ], + setActiveDialog: () => {}, + addToUnvoted: sinon.spy(), + }; + beforeEach(() => { - wrapper = mount(); + wrapper = mount(, { + context: { store: mockStore }, + childContextTypes: { store: PropTypes.object.isRequired }, + }); }); it('should render an Input', () => { expect(wrapper.find('Input')).to.have.lengthOf(1); }); + it('should render 2 menuItem', () => { + expect(wrapper.find('MenuItem')).to.have.lengthOf(2); + }); - it('should render i.material-icons with text of "search" when this.search is not called', () => { + it('should render i#searchIcon with text of "search" when this.search is not called', () => { // expect(wrapper.find('i.material-icons')).to.have.lengthOf(1); - expect(wrapper.find('i.material-icons').text()).to.be.equal('search'); + expect(wrapper.find('#searchIcon').text()).to.be.equal('search'); }); - it('should render i.material-icons with text of "close" when this.search is called', () => { + it('should render i#searchIcon with text of "close" when this.search is called', () => { wrapper.instance().search('query', '555'); - expect(wrapper.find('i.material-icons').text()).to.be.equal('close'); + expect(wrapper.find('#searchIcon').text()).to.be.equal('close'); }); it('should this.props.search when this.search is called', () => { wrapper.instance().search('query', '555'); - expect(search).to.have.been.calledWith('555'); + expect(props.search).to.have.been.calledWith('555'); }); it('should this.props.search when this.search is called', () => { wrapper.instance().search('query', '555'); - expect(search).to.have.been.calledWith('555'); + expect(props.search).to.have.been.calledWith('555'); }); - it('click on i.material-icons should clear vlaue of search input', () => { + it('click on #searchIcon should clear vlaue of search input', () => { wrapper.instance().search('query', '555'); - wrapper.find('i.material-icons').simulate('click'); + wrapper.find('#searchIcon').simulate('click'); expect(wrapper.state('query')).to.be.equal(''); }); }); diff --git a/src/components/voting/votingHeaderWrapper.js b/src/components/voting/votingHeaderWrapper.js new file mode 100644 index 000000000..0322b6d2f --- /dev/null +++ b/src/components/voting/votingHeaderWrapper.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import VotingHeader from './votingHeader'; +import { dialogDisplayed } from '../../actions/dialog'; +import { removedFromVoteList } from '../../actions/voting'; +import { transactionAdded } from '../../actions/transactions'; + + +const mapDispatchToProps = dispatch => ({ + setActiveDialog: data => dispatch(dialogDisplayed(data)), + addToUnvoted: data => dispatch(removedFromVoteList(data)), + addTransaction: data => dispatch(transactionAdded(data)), +}); +const mapStateToProps = state => ({ + votedList: state.voting.votedList, + unvotedList: state.voting.unvotedList, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(VotingHeader); diff --git a/src/components/voting/votingRow.js b/src/components/voting/votingRow.js new file mode 100644 index 000000000..e2939888d --- /dev/null +++ b/src/components/voting/votingRow.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { TableRow, TableCell } from 'react-toolbox/lib/table'; +import styles from './voting.css'; +import Checkbox from './voteCheckbox'; + +const setRowClass = ({ pending, selected, voted }) => { + if (pending) { + return styles.pendingRow; + } else if (selected) { + return voted ? styles.votedRow : styles.upVoteRow; + } + return voted ? styles.downVoteRow : ''; +}; + +const VotingRow = (props) => { + const { data } = props; + return ( + + + + {data.rank} + {data.username} + {data.address} + {data.productivity} % + {data.approval} % + + ); +}; + +export default VotingRow; diff --git a/src/components/voting/votingRow.test.js b/src/components/voting/votingRow.test.js new file mode 100644 index 000000000..bef85ba6c --- /dev/null +++ b/src/components/voting/votingRow.test.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import PropTypes from 'prop-types'; +import store from '../../store'; +import VotinRow from './votingRow'; + +describe('VotinRow', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(, + { + context: { store }, + childContextTypes: { store: PropTypes.object.isRequired }, + }, + ); + }); + + it('should TableRow has class name of "pendingRow" when props.data.pending is true', () => { + wrapper.setProps({ + data: { pending: true }, + }); + const expectedClass = /_pendingRow/g; + const html = wrapper.find('tr').html(); + expect(html.match(expectedClass)).to.have.lengthOf(1); + }); + + it(`should TableRow has class name of "votedRow" when props.data.selected + and props.data.voted are true`, () => { + wrapper.setProps({ + data: { selected: true, voted: true }, + }); + const expectedClass = /_votedRow/g; + const html = wrapper.find('tr').html(); + expect(html.match(expectedClass)).to.have.lengthOf(1); + }); + + it(`should TableRow has class name of "downVoteRow" when props.data.selected + is false and props.data.voted is true`, () => { + wrapper.setProps({ + data: { selected: false, voted: true }, + }); + const expectedClass = /_downVoteRow/g; + const html = wrapper.find('tr').html(); + expect(html.match(expectedClass)).to.have.lengthOf(1); + }); + + it(`should TableRow has class name of "upVoteRow" when props.data.selected + is true and props.data.voted is false`, () => { + wrapper.setProps({ + data: { selected: true, voted: false }, + }); + const expectedClass = /_upVoteRow/g; + const html = wrapper.find('tr').html(); + expect(html.match(expectedClass)).to.have.lengthOf(1); + }); +}); diff --git a/src/constants/actions.js b/src/constants/actions.js index f5ad1bf58..b2a954b84 100644 --- a/src/constants/actions.js +++ b/src/constants/actions.js @@ -8,6 +8,10 @@ const actionTypes = { dialogHidden: 'DIALOG_HIDDEN', forgedBlocksUpdated: 'FORGED_BLOCKS_UPDATED', forgingStatsUpdated: 'FORGING_STATS_UPDATED', + addedToVoteList: 'ADDED_TO_VOTE_LIST', + removedFromVoteList: 'REMOVEd_FROM_VOTE_LIST', + votesCleared: 'VOTES_CLEARED', + pendingVotesAdded: 'PENDING_VOTES_ADDED', toastDisplayed: 'TOAST_DISPLAYED', toastHidden: 'TOAST_HIDDEN', loadingStarted: 'LOADING_STARTED', diff --git a/src/store/reducers/index.js b/src/store/reducers/index.js index 469721e96..e41eb9934 100644 --- a/src/store/reducers/index.js +++ b/src/store/reducers/index.js @@ -2,7 +2,7 @@ export { default as account } from './account'; export { default as peers } from './peers'; export { default as dialog } from './dialog'; export { default as forging } from './forging'; +export { default as voting } from './voting'; export { default as loading } from './loading'; export { default as toaster } from './toaster'; export { default as transactions } from './transactions'; - diff --git a/src/store/reducers/voting.js b/src/store/reducers/voting.js new file mode 100644 index 000000000..f72f9f6a8 --- /dev/null +++ b/src/store/reducers/voting.js @@ -0,0 +1,86 @@ +import actionTypes from '../../constants/actions'; +/** + * remove a gelegate from list of delegates + * + * @param {array} list - list for delegates + * @param {object} item - a delegates that we want to remove it + */ +const removeFromList = (list, item) => { + const address = item.address; + return list.filter(delegate => delegate.address !== address); +}; +/** + * find index of a gelegate in list of delegates + * + * @param {array} list - list for delegates + * @param {object} item - a delegates that we want to find its index + */ +const findItemInList = (list, item) => { + const address = item.address; + let idx = -1; + list.forEach((delegate, index) => { + if (delegate.address === address) { + idx = index; + } + }); + return idx; +}; +/** + * voting reducer + * + * @param {Object} state + * @param {Object} action + */ +const voting = (state = { votedList: [], unvotedList: [] }, action) => { + switch (action.type) { + case actionTypes.addedToVoteList: + if (action.data.voted) { + return Object.assign({}, state, { + refresh: false, + unvotedList: [...removeFromList(state.unvotedList, action.data)], + }); + } + if (findItemInList(state.votedList, action.data) > -1) { + return state; + } + return Object.assign({}, state, { + refresh: false, + votedList: [ + ...state.votedList, + Object.assign(action.data, { selected: true }), + ], + }); + case actionTypes.removedFromVoteList: + if (!action.data.voted) { + return Object.assign({}, state, { + refresh: false, + votedList: [...removeFromList(state.votedList, action.data)], + }); + } + if (findItemInList(state.unvotedList, action.data) > -1) { + return state; + } + return Object.assign({}, state, { + refresh: false, + unvotedList: [ + ...state.unvotedList, + Object.assign(action.data, { selected: false }), + ], + }); + case actionTypes.votesCleared: + return Object.assign({}, state, { + votedList: state.votedList.filter(item => !item.pending), + unvotedList: state.unvotedList.filter(item => !item.pending), + refresh: true, + }); + case actionTypes.pendingVotesAdded: + return Object.assign({}, state, { + votedList: state.votedList.map(item => Object.assign(item, { pending: true })), + unvotedList: state.unvotedList.map(item => Object.assign(item, { pending: true })), + }); + default: + return state; + } +}; + +export default voting; diff --git a/src/store/reducers/voting.test.js b/src/store/reducers/voting.test.js new file mode 100644 index 000000000..57a63abb5 --- /dev/null +++ b/src/store/reducers/voting.test.js @@ -0,0 +1,140 @@ +import { expect } from 'chai'; +import actionTypes from '../../constants/actions'; +import voting from './voting'; + +describe('Reducer: voting(state, action)', () => { + const state = { + votedList: [ + { + address: 'voted address1', + }, + { + address: 'voted address2', + }, + ], + unvotedList: [ + { + address: 'unvoted address1', + }, + { + address: 'unvoted address2', + }, + ], + }; + it('should render default state', () => { + const action = { + type: '', + }; + const changedState = voting(state, action); + expect(changedState).to.be.equal(state); + }); + it('should be 1 items in state.unvotedList', () => { + const action = { + type: actionTypes.addedToVoteList, + data: { + voted: true, + address: 'unvoted address1', + }, + }; + const changedState = voting(state, action); + expect(changedState.unvotedList).to.have.lengthOf(1); + }); + + it('should return state if action.data existed in votedList before', () => { + const action = { + type: actionTypes.addedToVoteList, + data: { + address: 'voted address1', + }, + }; + const changedState = voting(state, action); + expect(changedState).to.be.deep.equal(state); + }); + + it('should be 3 items in state.votedList', () => { + const action = { + type: actionTypes.addedToVoteList, + data: { + address: 'voted address3', + }, + }; + const changedState = voting(state, action); + expect(changedState.votedList).to.have.lengthOf(3); + }); + + it('should be 1 items in state.votedList', () => { + const action = { + type: actionTypes.removedFromVoteList, + data: { + voted: false, + address: 'voted address1', + }, + }; + const changedState = voting(state, action); + expect(changedState.votedList).to.have.lengthOf(1); + }); + + it('should return state if action.data existed in unvotedList before', () => { + const action = { + type: actionTypes.removedFromVoteList, + data: { + voted: true, + address: 'unvoted address2', + }, + }; + const changedState = voting(state, action); + expect(changedState).to.be.deep.equal(state); + }); + + it('should be 3 items in state.unvotedList', () => { + const action = { + type: actionTypes.removedFromVoteList, + data: { + voted: true, + address: 'unvoted address3', + }, + }; + const changedState = voting(state, action); + expect(changedState.unvotedList).to.have.lengthOf(3); + }); + + it('should add pending to all items in votedList and unvotedList', () => { + const action = { + type: actionTypes.pendingVotesAdded, + }; + const expectedState = { + votedList: [ + { + address: 'voted address1', + pending: true, + }, + { + address: 'voted address2', + pending: true, + }, + ], + unvotedList: [ + { + address: 'unvoted address1', + pending: true, + }, + { + address: 'unvoted address2', + pending: true, + }, + ], + }; + const changedState = voting(state, action); + expect(changedState).to.be.deep.equal(expectedState); + }); + + it('should remove all pending in votedList and unvotedList', () => { + const action = { + type: actionTypes.votesCleared, + }; + const changedState = voting(state, action); + expect(changedState.unvotedList).to.have.lengthOf(0); + expect(changedState.votedList).to.have.lengthOf(0); + expect(changedState.refresh).to.be.equal(true); + }); +});