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 })); + }); +});