diff --git a/features/step_definitions/generic.step.js b/features/step_definitions/generic.step.js
index ce6dc887f..e083f8c9d 100644
--- a/features/step_definitions/generic.step.js
+++ b/features/step_definitions/generic.step.js
@@ -114,8 +114,13 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => {
.and.notify(callback);
});
- Then('I should see text "{text}" in "{fieldName}" element', (text, fieldName, callback) => {
- const selectorClass = `.${fieldName.replace(/ /g, '-')}`;
+ Then('I should see text "{text}" in "{elementName}" element', (text, elementName, callback) => {
+ const selectorClass = `.${elementName.replace(/ /g, '-')}`;
+ waitForElemAndCheckItsText(selectorClass, text, callback);
+ });
+
+ Then('I should see element "{elementName}" that contains text:', (elementName, text, callback) => {
+ const selectorClass = `.${elementName.replace(/ /g, '-')}`;
waitForElemAndCheckItsText(selectorClass, text, callback);
});
diff --git a/features/voting.feature b/features/voting.feature
index 5d455df0a..f66bbae40 100644
--- a/features/voting.feature
+++ b/features/voting.feature
@@ -36,6 +36,30 @@ Feature: Voting tab
Then I should see "Insufficient funds for 1 LSK fee" error message
And "submit button" should be disabled
+ Scenario: should display voting bar with numbers of selected votes if any selected
+ Given I'm logged in as "delegate candidate"
+ When I click tab number 2
+ And I should see no "voting bar"
+ And I click checkbox on table row no. 3
+ Then I should see element "voting bar" that contains text:
+ """
+ Upvotes: 1
+ Downvotes: 0
+ Total new votes: 1 / 33
+ Total votes: 1 / 101
+ """
+ And I click checkbox on table row no. 5
+ And I should see element "voting bar" that contains text:
+ """
+ Upvotes: 2
+ Downvotes: 0
+ Total new votes: 2 / 33
+ Total votes: 2 / 101
+ """
+ And I click checkbox on table row no. 3
+ And I click checkbox on table row no. 5
+ And I should see no "voting bar"
+
Scenario: should allow to select delegates in the "Voting" tab and vote for them
Given I'm logged in as "delegate candidate"
When I click tab number 2
diff --git a/src/components/voteDialog/voteDialog.js b/src/components/voteDialog/voteDialog.js
index 0ebc477d7..d485056c6 100644
--- a/src/components/voteDialog/voteDialog.js
+++ b/src/components/voteDialog/voteDialog.js
@@ -2,11 +2,14 @@ import React from 'react';
import InfoParagraph from '../infoParagraph';
import ActionBar from '../actionBar';
import Fees from '../../constants/fees';
+import votingConst from '../../constants/voting';
import Autocomplete from './voteAutocomplete';
import styles from './voteDialog.css';
import AuthInputs from '../authInputs';
import { authStatePrefill, authStateIsValid } from '../../utils/form';
+const { maxCountOfVotes, maxCountOfVotesInOneTurn } = votingConst;
+
export default class VoteDialog extends React.Component {
constructor() {
super();
@@ -58,9 +61,9 @@ export default class VoteDialog extends React.Component {
- You can select up to 33 delegates in one voting turn.
+ You can select up to {maxCountOfVotesInOneTurn} delegates in one voting turn.
- You can vote for up to 101 delegates in total.
+ You can vote for up to {maxCountOfVotes} delegates in total.
@@ -72,9 +75,9 @@ export default class VoteDialog extends React.Component {
label: 'Confirm',
fee: Fees.vote,
disabled: (
- totalVotes > 101 ||
+ totalVotes > maxCountOfVotes ||
votesList.length === 0 ||
- votesList.length > 33 ||
+ votesList.length > maxCountOfVotesInOneTurn ||
!authStateIsValid(this.state)
),
onClick: this.confirm.bind(this),
diff --git a/src/components/voting/voting.js b/src/components/voting/voting.js
index 1d71a46ab..bfd7b365e 100644
--- a/src/components/voting/voting.js
+++ b/src/components/voting/voting.js
@@ -1,11 +1,13 @@
-import React from 'react';
-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 { tableFactory } from 'react-toolbox/lib/table/Table';
+import { themr } from 'react-css-themr';
+import React from 'react';
import TableTheme from 'react-toolbox/lib/table/theme.css';
import Waypoint from 'react-waypoint';
+
import Header from './votingHeader';
+import VotingBar from './votingBar';
import VotingRow from './votingRow';
// Create a new Table component injecting Head and Row
@@ -126,6 +128,7 @@ class Voting extends React.Component {
scrollableAncestor={window}
key={this.props.delegates.length}
onEnter={this.loadMore.bind(this)}>
+
);
}
diff --git a/src/components/voting/votingBar.css b/src/components/voting/votingBar.css
new file mode 100644
index 000000000..ec882de27
--- /dev/null
+++ b/src/components/voting/votingBar.css
@@ -0,0 +1,9 @@
+.red {
+ color: #c62828;
+}
+
+.fixedAtBottom {
+ position: fixed;
+ bottom: 0;
+ left: 8px;
+}
diff --git a/src/components/voting/votingBar.js b/src/components/voting/votingBar.js
new file mode 100644
index 000000000..c066a05c9
--- /dev/null
+++ b/src/components/voting/votingBar.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import grid from 'flexboxgrid/dist/flexboxgrid.css';
+
+import votingConst from '../../constants/voting';
+import style from './votingBar.css';
+
+const VotingBar = ({ votes }) => {
+ const { maxCountOfVotes, maxCountOfVotesInOneTurn } = votingConst;
+ const votedList = Object.keys(votes).filter(key => votes[key].confirmed);
+ const voteList = Object.keys(votes).filter(
+ key => votes[key].unconfirmed && !votes[key].confirmed);
+ const unvoteList = Object.keys(votes).filter(
+ key => !votes[key].unconfirmed && votes[key].confirmed);
+ const totalVotesCount = (votedList.length - unvoteList.length) + voteList.length;
+ const totalNewVotesCount = voteList.length + unvoteList.length;
+
+ return (voteList.length + unvoteList.length ?
+
+
+
+ Upvotes:
+ {voteList.length}
+
+
+ Downvotes:
+ {unvoteList.length}
+
+
+ Total new votes:
+ maxCountOfVotesInOneTurn && style.red}>
+ {totalNewVotesCount}
+
+ / {maxCountOfVotesInOneTurn}
+
+
+ Total votes:
+ 101 && style.red}>
+ {totalVotesCount}
+
+ / {maxCountOfVotes}
+
+
+
:
+ null
+ );
+};
+
+export default VotingBar;
diff --git a/src/components/voting/votingBar.test.js b/src/components/voting/votingBar.test.js
new file mode 100644
index 000000000..1dbac3cb8
--- /dev/null
+++ b/src/components/voting/votingBar.test.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import { expect } from 'chai';
+import { mount } from 'enzyme';
+
+import VotingBar from './votingBar';
+
+import styles from './votingBar.css';
+
+describe('VotingBar', () => {
+ let wrapper;
+ const props = {
+ votes: {
+ voted: {
+ confirmed: true,
+ unconfirmed: false,
+ },
+ downvote: {
+ confirmed: true,
+ unconfirmed: true,
+ },
+ upvote: {
+ confirmed: false,
+ unconfirmed: true,
+ },
+ upvote2: {
+ confirmed: false,
+ unconfirmed: true,
+ },
+ notVoted: {
+ confirmed: false,
+ unconfirmed: false,
+ },
+ },
+ };
+
+ const generateNVotes = n => (
+ [...Array(n)].map((item, i) => i).reduce(
+ (dict, value) => {
+ dict[`genesis_${value}`] = { unconfirmed: true };
+ return dict;
+ }, {})
+ );
+
+ beforeEach(() => {
+ wrapper = mount();
+ });
+
+ it('should render number of upvotes', () => {
+ expect(wrapper.find('.upvotes')).to.have.text('Upvotes: 2');
+ });
+
+ it('should render number of downvotes', () => {
+ expect(wrapper.find('.downvotes')).to.have.text('Downvotes: 1');
+ });
+
+ it('should render number of downvotes', () => {
+ expect(wrapper.find('.total-new-votes')).to.have.text('Total new votes: 3 / 33');
+ });
+
+ it('should render number of total votes', () => {
+ expect(wrapper.find('.total-votes')).to.have.text('Total votes: 3 / 101');
+ });
+
+ it('should not render if no upvotes or downvotes', () => {
+ wrapper.setProps({ votes: {} });
+ expect(wrapper.html()).to.equal(null);
+ });
+
+ it('should render number of total votes in red if 101 exceeded', () => {
+ const votes = generateNVotes(102);
+
+ expect(wrapper.find(`.total-votes .${styles.red}`)).to.be.not.present();
+ wrapper.setProps({ votes });
+ expect(wrapper.find('.total-votes')).to.have.text('Total votes: 102 / 101');
+ expect(wrapper.find(`.total-votes .${styles.red}`)).to.have.text('102');
+ });
+
+ it('should render number of total new votes in red if 33 exceeded', () => {
+ const votes = generateNVotes(34);
+ expect(wrapper.find(`.total-new-votes .${styles.red}`)).to.be.not.present();
+ wrapper.setProps({ votes });
+ expect(wrapper.find('.total-new-votes')).to.have.text('Total new votes: 34 / 33');
+ expect(wrapper.find(`.total-new-votes .${styles.red}`)).to.have.text('34');
+ });
+});
+
diff --git a/src/constants/voting.js b/src/constants/voting.js
new file mode 100644
index 000000000..bc8bcaa0f
--- /dev/null
+++ b/src/constants/voting.js
@@ -0,0 +1,6 @@
+const votingConst = {
+ maxCountOfVotesInOneTurn: 33,
+ maxCountOfVotes: 101,
+};
+
+export default votingConst;
diff --git a/src/store/middlewares/index.js b/src/store/middlewares/index.js
index 1b279d01b..41a5f13fe 100644
--- a/src/store/middlewares/index.js
+++ b/src/store/middlewares/index.js
@@ -6,6 +6,7 @@ import addedTransactionMiddleware from './addedTransaction';
import loadingBarMiddleware from './loadingBar';
import offlineMiddleware from './offline';
import notificationMiddleware from './notification';
+import votingMiddleware from './voting';
import savedAccountsMiddleware from './savedAccounts';
export default [
@@ -17,5 +18,6 @@ export default [
loadingBarMiddleware,
offlineMiddleware,
notificationMiddleware,
+ votingMiddleware,
savedAccountsMiddleware,
];
diff --git a/src/store/middlewares/voting.js b/src/store/middlewares/voting.js
new file mode 100644
index 000000000..ce12e7cd2
--- /dev/null
+++ b/src/store/middlewares/voting.js
@@ -0,0 +1,32 @@
+import actionTypes from '../../constants/actions';
+import votingConst from '../../constants/voting';
+import { errorToastDisplayed } from '../../actions/toaster';
+
+const votingMiddleware = store => next => (action) => {
+ next(action);
+ if (action.type === actionTypes.voteToggled) {
+ const { votes } = store.getState().voting;
+ const currentVote = votes[action.data.username] || { unconfirmed: true, confirmed: false };
+
+ const newVoteCount = Object.keys(votes).filter(
+ key => votes[key].confirmed !== votes[key].unconfirmed).length;
+ if (newVoteCount === votingConst.maxCountOfVotesInOneTurn + 1 &&
+ currentVote.unconfirmed !== currentVote.confirmed) {
+ const label = `Maximum of ${votingConst.maxCountOfVotesInOneTurn} votes in one transaction exceeded.`;
+ const newAction = errorToastDisplayed({ label });
+ store.dispatch(newAction);
+ }
+
+ const voteCount = Object.keys(votes).filter(
+ key => (votes[key].confirmed && !votes[key].unconfirmed) || votes[key].unconfirmed).length;
+ if (voteCount === votingConst.maxCountOfVotes + 1 &&
+ currentVote.unconfirmed !== currentVote.confirmed) {
+ const label = `Maximum of ${votingConst.maxCountOfVotes} votes exceeded.`;
+ const newAction = errorToastDisplayed({ label });
+ store.dispatch(newAction);
+ }
+ }
+};
+
+export default votingMiddleware;
+
diff --git a/src/store/middlewares/voting.test.js b/src/store/middlewares/voting.test.js
new file mode 100644
index 000000000..f0bfbeb5e
--- /dev/null
+++ b/src/store/middlewares/voting.test.js
@@ -0,0 +1,103 @@
+import { expect } from 'chai';
+import { spy, stub } from 'sinon';
+import { errorToastDisplayed } from '../../actions/toaster';
+import middleware from './voting';
+import actionTypes from '../../constants/actions';
+import votingConst from '../../constants/voting';
+
+describe('voting middleware', () => {
+ let store;
+ let next;
+ const label = `Maximum of ${votingConst.maxCountOfVotesInOneTurn} votes in one transaction exceeded.`;
+ const label2 = `Maximum of ${votingConst.maxCountOfVotes} votes exceeded.`;
+
+ const generateNVotes = (n, vote) => (
+ [...Array(n)].map((item, i) => i).reduce(
+ (dict, value) => {
+ dict[`genesis_${value}`] = vote;
+ return dict;
+ }, {})
+ );
+
+ const initStoreWithNVotes = (n, vote) => {
+ store.getState = () => ({
+ voting: {
+ votes: {
+ ...generateNVotes(n, vote),
+ test2: {
+ unconfirmed: false,
+ confirmed: false,
+ },
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ store = stub();
+ initStoreWithNVotes(
+ votingConst.maxCountOfVotesInOneTurn + 1,
+ { confirmed: false, unconfirmed: true });
+ store.dispatch = spy();
+ next = spy();
+ });
+
+ it('should passes the action to next middleware', () => {
+ const givenAction = {
+ type: 'TEST_ACTION',
+ };
+
+ middleware(store)(next)(givenAction);
+ expect(next).to.have.been.calledWith(givenAction);
+ });
+
+ it('should dispatch errorToastDisplayed if 34 new votes and new vote unconfirmed !== confirmed ', () => {
+ const givenAction = {
+ type: actionTypes.voteToggled,
+ data: {
+ username: 'test',
+ },
+ };
+ middleware(store)(next)(givenAction);
+ expect(store.dispatch).to.have.been.calledWith(errorToastDisplayed({ label }));
+ });
+
+ it('should not dispatch errorToastDisplayed if 34 new votes and new vote unconfirmed === confirmed ', () => {
+ const givenAction = {
+ type: actionTypes.voteToggled,
+ data: {
+ username: 'test2',
+ },
+ };
+ middleware(store)(next)(givenAction);
+ expect(store.dispatch).to.not.have.been.calledWith(errorToastDisplayed({ label }));
+ });
+
+ it('should dispatch errorToastDisplayed if 102 votes and new vote unconfirmed !== confirmed ', () => {
+ initStoreWithNVotes(
+ votingConst.maxCountOfVotes + 1,
+ { confirmed: true, unconfirmed: true });
+ const givenAction = {
+ type: actionTypes.voteToggled,
+ data: {
+ username: 'test',
+ },
+ };
+ middleware(store)(next)(givenAction);
+ expect(store.dispatch).to.have.been.calledWith(errorToastDisplayed({ label: label2 }));
+ });
+
+ it('should not dispatch errorToastDisplayed if 102 votes and new vote unconfirmed === confirmed ', () => {
+ initStoreWithNVotes(
+ votingConst.maxCountOfVotes + 1,
+ { confirmed: true, unconfirmed: true });
+ const givenAction = {
+ type: actionTypes.voteToggled,
+ data: {
+ username: 'genesis_42',
+ },
+ };
+ middleware(store)(next)(givenAction);
+ expect(store.dispatch).to.not.have.been.calledWith(errorToastDisplayed({ label: label2 }));
+ });
+});