diff --git a/src/actions/voting.js b/src/actions/voting.js index bcb4e9053..d6db1cbd7 100644 --- a/src/actions/voting.js +++ b/src/actions/voting.js @@ -1,5 +1,5 @@ import actionTypes from '../constants/actions'; -import { vote } from '../utils/api/delegate'; +import { vote, listAccountDelegates, listDelegates } from '../utils/api/delegate'; import { transactionAdded } from './transactions'; import { errorAlertDialogDisplayed } from './dialog'; import Fees from '../constants/fees'; @@ -14,16 +14,54 @@ export const pendingVotesAdded = () => ({ /** * Remove all data from the list of voted delegates and list of unvoted delegates */ -export const clearVoteLists = () => ({ - type: actionTypes.votesCleared, +export const votesUpdated = data => ({ + type: actionTypes.votesUpdated, + data, }); /** - * + * Add data to the list of voted delegates */ -export const votePlaced = ({ activePeer, account, votedList, unvotedList, secondSecret }) => +export const votesAdded = data => ({ + type: actionTypes.votesAdded, + data, +}); + +/** + * Add data to the list of all delegates + */ +export const delegatesAdded = data => ({ + type: actionTypes.delegatesAdded, + data, +}); + +/** + * Toggles account's vote for the given delegate + */ +export const voteToggled = data => ({ + type: actionTypes.voteToggled, + data, +}); + +/** + * Makes Api call to register votes + * Adds pending state and then after the duration of one round + * cleans the pending state + */ +export const votePlaced = ({ activePeer, account, votes, secondSecret }) => (dispatch) => { - // Make the Api call + const votedList = []; + const unvotedList = []; + + Object.keys(votes).forEach((username) => { + /* istanbul ignore else */ + if (!votes[username].confirmed && votes[username].unconfirmed) { + votedList.push(votes[username].publicKey); + } else if (votes[username].confirmed && !votes[username].unconfirmed) { + unvotedList.push(votes[username].publicKey); + } + }); + vote( activePeer, account.passphrase, @@ -53,17 +91,32 @@ export const votePlaced = ({ activePeer, account, votedList, unvotedList, second }; /** - * Add data to the list of voted delegates + * Gets the list of delegates current account has voted for + * */ -export const addedToVoteList = data => ({ - type: actionTypes.addedToVoteList, - data, -}); +export const votesFetched = ({ activePeer, address, type }) => + (dispatch) => { + listAccountDelegates(activePeer, address).then(({ delegates }) => { + if (type === 'update') { + dispatch(votesUpdated({ list: delegates })); + } else { + dispatch(votesAdded({ list: delegates })); + } + }); + }; /** - * Remove data from the list of voted delegates + * Gets list of all delegates */ -export const removedFromVoteList = data => ({ - type: actionTypes.removedFromVoteList, - data, -}); +export const delegatesFetched = ({ activePeer, q, offset, refresh }) => + (dispatch) => { + listDelegates( + activePeer, { + offset, + limit: '100', + q, + }, + ).then(({ delegates, totalCount }) => { + dispatch(delegatesAdded({ list: delegates, totalDelegates: totalCount, refresh })); + }); + }; diff --git a/src/actions/voting.test.js b/src/actions/voting.test.js index 1b3abc430..9e6614b18 100644 --- a/src/actions/voting.test.js +++ b/src/actions/voting.test.js @@ -2,52 +2,60 @@ import { expect } from 'chai'; import sinon from 'sinon'; import actionTypes from '../constants/actions'; import { - addedToVoteList, - removedFromVoteList, - clearVoteLists, - pendingVotesAdded, - votePlaced, + pendingVotesAdded, + votesUpdated, + votesAdded, + voteToggled, + votePlaced, + votesFetched, + delegatesFetched, + delegatesAdded, } from './voting'; import Fees from '../constants/fees'; import { transactionAdded } from './transactions'; import { errorAlertDialogDisplayed } from './dialog'; import * as delegateApi from '../utils/api/delegate'; +const delegateList = [ + { username: 'username1', publicKey: '123HG3452245L' }, + { username: 'username2', publicKey: '123HG3522345L' }, +]; + describe('actions: voting', () => { - describe('addedToVoteList', () => { - it('should create an action to add data to vote list', () => { + describe('voteToggled', () => { + it('should create an action to add data to toggle the vote status for any given delegate', () => { const data = { label: 'dummy', }; const expectedAction = { data, - type: actionTypes.addedToVoteList, + type: actionTypes.voteToggled, }; - expect(addedToVoteList(data)).to.be.deep.equal(expectedAction); + expect(voteToggled(data)).to.be.deep.equal(expectedAction); }); }); - describe('removedFromVoteList', () => { + describe('votesAdded', () => { it('should create an action to remove data from vote list', () => { - const data = { - label: 'dummy', - }; + const data = delegateList; const expectedAction = { data, - type: actionTypes.removedFromVoteList, + type: actionTypes.votesAdded, }; - expect(removedFromVoteList(data)).to.be.deep.equal(expectedAction); + expect(votesAdded(data)).to.be.deep.equal(expectedAction); }); }); - describe('clearVoteLists', () => { - it('should create an action to remove all pending rows from vote list', () => { + describe('votesUpdated', () => { + it('should create an action to update the votes dictionary', () => { const expectedAction = { - type: actionTypes.votesCleared, + type: actionTypes.votesUpdated, + data: { list: delegateList }, }; - expect(clearVoteLists()).to.be.deep.equal(expectedAction); + const createdAction = votesUpdated({ list: delegateList }); + expect(createdAction).to.be.deep.equal(expectedAction); }); }); @@ -67,12 +75,14 @@ describe('actions: voting', () => { address: 'test_address', }; const activePeer = {}; - const votedList = []; - const unvotedList = []; const secondSecret = null; + const votes = { + username1: { publicKey: 'sample_key', confirmed: true, unconfirmed: false }, + username2: { publicKey: 'sample_key', confirmed: false, unconfirmed: true }, + }; const actionFunction = votePlaced({ - activePeer, account, votedList, unvotedList, secondSecret, + activePeer, account, votes, secondSecret, }); let dispatch; @@ -120,4 +130,56 @@ describe('actions: voting', () => { expect(dispatch).to.have.been.calledWith(expectedAction); }); }); + + describe('votesFetched', () => { + const data = { + activePeer: {}, + address: '8096217735672704724L', + }; + const delegates = delegateList; + const actionFunction = votesFetched(data); + + it('should create an action function', () => { + expect(typeof actionFunction).to.be.deep.equal('function'); + }); + + it.skip('should dispatch votesAdded action if resolved', () => { + const delegateApiMock = sinon.stub(delegateApi, 'listAccountDelegates'); + const dispatch = sinon.spy(); + + delegateApiMock.returnsPromise().resolves({ delegates }); + const expectedAction = { list: delegates }; + + actionFunction(dispatch); + expect(dispatch).to.have.been.calledWith(votesAdded(expectedAction)); + delegateApiMock.restore(); + }); + }); + + describe('delegatesFetched', () => { + const data = { + activePeer: {}, + q: '', + offset: 0, + refresh: true, + }; + const delegates = delegateList; + const actionFunction = delegatesFetched(data); + + it('should create an action function', () => { + expect(typeof actionFunction).to.be.deep.equal('function'); + }); + + it('should dispatch delegatesAdded action if resolved', () => { + const delegateApiMock = sinon.stub(delegateApi, 'listDelegates'); + const dispatch = sinon.spy(); + + delegateApiMock.returnsPromise().resolves({ delegates, totalCount: 10 }); + const expectedAction = { list: delegates, totalDelegates: 10, refresh: true }; + + actionFunction(dispatch); + expect(dispatch).to.have.been.calledWith(delegatesAdded(expectedAction)); + delegateApiMock.restore(); + }); + }); }); diff --git a/src/components/voteDialog/index.js b/src/components/voteDialog/index.js index 4e0551923..f4c8ff5bc 100644 --- a/src/components/voteDialog/index.js +++ b/src/components/voteDialog/index.js @@ -1,18 +1,17 @@ import { connect } from 'react-redux'; -import { votePlaced, addedToVoteList, removedFromVoteList } from '../../actions/voting'; +import { votePlaced, voteToggled } from '../../actions/voting'; import VoteDialog from './voteDialog'; const mapStateToProps = state => ({ - votedList: state.voting.votedList.filter(item => !item.pending), - unvotedList: state.voting.unvotedList.filter(item => !item.pending), + votes: state.voting.votes, + delegates: state.voting.delegates, account: state.account, activePeer: state.peers.data, }); const mapDispatchToProps = dispatch => ({ votePlaced: data => dispatch(votePlaced(data)), - addedToVoteList: data => dispatch(addedToVoteList(data)), - removedFromVoteList: data => dispatch(removedFromVoteList(data)), + voteToggled: data => dispatch(voteToggled(data)), }); export default connect(mapStateToProps, mapDispatchToProps)(VoteDialog); diff --git a/src/components/voteDialog/index.test.js b/src/components/voteDialog/index.test.js index b90d575c9..e83918bb2 100644 --- a/src/components/voteDialog/index.test.js +++ b/src/components/voteDialog/index.test.js @@ -21,27 +21,26 @@ const ordinaryAccount = { secondSignature: 0, balance: 10e8, }; -const votedList = [ +const delegates = [ { username: 'yashar', + publicKey: 'sample_key', }, { username: 'tom', + publicKey: 'sample_key', }, ]; -const unvotedList = [ - { - username: 'john', - }, - { - username: 'test', - }, -]; +const votes = { + john: { confirmed: false, unconfirmed: true, publicKey: 'sample_key' }, + yashar: { confirmed: true, unconfirmed: false, publicKey: 'sample_key' }, + +}; const store = configureMockStore([])({ account: ordinaryAccount, voting: { - votedList: [...votedList, { pending: true, username: 'pending' }], - unvotedList: [...unvotedList, { pending: true, username: 'pending2' }], + votes, + delegates, }, peers: { data: {} }, }); @@ -59,22 +58,16 @@ describe('VoteDialog HOC', () => { it('should pass appropriate properties to VoteDialog', () => { const confirmVotesProps = wrapper.find('VoteDialog').props(); - expect(confirmVotesProps.votedList).to.be.deep.equal(votedList); - expect(confirmVotesProps.unvotedList).to.be.deep.equal(unvotedList); + expect(confirmVotesProps.votes).to.be.equal(votes); + expect(confirmVotesProps.delegates).to.be.equal(delegates); expect(confirmVotesProps.account).to.be.equal(ordinaryAccount); expect(confirmVotesProps.activePeer).to.deep.equal({}); - expect(typeof confirmVotesProps.votePlaced).to.be.equal('function'); - }); - - it('should bind addedToVoteList action to VoteDialog props.addedToVoteList', () => { - const actionsSpy = sinon.spy(votingActions, 'addedToVoteList'); - wrapper.find('VoteDialog').props().addedToVoteList([]); - expect(actionsSpy).to.be.calledWith(); + expect(typeof confirmVotesProps.voteToggled).to.be.equal('function'); }); - it('should bind removedFromVoteList action to VoteDialog props.removedFromVoteList', () => { - const actionsSpy = sinon.spy(votingActions, 'removedFromVoteList'); - wrapper.find('VoteDialog').props().removedFromVoteList([]); + it('should bind voteToggled action to VoteDialog props.voteToggled', () => { + const actionsSpy = sinon.spy(votingActions, 'voteToggled'); + wrapper.find('VoteDialog').props().voteToggled([]); expect(actionsSpy).to.be.calledWith(); }); }); diff --git a/src/components/voteDialog/voteAutocomplete.js b/src/components/voteDialog/voteAutocomplete.js index bccafb528..d727d0277 100644 --- a/src/components/voteDialog/voteAutocomplete.js +++ b/src/components/voteDialog/voteAutocomplete.js @@ -37,7 +37,7 @@ export default class VoteAutocomplete extends React.Component { this.timeout = setTimeout(() => { if (value.length > 0) { if (name === 'votedListSearch') { - voteAutocomplete(this.props.activePeer, value, this.props.voted) + voteAutocomplete(this.props.activePeer, value, this.props.votes) .then((res) => { this.setState({ votedResult: res, @@ -45,7 +45,7 @@ export default class VoteAutocomplete extends React.Component { }); }); } else { - unvoteAutocomplete(value, this.props.voted) + unvoteAutocomplete(value, this.props.votes) .then((res) => { this.setState({ unvotedResult: res, @@ -86,13 +86,17 @@ export default class VoteAutocomplete extends React.Component { 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 + case 27 : // 27 is keyCode of escape key in keyboard this.setState({ [className]: styles.hidden, }); return false; - case 13 : // 27 is keyCode of escape key in keyboard + case 13 : // 13 is keyCode of enter key in keyboard if (selected.length > 0) { + selected[0].hovered = false; + this.setState({ + [listName]: this.state[listName], + }); this[selectFunc](selected[0]); } return false; @@ -136,14 +140,16 @@ export default class VoteAutocomplete extends React.Component { this.setState({ [name]: list }); } addToVoted(item) { - this.props.addedToVoteList(item); + const { username, publicKey } = item; + this.props.voteToggled({ username, publicKey }); this.setState({ votedListSearch: '', votedSuggestionClass: styles.hidden, }); } removeFromVoted(item) { - this.props.removedFromVoteList(item); + const { username, publicKey } = item; + this.props.voteToggled({ username, publicKey }); this.setState({ unvotedListSearch: '', unvotedSuggestionClass: styles.hidden, @@ -151,15 +157,29 @@ export default class VoteAutocomplete extends React.Component { } render() { + const { votes } = this.props; + const votedList = []; + const unvotedList = []; + + Object.keys(votes).forEach((delegate) => { + if (!votes[delegate].confirmed && votes[delegate].unconfirmed) { + votedList.push(delegate); + } else if (votes[delegate].confirmed && !votes[delegate].unconfirmed) { + unvotedList.push(delegate); + } + }); + + return (

Add vote to

- {this.props.votedList.map( - item => - {item.username} + onDeleteClick={this.props.voteToggled.bind(this, + { username: item, publicKey: votes[item].publicKey })}> + {item} , )}
@@ -186,11 +206,12 @@ export default class VoteAutocomplete extends React.Component {

Remove vote from

- {this.props.unvotedList.map( - item => - {item.username} + onDeleteClick={this.props.voteToggled.bind(this, + { username: item, publicKey: votes[item].publicKey })}> + {item} , )}
diff --git a/src/components/voteDialog/voteAutocomplete.test.js b/src/components/voteDialog/voteAutocomplete.test.js index c519ce27c..3b5961954 100644 --- a/src/components/voteDialog/voteAutocomplete.test.js +++ b/src/components/voteDialog/voteAutocomplete.test.js @@ -6,35 +6,27 @@ import configureMockStore from 'redux-mock-store'; import * as delegateApi from '../../utils/api/delegate'; import VoteAutocomplete from './voteAutocomplete'; +const votes = { + username1: { publicKey: 'sample_key', confirmed: true, unconfirmed: false }, + username2: { publicKey: 'sample_key', confirmed: false, unconfirmed: true }, +}; +const delegates = [ + { username: 'username1', publicKey: '123HG3452245L' }, + { username: 'username2', publicKey: '123HG3522345L' }, +]; const props = { activePeer: {}, - voted: [], - votedList: [ - { - username: 'yashar', - }, - { - username: 'tom', - }, - ], - unvotedList: [ - { - username: 'john', - }, - { - username: 'test', - }, - ], - addedToVoteList: sinon.spy(), - removedFromVoteList: sinon.spy(), + votes, + delegates, + voteToggled: sinon.spy(), }; let wrapper; const store = configureMockStore([])({ peers: {}, voting: { - votedList: [], - unvotedList: [], + votes, + delegates, }, account: {}, }); @@ -167,17 +159,17 @@ describe('VoteAutocomplete', () => { wrapper.setState({ votedResult: list }); const returnValue = wrapper.instance() .keyPress({ keyCode: 13 }, 'votedSuggestionClass', 'votedResult'); - expect(props.addedToVoteList).to.have.property('callCount', 1); + expect(props.voteToggled).to.have.property('callCount', 1); expect(returnValue).to.be.equal(false); }); - it(`should keyPress call "removedFromVoteList" when event.keyCode is equal to 13 and + it(`should keyPress call "voteToggled" 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(props.voteToggled).to.have.property('callCount', 2); expect(returnValue).to.be.equal(false); }); }); diff --git a/src/components/voteDialog/voteDialog.js b/src/components/voteDialog/voteDialog.js index 10ba04550..977273406 100644 --- a/src/components/voteDialog/voteDialog.js +++ b/src/components/voteDialog/voteDialog.js @@ -26,11 +26,11 @@ export default class VoteDialog extends React.Component { this.props.votePlaced({ activePeer: this.props.activePeer, account: this.props.account, - votedList: this.props.votedList, - unvotedList: this.props.unvotedList, + votes: this.props.votes, secondSecret, }); } + setSecondPass(name, value, error) { this.setState({ [name]: { @@ -41,14 +41,19 @@ export default class VoteDialog extends React.Component { } render() { + const { votes } = this.props; + let totalVotes = 0; + const votesList = []; + Object.keys(votes).forEach((item) => { + if (votes[item].confirmed || votes[item].unconfirmed) totalVotes++; + if (votes[item].confirmed !== votes[item].unconfirmed) votesList.push(item); + }); return (
101) || - (this.props.votedList.length + - this.props.unvotedList.length > 33) || - (this.props.votedList.length === 0 && - this.props.unvotedList.length === 0) || + totalVotes > 101 || + votesList.length === 0 || + votesList.length > 33 || (!!this.state.secondPassphrase.error || this.state.secondPassphrase.value === '') ), diff --git a/src/components/voteDialog/voteDialog.test.js b/src/components/voteDialog/voteDialog.test.js index 86dd3afd5..e0d216a46 100644 --- a/src/components/voteDialog/voteDialog.test.js +++ b/src/components/voteDialog/voteDialog.test.js @@ -18,27 +18,20 @@ const accountWithSecondPassphrase = { publicKey: 'key', secondSignature: 1, }; -const votedList = [ - { - username: 'yashar', - }, - { - username: 'tom', - }, -]; -const unvotedList = [ - { - username: 'john', - }, - { - username: 'test', - }, +const votes = { + username1: { publicKey: 'sample_key', confirmed: true, unconfirmed: false }, + username2: { publicKey: 'sample_key', confirmed: false, unconfirmed: true }, +}; +const delegates = [ + { username: 'username1', publicKey: '123HG3452245L' }, + { username: 'username2', publicKey: '123HG3522345L' }, ]; + const store = configureMockStore([])({ account: ordinaryAccount, voting: { - votedList, - unvotedList, + votes, + delegates, }, peers: { data: {} }, }); @@ -49,13 +42,11 @@ describe('VoteDialog', () => { props = { voted: [], activePeer: {}, - votedList, - unvotedList, + votes, + delegates, closeDialog: sinon.spy(), - clearVoteLists: sinon.spy(), votePlaced: sinon.spy(), - addedToVoteList: sinon.spy(), - removedFromVoteList: sinon.spy(), + voteToggled: sinon.spy(), }; describe('Ordinary account', () => { @@ -83,33 +74,18 @@ describe('VoteDialog', () => { account: ordinaryAccount, activePeer: props.activePeer, secondSecret: null, - unvotedList: props.unvotedList, - votedList: props.votedList, + votes, }); }); it('should not fire votePlaced action if lists are empty', () => { const noVoteProps = { - ...props, - ...{ - votedList: [], - unvotedList: [], - }, - }; - const mounted = mount( - ); - const primaryButton = mounted.find('VoteDialog .primary-button button'); - - expect(primaryButton.props().disabled).to.be.equal(true); - }); - - it('should not fire votePlaced action the combined lenght of votedList and unvotedList is higher than 33', () => { - const noVoteProps = { - ...props, - ...{ - votedList: Array(20).fill({}).map((obj, key) => ({ username: `standby_${key}` })), - unvotedList: Array(14).fill({}).map((obj, key) => ({ username: `genesis_${key}` })), - }, + activePeer: {}, + votes: {}, + delegates: [], + closeDialog: () => {}, + voteToggled: () => {}, + votePlaced: () => {}, }; const mounted = mount( ); @@ -132,10 +108,22 @@ describe('VoteDialog', () => { expect(props.votePlaced).to.have.been.calledWith({ activePeer: props.activePeer, account: accountWithSecondPassphrase, - votedList: props.votedList, - unvotedList: props.unvotedList, + votes, secondSecret: secondPassphrase, }); }); }); + + it('should not fire votePlaced action if the number of vote is higher than 33', () => { + const extraVotes = {}; + for (let i = 0; i < 35; i++) { + extraVotes[`standby_${i}`] = { confirmed: false, unconfirmed: true, publicKey: `public_key_${i}` }; + } + const noVoteProps = Object.assign({}, props, { votes: extraVotes }); + const mounted = mount( + ); + const primaryButton = mounted.find('VoteDialog .primary-button button'); + + expect(primaryButton.props().disabled).to.be.equal(true); + }); }); diff --git a/src/components/voting/index.js b/src/components/voting/index.js index 7410b8f96..e7a26e55c 100644 --- a/src/components/voting/index.js +++ b/src/components/voting/index.js @@ -1,23 +1,22 @@ import { connect } from 'react-redux'; import { dialogDisplayed } from '../../actions/dialog'; -import { removedFromVoteList, addedToVoteList } from '../../actions/voting'; -import { transactionAdded } from '../../actions/transactions'; +import { voteToggled, votesFetched, delegatesFetched } from '../../actions/voting'; import Voting from './voting'; const mapStateToProps = state => ({ address: state.account.address, activePeer: state.peers.data, - votedList: state.voting.votedList, - unvotedList: state.voting.unvotedList, + votes: state.voting.votes, + delegates: state.voting.delegates, + totalDelegates: state.voting.totalDelegates, refreshDelegates: state.voting.refresh, }); const mapDispatchToProps = dispatch => ({ setActiveDialog: data => dispatch(dialogDisplayed(data)), - addToUnvoted: data => dispatch(removedFromVoteList(data)), - addToVoteList: data => dispatch(addedToVoteList(data)), - removeFromVoteList: data => dispatch(removedFromVoteList(data)), - addTransaction: data => dispatch(transactionAdded(data)), + voteToggled: data => dispatch(voteToggled(data)), + votesFetched: data => dispatch(votesFetched(data)), + delegatesFetched: data => dispatch(delegatesFetched(data)), }); export default connect(mapStateToProps, mapDispatchToProps)(Voting); diff --git a/src/components/voting/index.test.js b/src/components/voting/index.test.js index 9ab8859d6..ae071607b 100644 --- a/src/components/voting/index.test.js +++ b/src/components/voting/index.test.js @@ -16,8 +16,12 @@ describe('VotingHOC', () => { confirmed: [], }, voting: { - votedList: [], - unvotedList: [], + delegates: [ + { username: 'username1', publicKey: 'sample_key' }, + ], + votes: { + username1: { confirmed: true, unconfirmed: true, publicKey: 'sample_key' }, + }, }, account: {}, }); diff --git a/src/components/voting/voteCheckbox.js b/src/components/voting/voteCheckbox.js index e8ec9f873..ce78d4de1 100644 --- a/src/components/voting/voteCheckbox.js +++ b/src/components/voting/voteCheckbox.js @@ -2,28 +2,16 @@ import React from 'react'; import Checkbox from 'react-toolbox/lib/checkbox'; import Spinner from '../spinner'; -export default 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); - } - } +const VoteCheckbox = ({ data, status, styles, toggle }) => { + const { username, publicKey } = data; + const template = status && status.pending ? + : + ; + return template; +}; - render() { - const template = this.props.pending ? - : - ; - return template; - } -} +export default VoteCheckbox; diff --git a/src/components/voting/voteCheckbox.test.js b/src/components/voting/voteCheckbox.test.js index 998ce0fd3..5791739b9 100644 --- a/src/components/voting/voteCheckbox.test.js +++ b/src/components/voting/voteCheckbox.test.js @@ -2,47 +2,61 @@ import React from 'react'; import { expect } from 'chai'; import sinon from 'sinon'; import { mount } from 'enzyme'; -import configureStore from 'redux-mock-store'; import VoteCheckbox from './voteCheckbox'; import styles from './voting.css'; -const mockStore = configureStore(); - describe('VoteCheckbox', () => { - let wrapper; const props = { - store: mockStore({ runtime: {} }), data: { username: 'yashar', - address: 'address 1', + publicKey: 'address 1', }, styles, - pending: false, - value: true, - addToVoteList: sinon.spy(), - removeFromVoteList: sinon.spy(), + toggle: sinon.spy(), }; + const voteStatus = { confirmed: false, unconfirmed: true }; + const unvoteStatus = { confirmed: true, unconfirmed: false }; + const pendingStatus = { confirmed: true, unconfirmed: true, pending: true }; - beforeEach(() => { - wrapper = mount(); - }); + describe('General', () => { + let wrapper; + 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', () => { + expect(wrapper.find('Checkbox').exists()).to.be.equal(true); + }); + + it('should Checkbox change event should call props.toggle', () => { + wrapper.find('input').simulate('click'); + expect(props.toggle).to.have.been.calledWith(props.data); + }); }); - it('should render a Checkbox is false', () => { - expect(wrapper.find('Checkbox').exists()).to.be.equal(true); + describe('To show vote', () => { + it('should render a Checkbox', () => { + const wrapper = mount(); + expect(wrapper.find('input').props().checked).to.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); + describe('To show unvote', () => { + it('should render a Checkbox', () => { + const wrapper = mount(); + expect(wrapper.find('input').props().checked).to.equal(false); + }); + + it('should render a Checkbox even if status is not passed', () => { + const wrapper = mount(); + expect(wrapper.find('input').props().checked).to.equal(false); + }); }); - 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); + describe('To show pending', () => { + it('should render a Spinner When pending is true', () => { + const wrapper = mount(); + expect(wrapper.find('Spinner').exists()).to.be.equal(true); + }); }); }); diff --git a/src/components/voting/voting.css b/src/components/voting/voting.css index e0483d5f9..f5c4ac255 100644 --- a/src/components/voting/voting.css +++ b/src/components/voting/voting.css @@ -90,3 +90,7 @@ margin-top: 18px; margin-right: 16px; } + +.rotated { + transform: rotate(45deg); +} diff --git a/src/components/voting/voting.js b/src/components/voting/voting.js index 2436ed247..f4401dd4f 100644 --- a/src/components/voting/voting.js +++ b/src/components/voting/voting.js @@ -5,7 +5,6 @@ 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 { listAccountDelegates, listDelegates } from '../../utils/api/delegate'; import Header from './votingHeader'; import VotingRow from './votingRow'; @@ -15,58 +14,40 @@ const Table = themr(TABLE, TableTheme)(tableFactory(TableHead, VotingRow)); class Voting extends React.Component { constructor() { super(); - this.state = { - delegates: [], - votedDelegates: [], - selected: [], - offset: 0, - loadMore: false, - length: 1, - notFound: '', - }; + this.freezeLoading = false; + this.isInitial = true; + this.offset = -1; this.query = ''; } - componentWillReceiveProps() { + componentWillUpdate(nextProps) { setTimeout(() => { if (this.props.refreshDelegates) { this.loadVotedDelegates(true); - } else { - const delegates = this.state.delegates.map(delegate => this.setStatus(delegate)); - this.setState({ - delegates, - }); } }, 1); + if (this.props.delegates.length < nextProps.delegates.length) { + setTimeout(() => { + this.freezeLoading = false; + this.offset = nextProps.delegates.length; + this.isInitial = false; + }, 5); + } } componentDidMount() { - this.loadVotedDelegates(); + this.loadVotedDelegates(true); } loadVotedDelegates(refresh) { - listAccountDelegates(this.props.activePeer, this.props.address).then((res) => { - if (res.delegates) { - 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); - } - }) - .catch(() => { - this.loadDelegates(this.query); - }); + /* istanbul-ignore-else */ + if (!this.freezeLoading) { + this.props.votesFetched({ + activePeer: this.props.activePeer, + address: this.props.address, + }); + this.loadDelegates('', refresh); + } } /** @@ -75,79 +56,36 @@ class Voting extends React.Component { */ search(query) { this.query = query; - this.setState({ - offset: 0, - delegates: [], - length: 1, - loadMore: false, - }); - setTimeout(() => { - this.loadDelegates(this.query); - }, 1); + this.offset = 0; + this.freezeLoading = false; + this.loadDelegates(query, true); } /** * Fetches a list of delegates * * @method loadDelegates - * @param {String} search - The search phrase to match with the delegate name + * @param {String} query - The search phrase to match with the delegate name * should replace the old delegates list * @param {Number} limit - The maximum number of results */ - loadDelegates(search, limit = 100) { - this.setState({ loadMore: false }); - - listDelegates( - this.props.activePeer, { - offset: this.state.offset, - limit: limit.toString(), - q: search, - }, - ).then((res) => { - const delegatesList = res.delegates - .map(delegate => this.setStatus(delegate)); - this.setState({ - delegates: [...this.state.delegates, ...delegatesList], - offset: this.state.offset + delegatesList.length, - length: parseInt(res.totalCount, 10), - loadMore: true, - notFound: delegatesList.length > 0 ? '' :
No delegates found
, - }); + loadDelegates(q = '', refresh) { + this.freezeLoading = true; + this.offset = refresh ? -1 : this.offset; + this.props.delegatesFetched({ + activePeer: this.props.activePeer, + offset: this.offset > -1 ? this.offset : 0, + q, + refresh, }); } /** - * Sets delegate.status to be always the same object for given delegate.address - */ - setStatus(delegate) { - 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 }); - } - - /** - * load more data when scroll bar reachs end of the page + * load more data when scroll bar reaches end of the page */ loadMore() { - if (this.state.loadMore && this.state.length > this.state.offset) { + /* istanbul-ignore-else */ + if (!this.freezeLoading && this.props.totalDelegates > this.offset) { this.loadDelegates(this.query); } } @@ -157,12 +95,9 @@ class Voting extends React.Component {
this.search(value) } />
@@ -175,19 +110,22 @@ class Voting extends React.Component { Uptime Approval - {this.state.delegates.map(item => ( + {this.props.delegates.map(item => ( ))}
- {this.state.notFound} + { + (!this.isInitial && this.props.delegates.length === 0) && +
No delegates found
+ } + scrollableAncestor={window} + key={this.props.delegates.length} + onEnter={this.loadMore.bind(this)}>
); } diff --git a/src/components/voting/voting.test.js b/src/components/voting/voting.test.js index 713a0f031..64a7592e8 100644 --- a/src/components/voting/voting.test.js +++ b/src/components/voting/voting.test.js @@ -5,47 +5,39 @@ import PropTypes from 'prop-types'; import sinon from 'sinon'; import Voting from './voting'; import store from '../../store'; -import * as delegateApi from '../../utils/api/delegate'; describe('Voting', () => { let wrapper; - const listAccountDelegatesMock = sinon.stub(delegateApi, 'listAccountDelegates'); - let listDelegatesMock; - listAccountDelegatesMock.returnsPromise().resolves({ - delegates: [ - { - address: 'address 1', - }, - { - address: 'address 1', - }, - ], - }); + const delegates = [ + { + address: 'address 1', + username: 'username1', + publicKey: 'sample_key', + }, + { + address: 'address 2', + username: 'username2', + publicKey: 'sample_key', + }, + ]; + const votes = { + username1: { confirmed: true, unconfirmed: true, publicKey: 'sample_key' }, + }; const props = { refreshDelegates: false, + delegates, + totalDelegates: 10, + votes, activePeer: {}, address: '16313739661670634666L', - votedList: [], - unvotedList: [], - addToUnvoted: sinon.spy(), + setActiveDialog: sinon.spy(), + voteToggled: sinon.spy(), + addTransaction: sinon.spy(), + votesFetched: sinon.spy(), + delegatesFetched: sinon.spy(), }; 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 }, @@ -55,63 +47,33 @@ describe('Voting', () => { }); afterEach(() => { - Voting.prototype.loadVotedDelegates.restore(); - Voting.prototype.loadDelegates.restore(); - Voting.prototype.setStatus.restore(); - listDelegatesMock.restore(); + // Voting.prototype.setStatus.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 render VotingHeader', () => { + expect(wrapper.find('VotingHeader')).to.have.lengthOf(1); }); - - 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 render VotingRow', () => { + expect(wrapper.find('VotingRow')).to.have.lengthOf(delegates.length); }); - - it('should call "loadVotedDelegates" once when "refreshDelegates" is not changed', () => { - const clock = sinon.useFakeTimers(); - clock.tick(100); - wrapper.setProps({ votedList: [] }); - // 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 render Table', () => { + expect(wrapper.find('Table')).to.have.lengthOf(1); }); - it('should "search" function call "loadDelegates"', () => { - wrapper.instance().search('query'); + it('should define search method to reload delegates based on given 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); + props.delegatesFetched.reset(); + wrapper.instance().search('query'); + clock.tick(2); + expect(props.delegatesFetched).to.be.calledWith({ + activePeer: props.activePeer, + offset: 0, + q: 'query', + refresh: true, + }); + clock.restore(); }); }); diff --git a/src/components/voting/votingHeader.js b/src/components/voting/votingHeader.js index afbc13459..4f702910c 100644 --- a/src/components/voting/votingHeader.js +++ b/src/components/voting/votingHeader.js @@ -13,6 +13,7 @@ class VotingHeader extends React.Component { this.state = { query: '', searchIcon: 'search', + votesList: [], }; } @@ -38,26 +39,28 @@ class VotingHeader extends React.Component { confirmVoteText() { let info = 'VOTE'; - const voted = this.props.votedList.filter(item => !item.pending).length; - const unvoted = this.props.unvotedList.filter(item => !item.pending).length; + const { votes } = this.props; + const votesList = Object.keys(votes); + const voted = votesList.filter(item => + !votes[item].confirmed && votes[item].unconfirmed).length; + const unvoted = votesList.filter(item => + votes[item].confirmed && !votes[item].unconfirmed).length; if (voted > 0 || unvoted > 0) { - const seprator = (voted > 0 && unvoted > 0) ? ' / ' : ''; // eslint-disable-line + const separator = (voted > 0 && unvoted > 0) ? ' / ' : ''; // eslint-disable-line const votedHtml = voted > 0 ? +{voted} : ''; const unvotedHtml = unvoted > 0 ? -{unvoted} : ''; - info = VOTE ({votedHtml}{seprator}{unvotedHtml}); + info = VOTE ({votedHtml}{separator}{unvotedHtml}); } return info; } - isOnUnvoteList(delegate) { - return this.props.unvotedList.filter(d => d.username === delegate.username).length; - } - render() { - const theme = this.props.votedDelegates.length === 0 ? disableStyle : styles; + const { votes } = this.props; + const votesList = Object.keys(votes); + const theme = votesList.length === 0 ? disableStyle : styles; const button =
visibility - my votes ({this.props.votedDelegates.length}) + my votes ({votesList.length})
; return (
@@ -75,31 +78,22 @@ class VotingHeader extends React.Component {
- {this.props.votedDelegates.map(delegate => - (this.isOnUnvoteList(delegate) ? - : - ), - )} + {votesList.map(username => + )}
diff --git a/src/components/voting/votingHeader.test.js b/src/components/voting/votingHeader.test.js index dcf9c2329..9995ae0aa 100644 --- a/src/components/voting/votingHeader.test.js +++ b/src/components/voting/votingHeader.test.js @@ -5,84 +5,119 @@ import { mount } from 'enzyme'; import configureStore from 'redux-mock-store'; import PropTypes from 'prop-types'; import VotingHeader from './votingHeader'; +import VoteDialog from '../voteDialog'; describe('VotingHeader', () => { let wrapper; const mockStore = configureStore(); + const voteDict = { + username3: { confirmed: false, unconfirmed: true, publicKey: 'sample_key3' }, + }; + const unvoteDict = { + username1: { confirmed: true, unconfirmed: false, publicKey: 'sample_key1' }, + }; + const votes = Object.assign({}, voteDict, unvoteDict); const props = { store: mockStore({ runtime: {} }), - search: sinon.spy(), votedDelegates: [ { - username: 'yashar', + username: 'username1', address: 'address 1', }, { - username: 'tom', + username: 'username2', 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(), - addToVoteList: sinon.spy(), + setActiveDialog: sinon.spy(), + voteToggled: sinon.spy(), + addTransaction: sinon.spy(), + search: sinon.spy(), }; - beforeEach(() => { - wrapper = mount(, { - context: { store: mockStore }, - childContextTypes: { store: PropTypes.object.isRequired }, + describe('Vote and Unvote', () => { + beforeEach(() => { + 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 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#searchIcon with text of "search" when this.search is not called', () => { + // expect(wrapper.find('i.material-icons')).to.have.lengthOf(1); + expect(wrapper.find('#searchIcon').text()).to.be.equal('search'); + }); + + it('should render i#searchIcon with text of "close" when this.search is called', () => { + wrapper.instance().search('query', '555'); + expect(wrapper.find('#searchIcon').text()).to.be.equal('close'); + }); + + it('should this.props.search when this.search is called', () => { + const clock = sinon.useFakeTimers(); + wrapper.instance().search('query', '555'); + clock.tick(250); + expect(props.search).to.have.been.calledWith('555'); + }); + + it('click on #searchIcon should clear value of search input', () => { + wrapper.instance().search('query', '555'); + wrapper.find('#searchIcon').simulate('click'); + expect(wrapper.state('query')).to.be.equal(''); + }); - 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('#searchIcon').text()).to.be.equal('search'); + it('click on vote button should call setActiveDialog with VotingDialog as childComponent', () => { + wrapper.find('.vote-button').simulate('click'); + expect(props.setActiveDialog).to.have.been.calledWith({ + title: 'Vote for delegates', + childComponent: VoteDialog, + }); + }); }); - it('should render i#searchIcon with text of "close" when this.search is called', () => { - wrapper.instance().search('query', '555'); - expect(wrapper.find('#searchIcon').text()).to.be.equal('close'); + describe('Only vote', () => { + beforeEach(() => { + wrapper = mount(, { + context: { store: mockStore }, + childContextTypes: { store: PropTypes.object.isRequired }, + }); + }); + + it('should render vote button reflecting only (up)vote', () => { + expect(wrapper.find('.vote-button-info').text()).to.be.equal('VOTE (+1)'); + }); }); - it('should this.props.search when this.search is called', () => { - const clock = sinon.useFakeTimers(); - wrapper.instance().search('query', '555'); - clock.tick(250); - expect(props.search).to.have.been.calledWith('555'); + describe('Only unvote', () => { + beforeEach(() => { + wrapper = mount(, { + context: { store: mockStore }, + childContextTypes: { store: PropTypes.object.isRequired }, + }); + }); + + it('should render vote button reflecting only unvote', () => { + expect(wrapper.find('.vote-button-info').text()).to.be.equal('VOTE (-1)'); + }); }); - it('click on #searchIcon should clear value of search input', () => { - wrapper.instance().search('query', '555'); - wrapper.find('#searchIcon').simulate('click'); - expect(wrapper.state('query')).to.be.equal(''); + describe('Without votes', () => { + beforeEach(() => { + wrapper = mount(, { + context: { store: mockStore }, + childContextTypes: { store: PropTypes.object.isRequired }, + }); + }); + + it('should disable my votes button', () => { + expect(wrapper.find('.my-votes-button button').hasClass('disableMenu__icon___2NDu1')).to.equal(true); + }); }); }); diff --git a/src/components/voting/votingRow.js b/src/components/voting/votingRow.js index 69ced7c6e..16b1076ec 100644 --- a/src/components/voting/votingRow.js +++ b/src/components/voting/votingRow.js @@ -3,31 +3,38 @@ import { TableRow, TableCell } from 'react-toolbox/lib/table'; import styles from './voting.css'; import Checkbox from './voteCheckbox'; -const setRowClass = ({ pending, selected, voted }) => { +const setRowClass = (voteStatus) => { + if (!voteStatus) { + return ''; + } + const { pending, confirmed, unconfirmed } = voteStatus; if (pending) { return styles.pendingRow; - } else if (selected) { - return voted ? styles.votedRow : styles.upVoteRow; + } else if (confirmed !== unconfirmed) { + return confirmed ? styles.downVoteRow : styles.upVoteRow; } - return voted ? styles.downVoteRow : ''; + return confirmed ? styles.votedRow : ''; }; class VotingRow extends React.Component { // eslint-disable-next-line class-methods-use-this - shouldComponentUpdate(nextProps) { - return !!nextProps.data.dirty; + shouldComponentUpdate({ voteStatus }) { + const oldStatus = this.props.voteStatus; + return (!oldStatus && !!voteStatus) || + (!!oldStatus && !voteStatus) || + ((!!oldStatus && !!voteStatus) && + (oldStatus.unconfirmed !== voteStatus.unconfirmed || + oldStatus.pending !== voteStatus.pending)); } render() { - const props = this.props; - const { data } = props; - return ( + const { data, voteStatus, voteToggled } = this.props; + return ( diff --git a/src/components/voting/votingRow.test.js b/src/components/voting/votingRow.test.js index aaa4e0175..d303b048b 100644 --- a/src/components/voting/votingRow.test.js +++ b/src/components/voting/votingRow.test.js @@ -3,54 +3,48 @@ import { expect } from 'chai'; import { mount } from 'enzyme'; import PropTypes from 'prop-types'; import store from '../../store'; -import VotinRow from './votingRow'; +import VotingRow from './votingRow'; -describe('VotinRow', () => { - let wrapper; - - beforeEach(() => { - wrapper = mount(, - { - context: { store }, - childContextTypes: { store: PropTypes.object.isRequired }, - }, - ); - }); +describe('VotingRow', () => { + const votedStatus = { confirmed: true, unconfirmed: true, publicKey: 'sample_key' }; + const voteStatus = { confirmed: false, unconfirmed: true, publicKey: 'sample_key' }; + const unvoteStatus = { confirmed: true, unconfirmed: false, publicKey: 'sample_key' }; + const pendingStatus = { confirmed: true, unconfirmed: true, pending: true, publicKey: 'sample_key' }; + const props = { + data: {}, + voteToggled: () => {}, + }; + const options = { + 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, dirty: true }, - }); + const wrapper = mount(, options); const expectedClass = '_pendingRow'; const className = wrapper.find('tr').prop('className'); expect(className).to.contain(expectedClass); }); - 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, dirty: true }, - }); + it(`should TableRow has class name of "votedRow" when voteStatus.unconfirmed and + confirmed are true`, () => { + const wrapper = mount(, options); const expectedClass = '_votedRow'; const className = wrapper.find('tr').prop('className'); expect(className).to.contain(expectedClass); }); - 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, dirty: true }, - }); + it(`should TableRow has class name of "downVoteRow" when voteStatus.unconfirmed is false + but confirmed is true`, () => { + const wrapper = mount(, options); const expectedClass = '_downVoteRow'; const className = wrapper.find('tr').prop('className'); expect(className).to.contain(expectedClass); }); - 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, dirty: true }, - }); + it(`should TableRow has class name of "upVoteRow" when voteStatus.unconfirmed is false + but confirmed is true`, () => { + const wrapper = mount(, options); const expectedClass = '_upVoteRow'; const className = wrapper.find('tr').prop('className'); expect(className).to.contain(expectedClass); diff --git a/src/constants/actions.js b/src/constants/actions.js index 3b240d885..3fe3994ff 100644 --- a/src/constants/actions.js +++ b/src/constants/actions.js @@ -12,9 +12,10 @@ const actionTypes = { forgingStatsUpdated: 'FORGING_STATS_UPDATED', forgingReset: 'FORGING_RESET', VotePlaced: 'VOTE_PLACED', - addedToVoteList: 'ADDED_TO_VOTE_LIST', - removedFromVoteList: 'REMOVEd_FROM_VOTE_LIST', - votesCleared: 'VOTES_CLEARED', + voteToggled: 'VOTE_TOGGLED', + votesAdded: 'VOTES_ADDED', + votesUpdated: 'VOTES_UPDATED', + delegatesAdded: 'DELEGATES_ADDED', pendingVotesAdded: 'PENDING_VOTES_ADDED', toastDisplayed: 'TOAST_DISPLAYED', toastHidden: 'TOAST_HIDDEN', diff --git a/src/store/middlewares/account.js b/src/store/middlewares/account.js index 21f480a45..3bbdf0bd4 100644 --- a/src/store/middlewares/account.js +++ b/src/store/middlewares/account.js @@ -2,7 +2,7 @@ import { getAccountStatus, getAccount, transactions } from '../../utils/api/acco import { accountUpdated, accountLoggedIn } from '../../actions/account'; import { transactionsUpdated } from '../../actions/transactions'; import { activePeerUpdate } from '../../actions/peers'; -import { clearVoteLists } from '../../actions/voting'; +import { votesFetched } from '../../actions/voting'; import actionTypes from '../../constants/actions'; import { fetchAndUpdateForgedBlocks } from '../../actions/forging'; import { getDelegate } from '../../utils/api/delegate'; @@ -81,7 +81,14 @@ const votePlaced = (store, action) => { action.data.confirmed, transactionTypes.vote); if (voteTransaction) { - store.dispatch(clearVoteLists()); + const state = store.getState(); + const { peers, account } = state; + + store.dispatch(votesFetched({ + activePeer: peers.data, + address: account.address, + type: 'update', + })); } }; diff --git a/src/store/middlewares/account.test.js b/src/store/middlewares/account.test.js index 99676625f..875c82191 100644 --- a/src/store/middlewares/account.test.js +++ b/src/store/middlewares/account.test.js @@ -6,7 +6,7 @@ import * as delegateApi from '../../utils/api/delegate'; import actionTypes from '../../constants/actions'; import transactionTypes from '../../constants/transactionTypes'; import { SYNC_ACTIVE_INTERVAL, SYNC_INACTIVE_INTERVAL } from '../../constants/api'; -import { clearVoteLists } from '../../actions/voting'; +import * as votingActions from '../../actions/voting'; describe('Account middleware', () => { let store; @@ -49,6 +49,7 @@ describe('Account middleware', () => { }, account: { balance: 0, + address: 'sample_address', }, transactions: { pending: [{ @@ -164,10 +165,15 @@ describe('Account middleware', () => { delegateApiMock.restore(); }); - it(`should dispatch clearVoteLists action on ${actionTypes.transactionsUpdated} action if action.data.confirmed contains delegateRegistration transactions`, () => { + it(`should dispatch ${actionTypes.votesFetched} action on ${actionTypes.transactionsUpdated} action if action.data.confirmed contains delegateRegistration transactions`, () => { + const actionSpy = spy(votingActions, 'votesFetched'); transactionsUpdatedAction.data.confirmed[0].type = transactionTypes.vote; middleware(store)(next)(transactionsUpdatedAction); - expect(store.dispatch).to.have.been.calledWith(clearVoteLists()); + expect(actionSpy).to.have.been.calledWith({ + activePeer: state.peers.data, + address: state.account.address, + type: 'update', + }); }); }); diff --git a/src/store/reducers/voting.js b/src/store/reducers/voting.js index 4894e6e8f..7578397c1 100644 --- a/src/store/reducers/voting.js +++ b/src/store/reducers/voting.js @@ -1,89 +1,125 @@ 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; + +const mergeVotes = (newList, oldDict) => { + const newDict = newList.reduce((tempDict, delegate) => { + tempDict[delegate.username] = { + confirmed: true, + unconfirmed: true, + pending: false, + publicKey: delegate.publicKey, + }; + return tempDict; + }, {}); + + Object.keys(oldDict).forEach((username) => { + // By pendingVotesAdded, we set confirmed equal to unconfirmed, + // to recognize pending-not-voted items from pending-voted + // so here we just check unconfirmed flag. + const { confirmed, unconfirmed, pending } = oldDict[username]; + if (// we've voted but it's not in the new list + (pending && unconfirmed && newDict[username] === undefined) || + // we've un-voted but it still exists in the new list + (pending && !unconfirmed && newDict[username] !== undefined) || + // dirty, not voted for and not updated in other client + (!pending && unconfirmed !== confirmed && + (newDict[username] === undefined || confirmed === newDict[username].confirmed)) + ) { + newDict[username] = Object.assign({}, oldDict[username]); } }); - return idx; + + return newDict; }; + /** * voting reducer * * @param {Object} state * @param {Object} action */ -const voting = (state = { votedList: [], unvotedList: [] }, action) => { +const voting = (state = { votes: {}, delegates: [], totalDelegates: 0 }, 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; - } + case actionTypes.votesAdded: return Object.assign({}, state, { + votes: action.data.list + .reduce((votesDict, delegate) => { + votesDict[delegate.username] = { + confirmed: true, + unconfirmed: true, + publicKey: delegate.publicKey, + }; + return votesDict; + }, {}), refresh: false, - votedList: [ - ...state.votedList, - Object.assign(action.data, { selected: true, dirty: 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; - } + + case actionTypes.delegatesAdded: + return Object.assign({}, state, { + delegates: action.data.refresh ? action.data.list : + [...state.delegates, ...action.data.list], + totalDelegates: action.data.totalDelegates, + refresh: true, + }); + + case actionTypes.voteToggled: return Object.assign({}, state, { refresh: false, - unvotedList: [ - ...state.unvotedList, - Object.assign(action.data, { selected: false, dirty: true }), - ], + votes: Object.assign({}, state.votes, { + [action.data.username]: { + confirmed: state.votes[action.data.username] ? + state.votes[action.data.username].confirmed : false, + unconfirmed: state.votes[action.data.username] ? + !state.votes[action.data.username].unconfirmed : true, + publicKey: action.data.publicKey, + }, + }), }); + + case actionTypes.accountLoggedOut: return Object.assign({}, state, { - votedList: [], - unvotedList: [], + votes: {}, + delegates: [], refresh: true, }); + case actionTypes.votesCleared: return Object.assign({}, state, { - votedList: state.votedList.filter(item => !item.pending), - unvotedList: state.unvotedList.filter(item => !item.pending), + votes: Object.keys(state.votes).reduce((votesDict, username) => { + votesDict[username] = { + confirmed: state.votes[username].confirmed, + unconfirmed: state.votes[username].confirmed, + publicKey: state.votes[username].publicKey, + pending: false, + }; + return votesDict; + }, {}), refresh: true, }); + + case actionTypes.votesUpdated: + return Object.assign({}, state, { + votes: mergeVotes(action.data.list, state.votes), + refresh: false, + }); + 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 })), + refresh: false, + votes: Object.keys(state.votes).reduce((votesDict, username) => { + const { confirmed, unconfirmed, publicKey, pending } = state.votes[username]; + const nextPendingStatus = pending || (confirmed !== unconfirmed); + + votesDict[username] = { + confirmed: nextPendingStatus ? !confirmed : confirmed, + unconfirmed, + pending: nextPendingStatus, + publicKey, + }; + return votesDict; + }, {}), }); + default: return state; } diff --git a/src/store/reducers/voting.test.js b/src/store/reducers/voting.test.js index 57a63abb5..129f31c24 100644 --- a/src/store/reducers/voting.test.js +++ b/src/store/reducers/voting.test.js @@ -3,138 +3,182 @@ 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', - }, - ], + const initialState = { votes: {}, delegates: [], refresh: true }; + const cleanVotes = { + username1: { confirmed: false, unconfirmed: false, publicKey: 'sample_key' }, + username2: { confirmed: true, unconfirmed: true, publicKey: 'sample_key' }, + username3: { confirmed: false, unconfirmed: false, publicKey: 'sample_key' }, + }; + const dirtyVotes = { + username1: { confirmed: false, unconfirmed: true, publicKey: 'sample_key' }, + username2: { confirmed: true, unconfirmed: true, publicKey: 'sample_key' }, + username3: { confirmed: false, unconfirmed: false, publicKey: 'sample_key' }, + }; + const pendingVotes = { + username1: { confirmed: true, unconfirmed: true, pending: true, publicKey: 'sample_key' }, + username2: { confirmed: true, unconfirmed: true, pending: false, publicKey: 'sample_key' }, + username3: { confirmed: false, unconfirmed: false, pending: false, publicKey: 'sample_key' }, }; - it('should render default state', () => { + const restoredVotes = { + username1: { confirmed: false, unconfirmed: false, pending: false, publicKey: 'sample_key' }, + username2: { confirmed: true, unconfirmed: true, pending: false, publicKey: 'sample_key' }, + username3: { confirmed: false, unconfirmed: false, pending: false, publicKey: 'sample_key' }, + }; + const delegates1 = [ + { username: 'username1', publicKey: 'sample_key' }, + { username: 'username2', publicKey: 'sample_key' }, + ]; + const delegates2 = [ + { username: 'username3', publicKey: 'sample_key' }, + { username: 'username4', publicKey: 'sample_key' }, + ]; + const fullDelegates = [...delegates1, ...delegates2]; + + it('should return default state if action does not match', () => { const action = { type: '', }; + const state = { votes: cleanVotes }; const changedState = voting(state, action); + expect(changedState).to.be.equal(state); }); - it('should be 1 items in state.unvotedList', () => { + + it('should clean up with action: accountLoggedOut', () => { const action = { - type: actionTypes.addedToVoteList, - data: { - voted: true, - address: 'unvoted address1', - }, + type: actionTypes.accountLoggedOut, }; + const state = { votes: cleanVotes, delegates: fullDelegates, refresh: false }; const changedState = voting(state, action); - expect(changedState.unvotedList).to.have.lengthOf(1); + + expect(changedState).to.be.deep.equal(initialState); }); - it('should return state if action.data existed in votedList before', () => { + it('should fill votes object with action: votesAdded', () => { const action = { - type: actionTypes.addedToVoteList, + type: actionTypes.votesAdded, data: { - address: 'voted address1', + list: delegates1, }, }; - const changedState = voting(state, action); - expect(changedState).to.be.deep.equal(state); + const expectedState = { + votes: { + username1: { confirmed: true, unconfirmed: true, publicKey: 'sample_key' }, + username2: { confirmed: true, unconfirmed: true, publicKey: 'sample_key' }, + }, + delegates: [], + refresh: false, + }; + const changedState = voting(initialState, action); + + expect(changedState).to.be.deep.equal(expectedState); }); - it('should be 3 items in state.votedList', () => { + it('should append to delegates list with action: delegatesAdded, refresh: false', () => { const action = { - type: actionTypes.addedToVoteList, + type: actionTypes.delegatesAdded, data: { - address: 'voted address3', + list: delegates2, + totalCount: 100, + refresh: false, }, }; + const state = { + delegates: delegates1, + }; + const expectedState = { + delegates: fullDelegates, + refresh: true, + totalDelegates: 100, + }; const changedState = voting(state, action); - expect(changedState.votedList).to.have.lengthOf(3); + + expect(changedState.delegates).to.be.deep.equal(expectedState.delegates); }); - it('should be 1 items in state.votedList', () => { + it('should replace delegates with the new delegates list with action: delegatesAdded, refresh: true', () => { const action = { - type: actionTypes.removedFromVoteList, + type: actionTypes.delegatesAdded, data: { - voted: false, - address: 'voted address1', + list: delegates1, + totalDelegates: 100, + refresh: true, }, }; + const state = { + delegates: delegates2, + }; + const expectedState = { + delegates: delegates1, + refresh: true, + totalDelegates: 100, + }; const changedState = voting(state, action); - expect(changedState.votedList).to.have.lengthOf(1); + + expect(changedState).to.be.deep.equal(expectedState); }); - it('should return state if action.data existed in unvotedList before', () => { + it('should toggle unconfirmed state, with action: voteToggled', () => { const action = { - type: actionTypes.removedFromVoteList, - data: { - voted: true, - address: 'unvoted address2', - }, + type: actionTypes.voteToggled, + data: delegates1[0], + }; + const state = { votes: cleanVotes }; + const expectedState = { + votes: dirtyVotes, + refresh: false, }; const changedState = voting(state, action); - expect(changedState).to.be.deep.equal(state); + + expect(changedState).to.be.deep.equal(expectedState); }); - it('should be 3 items in state.unvotedList', () => { + it('should add to votes dictionary in not exist, with action: voteToggled', () => { const action = { - type: actionTypes.removedFromVoteList, - data: { - voted: true, - address: 'unvoted address3', + type: actionTypes.voteToggled, + data: delegates1[0], + }; + const expectedState = { + votes: { + [delegates1[0].username]: dirtyVotes[delegates1[0].username], }, + delegates: [], + refresh: false, }; - const changedState = voting(state, action); - expect(changedState.unvotedList).to.have.lengthOf(3); + const changedState = voting(initialState, action); + + expect(changedState).to.be.deep.equal(expectedState); }); - it('should add pending to all items in votedList and unvotedList', () => { + it('should mark the toggles votes as pending, with action: pendingVotesAdded ', () => { const action = { type: actionTypes.pendingVotesAdded, }; + const state = { + votes: dirtyVotes, + }; const expectedState = { - votedList: [ - { - address: 'voted address1', - pending: true, - }, - { - address: 'voted address2', - pending: true, - }, - ], - unvotedList: [ - { - address: 'unvoted address1', - pending: true, - }, - { - address: 'unvoted address2', - pending: true, - }, - ], + votes: pendingVotes, + refresh: false, }; const changedState = voting(state, action); expect(changedState).to.be.deep.equal(expectedState); }); - it('should remove all pending in votedList and unvotedList', () => { + it('should remove all pending flags from votes, with action: votesCleared', () => { const action = { type: actionTypes.votesCleared, }; + const state = { + votes: dirtyVotes, + }; + + const expectedState = { + votes: restoredVotes, + refresh: true, + }; 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); + + expect(changedState).to.be.deep.equal(expectedState); }); }); diff --git a/src/utils/api/delegate.js b/src/utils/api/delegate.js index 5cc5325e3..22f24a5d7 100644 --- a/src/utils/api/delegate.js +++ b/src/utils/api/delegate.js @@ -14,29 +14,32 @@ export const vote = (activePeer, secret, publicKey, voteList, unvoteList, second requestToActivePeer(activePeer, 'accounts/delegates', { secret, publicKey, - delegates: voteList.map(delegate => `+${delegate.publicKey}`).concat( - unvoteList.map(delegate => `-${delegate.publicKey}`), + delegates: voteList.map(delegate => `+${delegate}`).concat( + unvoteList.map(delegate => `-${delegate}`), ), secondSecret, }); -export const voteAutocomplete = (activePeer, username, votedList) => { +export const voteAutocomplete = (activePeer, username, votedDict) => { const options = { q: username }; return new Promise((resolve, reject) => listDelegates(activePeer, options) .then((response) => { resolve(response.delegates.filter(delegate => - votedList.filter(item => item.username === delegate.username).length === 0, + Object.keys(votedDict).filter(item => item === delegate.username).length === 0, )); }) .catch(reject), ); }; -export const unvoteAutocomplete = (username, votedList) => +export const unvoteAutocomplete = (username, votedDict) => new Promise((resolve) => { - resolve(votedList.filter(delegate => delegate.username.indexOf(username) !== -1)); + resolve( + Object.keys(votedDict) + .filter(delegate => delegate.indexOf(username) !== -1) + .map(element => ({ username: element, publicKey: votedDict[element].publicKey }))); }); export const registerDelegate = (activePeer, username, secret, secondSecret = null) => { diff --git a/src/utils/api/delegate.test.js b/src/utils/api/delegate.test.js index c86980ac1..97e6f8370 100644 --- a/src/utils/api/delegate.test.js +++ b/src/utils/api/delegate.test.js @@ -70,15 +70,19 @@ describe('Utils: Delegate', () => { describe('unvoteAutocomplete', () => { it('should return a promise', () => { - const votedList = ['genesis_1', 'genesis_2', 'genesis_3']; + const voteList = { + genesis_1: { confirmed: true, unconfirmed: false, publicKey: 'sample_key' }, + genesis_2: { confirmed: true, unconfirmed: false, publicKey: 'sample_key' }, + genesis_3: { confirmed: true, unconfirmed: false, publicKey: 'sample_key' }, + }; const nonExistingUsername = 'genesis_4'; - const promise = unvoteAutocomplete(username, votedList); + const promise = unvoteAutocomplete(username, voteList); expect(typeof promise.then).to.be.equal('function'); promise.then((result) => { expect(result).to.be.equal(true); }); - unvoteAutocomplete(nonExistingUsername, votedList).then((result) => { + unvoteAutocomplete(nonExistingUsername, voteList).then((result) => { expect(result).to.be.equal(false); }); });