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
+
+ {this.props.votedList.map(item => - {item.username}
)}
+
+ Remove vote from
+
+ {this.props.unvotedList.map(item => - {item.username}
)}
+
+
+ {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) }>
-