diff --git a/features/step_definitions/voting.step.js b/features/step_definitions/voting.step.js index e326edd4d..e659e74bd 100644 --- a/features/step_definitions/voting.step.js +++ b/features/step_definitions/voting.step.js @@ -13,10 +13,11 @@ defineSupportCode(({ When, Then }) => { }); When('Search twice for "{searchTerm}" in vote dialog', (searchTerm, callback) => { - element.all(by.css('md-autocomplete-wrap input')).get(0).sendKeys(searchTerm); - waitForElemAndClickIt('ul.md-autocomplete-suggestions li:nth-child(1) md-autocomplete-parent-scope'); - element.all(by.css('md-autocomplete-wrap input')).get(0).sendKeys(searchTerm); - waitForElemAndClickIt('ul.md-autocomplete-suggestions li:nth-child(1) md-autocomplete-parent-scope', callback); + element.all(by.css('.votedListSearch input')).get(0).sendKeys(searchTerm); + waitForElemAndClickIt('#votedResult ul li:nth-child(1)'); + element.all(by.css('.votedListSearch input')).get(0).sendKeys(searchTerm); + browser.sleep(500); + waitForElemAndClickIt('#votedResult ul li:nth-child(1)', callback); }); Then('I should see delegates list with {count} lines', (count, callback) => { diff --git a/features/voting.feature b/features/voting.feature index 5332bcbd0..5d455df0a 100644 --- a/features/voting.feature +++ b/features/voting.feature @@ -57,7 +57,6 @@ Feature: Voting tab And I click "submit button" Then I should see alert dialog with title "Success" and text "Your votes were successfully submitted. It can take several seconds before they are processed." - @ignore Scenario: should allow to select delegates in the "Vote" dialog and vote for them Given I'm logged in as "delegate candidate" When I click tab number 2 diff --git a/src/components/voting/confirmVotes.js b/src/components/voting/confirmVotes.js index 904b18946..80cd693ae 100644 --- a/src/components/voting/confirmVotes.js +++ b/src/components/voting/confirmVotes.js @@ -5,6 +5,7 @@ import { votePlaced } from '../../actions/voting'; import InfoParagraph from '../infoParagraph'; import ActionBar from '../actionBar'; import Fees from '../../constants/fees'; +import Autocomplete from './voteAutocomplete'; export class ConfirmVotes extends React.Component { constructor() { @@ -39,20 +40,13 @@ export class ConfirmVotes extends React.Component { return (
-

Add vote to

- -

Remove vote from

- - + {secondPassphrase} - You can select up to 33 delegates in one voting turn. -
+
+ You can select up to 33 delegates in one voting turn. +
You can vote for up to 101 delegates in total.
diff --git a/src/components/voting/confirmVotes.test.js b/src/components/voting/confirmVotes.test.js index 9d2027e09..f9c147b7c 100644 --- a/src/components/voting/confirmVotes.test.js +++ b/src/components/voting/confirmVotes.test.js @@ -72,9 +72,8 @@ describe('ConfirmVotes', () => { expect(wrapper.find('InfoParagraph')).to.have.lengthOf(1); }); - it('should render two unordered list of voted and unvoted delegates', () => { - expect(wrapper.find('ul.voted-list li')).to.have.lengthOf(props.votedList.length); - expect(wrapper.find('ul.unvoted-list li')).to.have.lengthOf(props.unvotedList.length); + it('should render Autocomplete', () => { + expect(wrapper.find('VoteAutocomplete')).to.have.lengthOf(1); }); it('should render an ActionBar', () => { diff --git a/src/components/voting/voteAutocomplete.js b/src/components/voting/voteAutocomplete.js new file mode 100644 index 000000000..158fafe2a --- /dev/null +++ b/src/components/voting/voteAutocomplete.js @@ -0,0 +1,234 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Input from 'react-toolbox/lib/input'; +import Chip from 'react-toolbox/lib/chip'; +import { Card } from 'react-toolbox/lib/card'; +import { List, ListItem } from 'react-toolbox/lib/list'; +import { voteAutocomplete, unvoteAutocomplete } from '../../utils/api/delegate'; +import { addedToVoteList, removedFromVoteList } from '../../actions/voting'; +import styles from './voting.css'; + +export class VoteAutocomplete extends React.Component { + constructor() { + super(); + this.state = { + votedSuggestionClass: styles.hidden, + unvotedSuggestionClass: styles.hidden, + votedResult: [], + unvotedResult: [], + votedListSearch: '', + unvotedListSearch: '', + }; + } + + suggestionStatus(value, name) { + const className = value ? '' : styles.hidden; + setTimeout(() => { + this.setState({ [name]: className }); + }, 200); + } + /** + * Update state and call a suitable api to create a list of suggestion + * + * @param {*} name - name of input in react state + * @param {*} value - value that we want save it in react state + */ + search(name, value) { + this.setState({ ...this.state, [name]: value }); + clearTimeout(this.timeout); + this.timeout = setTimeout(() => { + if (value.length > 0) { + if (name === 'votedListSearch') { + voteAutocomplete(this.props.activePeer, value, this.props.voted) + .then((res) => { + this.setState({ + votedResult: res, + votedSuggestionClass: '', + }); + }); + } else { + unvoteAutocomplete(value, this.props.voted) + .then((res) => { + this.setState({ + unvotedResult: res, + unvotedSuggestionClass: '', + }); + }); + } + } else { + this.setState({ + votedResult: [], + unvotedResult: [], + votedSuggestionClass: styles.hidden, + unvotedSuggestionClass: styles.hidden, + }); + } + }, 250); + } + votedSearchKeyDown(event) { + this.keyPress(event, 'votedSuggestionClass', 'votedResult'); + } + unvotedSearchKeyDown(event) { + this.keyPress(event, 'unvotedSuggestionClass', 'unvotedResult'); + } + /** + * handle key down event on autocomplete inputs + * + * @param {object} event - event of javascript + * @param {*} className - class name of suggestion box + * @param {*} listName - name of the list that we want to use as a suggestion list + */ + keyPress(event, className, listName) { + const selectFunc = listName === 'votedResult' ? 'addToVoted' : 'removeFromVoted'; + const selected = this.state[listName].filter(d => d.hovered); + switch (event.keyCode) { + case 40: // 40 is keyCode of arrow down key in keyboard + this.handleArrowDown(this.state[listName], listName); + return false; + case 38: // 38 is keyCode of arrow up key in keyboard + this.handleArrowUp(this.state[listName], listName); + return false; + case 27 : // 27 is keyCode of enter key in keyboard + this.setState({ + [className]: styles.hidden, + }); + return false; + case 13 : // 27 is keyCode of escape key in keyboard + if (selected.length > 0) { + this[selectFunc](selected[0]); + } + return false; + default: + break; + } + return true; + } + /** + * move to the next item when arrow down is pressed + * + * @param {*} list - suggested list + * @param {*} className - name of the list that we want to use as a suggestion list in react state + */ + handleArrowDown(list, name) { + const selected = list.filter(d => d.hovered); + const index = list.indexOf(selected[0]); + if (selected.length > 0 && list[index + 1]) { + list[index].hovered = false; + list[index + 1].hovered = true; + // document.getElementById('votedResult').scrollTop = 56 + } else if (list[index + 1]) { + list[0].hovered = true; + } + this.setState({ [name]: list }); + } + + /** + * move to the next item when up down is pressed + * + * @param {*} list - suggested list + * @param {*} className - name of the list that we want to use as a suggestion list in react state + */ + handleArrowUp(list, name) { + const selected = list.filter(d => d.hovered); + const index = list.indexOf(selected[0]); + if (index - 1 > -1) { + list[index].hovered = false; + list[index - 1].hovered = true; + } + this.setState({ [name]: list }); + } + addToVoted(item) { + this.props.addedToVoteList(item); + this.setState({ + votedListSearch: '', + votedSuggestionClass: styles.hidden, + }); + } + removeFromVoted(item) { + this.props.removedFromVoteList(item); + this.setState({ + unvotedListSearch: '', + unvotedSuggestionClass: styles.hidden, + }); + } + + render() { + return ( +
+

Add vote to

+
+ {this.props.votedList.map( + item => + {item.username} + , + )} +
+
+ + + + {this.state.votedResult.map( + item => , + )} + + +
+

Remove vote from

+
+ {this.props.unvotedList.map( + item => + {item.username} + , + )} +
+
+ + + + {this.state.unvotedResult.map( + item => , + )} + + +
+
+ ); + } +} + +const mapStateToProps = state => ({ + votedList: state.voting.votedList, + unvotedList: state.voting.unvotedList, + activePeer: state.peers.data, +}); + +const mapDispatchToProps = dispatch => ({ + addedToVoteList: data => dispatch(addedToVoteList(data)), + removedFromVoteList: data => dispatch(removedFromVoteList(data)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(VoteAutocomplete); diff --git a/src/components/voting/voteAutocomplete.test.js b/src/components/voting/voteAutocomplete.test.js new file mode 100644 index 000000000..0be33f9fa --- /dev/null +++ b/src/components/voting/voteAutocomplete.test.js @@ -0,0 +1,212 @@ +import React from 'react'; +import chai, { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import PropTypes from 'prop-types'; +import sinonStubPromise from 'sinon-stub-promise'; +import configureMockStore from 'redux-mock-store'; +import * as delegateApi from '../../utils/api/delegate'; +import * as votingActions from '../../actions/voting'; +import VoteAutocompleteContainer, { VoteAutocomplete } from './voteAutocomplete'; + +sinonStubPromise(sinon); +chai.use(sinonChai); + +const props = { + activePeer: {}, + voted: [], + votedList: [ + { + username: 'yashar', + }, + { + username: 'tom', + }, + ], + unvotedList: [ + { + username: 'john', + }, + { + username: 'test', + }, + ], + addedToVoteList: sinon.spy(), + removedFromVoteList: sinon.spy(), +}; +let wrapper; + +const store = configureMockStore([])({ + peers: {}, + voting: { + votedList: [], + unvotedList: [], + }, + account: {}, +}); + +describe('VoteAutocompleteContainer', () => { + beforeEach(() => { + wrapper = mount(, { + context: { store }, + childContextTypes: { store: PropTypes.object.isRequired }, + }); + }); + it('should render VoteAutocomplete', () => { + expect(wrapper.find('VoteAutocomplete').exists()).to.be.equal(true); + }); + it('should bind addedToVoteList action to ForgingComponent props.addedToVoteList', () => { + const actionsSpy = sinon.spy(votingActions, 'addedToVoteList'); + wrapper.find('VoteAutocomplete').props().addedToVoteList([]); + expect(actionsSpy).to.be.calledWith(); + }); + + it('should bind removedFromVoteList action to ForgingComponent props.removedFromVoteList', () => { + const actionsSpy = sinon.spy(votingActions, 'removedFromVoteList'); + wrapper.find('VoteAutocomplete').props().removedFromVoteList([]); + expect(actionsSpy).to.be.calledWith(); + }); +}); +describe('VoteAutocomplete', () => { + let voteAutocompleteApiMock; + let unvoteAutocompleteApiMock; + beforeEach(() => { + sinon.spy(VoteAutocomplete.prototype, 'keyPress'); + sinon.spy(VoteAutocomplete.prototype, 'handleArrowDown'); + sinon.spy(VoteAutocomplete.prototype, 'handleArrowUp'); + + voteAutocompleteApiMock = sinon.stub(delegateApi, 'voteAutocomplete'); + unvoteAutocompleteApiMock = sinon.stub(delegateApi, 'unvoteAutocomplete'); + wrapper = mount(); + }); + afterEach(() => { + voteAutocompleteApiMock.restore(); + unvoteAutocompleteApiMock.restore(); + VoteAutocomplete.prototype.keyPress.restore(); + VoteAutocomplete.prototype.handleArrowDown.restore(); + VoteAutocomplete.prototype.handleArrowUp.restore(); + }); + + it('should suggestionStatus(false, className) change value of className in state', () => { + const clock = sinon.useFakeTimers(); + wrapper.instance().suggestionStatus(false, 'className'); + clock.tick(200); + expect(wrapper.state('className').match(/hidden/g)).to.have.lengthOf(1); + }); + + it('should suggestionStatus(true, className) clear value of className in state', () => { + const clock = sinon.useFakeTimers(); + wrapper.instance().suggestionStatus(true, 'className'); + clock.tick(200); + expect(wrapper.state('className')).to.be.equal(''); + }); + + it('should search hide suggestion boxes when value is equal to ""', () => { + const clock = sinon.useFakeTimers(); + sinon.spy(VoteAutocomplete.prototype, 'setState'); + wrapper.instance().search('votedListSearch', ''); + clock.tick(250); + expect(VoteAutocomplete.prototype.setState).to.have.property('callCount', 2); + expect(wrapper.state('votedResult')).to.have.lengthOf(0); + expect(wrapper.state('unvotedResult')).to.have.lengthOf(0); + expect(wrapper.state('votedSuggestionClass').match(/hidden/g)).to.have.lengthOf(1); + expect(wrapper.state('unvotedSuggestionClass').match(/hidden/g)).to.have.lengthOf(1); + VoteAutocomplete.prototype.setState.restore(); + }); + it('search should call "voteAutocomplete" when name is equal to "votedListSearch"', () => { + const clock = sinon.useFakeTimers(); + voteAutocompleteApiMock.returnsPromise().resolves({ success: true }) + .returnsPromise().resolves([]); + // sinon.stub(delegateApi, 'listDelegates').returnsPromise().resolves({ success: true }); + wrapper.instance().search('votedListSearch', 'val'); + clock.tick(250); + expect(wrapper.state('votedSuggestionClass')).to.be.equal(''); + }); + + it('search should call "unvoteAutocomplete" when name is equal to "unvotedListSearch"', () => { + const clock = sinon.useFakeTimers(); + unvoteAutocompleteApiMock.returnsPromise().resolves([]); + wrapper.instance().search('unvotedListSearch', 'val'); + clock.tick(250); + expect(wrapper.state('unvotedSuggestionClass')).to.be.equal(''); + }); + + it('should "votedSearchKeydown" call "keyPress"', () => { + wrapper.instance().votedSearchKeyDown({}); + expect(VoteAutocomplete.prototype.keyPress).to.have.property('callCount', 1); + }); + + it('should "unvotedSearchKeydown" call "keyPress"', () => { + wrapper.instance().unvotedSearchKeyDown({}); + expect(VoteAutocomplete.prototype.keyPress).to.have.property('callCount', 1); + }); + + it('should keyPress call "handleArrowDown" when event.keyCode is equal to 40', () => { + const list = [ + { address: 'address 0' }, + { address: 'address 1', hovered: true }, + { address: 'address 2' }, + ]; + wrapper.setState({ votedResult: list }); + wrapper.instance().keyPress({ keyCode: 40 }, 'votedSuggestionClass', 'votedResult'); + expect(VoteAutocomplete.prototype.handleArrowDown).to.have.property('callCount', 1); + }); + it('should handleArrowDown select first item in votedResult when no one is selected', () => { + const list = [ + { address: 'address 0' }, + { address: 'address 1' }, + { address: 'address 2' }, + ]; + wrapper.setState({ votedResult: list }); + wrapper.instance().handleArrowDown(wrapper.state('votedResult'), 'votedResult'); + expect(wrapper.state('votedResult')[0].hovered).to.have.be.equal(true); + }); + + it('should handleArrowDown select first item in unvotedResult when no one is selected', () => { + const list = [ + { address: 'address 0' }, + { address: 'address 1' }, + { address: 'address 2' }, + ]; + wrapper.setState({ unvotedResult: list }); + wrapper.instance().handleArrowDown(wrapper.state('unvotedResult'), 'unvotedResult'); + expect(wrapper.state('unvotedResult')[0].hovered).to.have.be.equal(true); + }); + it('should keyPress call "handleArrowUp" when event.keyCode is equal to 38', () => { + const list = [ + { address: 'address 0' }, + { address: 'address 1', hovered: true }, + ]; + wrapper.setState({ votedResult: list }); + wrapper.instance().keyPress({ keyCode: 38 }, 'votedSuggestionClass', 'votedResult'); + expect(VoteAutocomplete.prototype.handleArrowUp).to.have.property('callCount', 1); + }); + + it('should keyPress hide suggestions when event.keyCode is equal to 27', () => { + const returnValue = wrapper.instance() + .keyPress({ keyCode: 27 }, 'votedSuggestionClass', 'votedResult'); + expect(wrapper.state('votedSuggestionClass').match(/hidden/g)).to.have.lengthOf(1); + expect(returnValue).to.be.equal(false); + }); + + it(`should keyPress call "addToVoted" when event.keyCode is equal to 13 and + list name is equal to votedResult`, () => { + const list = [{ address: 'address 1', hovered: true }]; + wrapper.setState({ votedResult: list }); + const returnValue = wrapper.instance() + .keyPress({ keyCode: 13 }, 'votedSuggestionClass', 'votedResult'); + expect(props.addedToVoteList).to.have.property('callCount', 1); + expect(returnValue).to.be.equal(false); + }); + + it(`should keyPress call "removedFromVoteList" when event.keyCode is equal to 13 and + list name is equal to unvotedResult`, () => { + const list = [{ address: 'address 1', hovered: true }]; + wrapper.setState({ unvotedResult: list }); + const returnValue = wrapper.instance() + .keyPress({ keyCode: 13 }, 'unvotedSuggestionClass', 'unvotedResult'); + expect(props.removedFromVoteList).to.have.property('callCount', 1); + expect(returnValue).to.be.equal(false); + }); +}); diff --git a/src/components/voting/voting.css b/src/components/voting/voting.css index 8541aca94..016c59f1b 100644 --- a/src/components/voting/voting.css +++ b/src/components/voting/voting.css @@ -36,6 +36,12 @@ margin-top: 9px; display: inline-block; } +.selectedRow{ + background: #EEEEEE; +} +.hidden{ + display: none !important; +} .votesMenuButton{ margin-right: 16px; margin-top: 8px; @@ -51,6 +57,18 @@ .unvoted { color: #c62828; } +.searchContainer{ + position: relative; +} +.searchResult{ + position: absolute; + left: 0; + top: 52px; + width: 300px !important; + max-height: 200px; + overflow: auto !important; + z-index: 9; +} /* react toolbar overwroght */ .input{ margin-top: -15px; diff --git a/src/components/voting/votingHeader.js b/src/components/voting/votingHeader.js index 783528256..e6e152bfe 100644 --- a/src/components/voting/votingHeader.js +++ b/src/components/voting/votingHeader.js @@ -84,6 +84,7 @@ class VotingHeader extends React.Component { childComponent: Confirm, childComponentProps: { addTransaction: this.props.addTransaction, + voted: this.props.votedDelegates, }, })} label={this.confirmVoteText()} /> diff --git a/src/utils/api/delegate.js b/src/utils/api/delegate.js index 7d0143475..5cc5325e3 100644 --- a/src/utils/api/delegate.js +++ b/src/utils/api/delegate.js @@ -20,13 +20,15 @@ export const vote = (activePeer, secret, publicKey, voteList, unvoteList, second secondSecret, }); -export const voteAutocomplete = (activePeer, username, votedDict) => { +export const voteAutocomplete = (activePeer, username, votedList) => { const options = { q: username }; return new Promise((resolve, reject) => listDelegates(activePeer, options) .then((response) => { - resolve(response.delegates.filter(d => !votedDict[d.username])); + resolve(response.delegates.filter(delegate => + votedList.filter(item => item.username === delegate.username).length === 0, + )); }) .catch(reject), ); diff --git a/src/utils/api/peers.test.js b/src/utils/api/peers.test.js index 3aad6ebb5..162d0fc8d 100644 --- a/src/utils/api/peers.test.js +++ b/src/utils/api/peers.test.js @@ -21,7 +21,6 @@ describe('Utils: Peers', () => { }); afterEach(() => { - activePeerMock.verify(); activePeerMock.restore(); });