diff --git a/features/step_definitions/generic.step.js b/features/step_definitions/generic.step.js index f749f811b..865c6fdfe 100644 --- a/features/step_definitions/generic.step.js +++ b/features/step_definitions/generic.step.js @@ -119,6 +119,11 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { waitForElemAndCheckItsText(selectorClass, text, callback); }); + Then('I should see "{elementName}" element with text:', (elementName, text, 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); @@ -136,6 +141,10 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { waitForElemAndClickIt('.login-button', callback); }); + When('I go to "{url}"', (url, callback) => { + browser.get(`${browser.params.baseURL}#${url}`).then(callback); + }); + When('I {iterations} times move mouse randomly', (iterations, callback) => { const actions = browser.actions(); /** diff --git a/features/support/util.js b/features/support/util.js index ba95c7315..4ba37f43e 100644 --- a/features/support/util.js +++ b/features/support/util.js @@ -5,7 +5,7 @@ const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); const expect = chai.expect; const EC = protractor.ExpectedConditions; -const waitTime = 4000; +const waitTime = 10000; function waitForElemAndCheckItsText(selector, text, callback) { const elem = element(by.css(selector)); diff --git a/features/voting.feature b/features/voting.feature index c90313778..c20029afd 100644 --- a/features/voting.feature +++ b/features/voting.feature @@ -111,3 +111,17 @@ Feature: Voting tab And I click "vote button" And I click "cancel button" Then I should see no "modal dialog" + + Scenario: should allow to select delegates by URL + Given I'm logged in as "delegate candidate" + When I go to "/main/voting/vote?votes=standby_27,standby_28,standby_29,nonexisting_22&unvotes=standby_33" + Then I should see text "3 delegate names successfully resolved to add vote to." in "upvotes message" element + And I should see text "1 of delegate names selected for unvote were not voted for:standby_33" in "notVotedYet message" element + And I should see text "1 of entered delegate names could not be resolved:nonexisting_22" in "notFound message" element + And I should see "vote list" element with text: + """ + standby_27 + standby_28 + standby_29 + """ + diff --git a/src/actions/voting.js b/src/actions/voting.js index 7dee77a66..d17776ff6 100644 --- a/src/actions/voting.js +++ b/src/actions/voting.js @@ -1,9 +1,13 @@ -import { vote, listAccountDelegates, listDelegates } from '../utils/api/delegate'; -import { transactionAdded } from './transactions'; import { errorAlertDialogDisplayed } from './dialog'; +import { + listAccountDelegates, + listDelegates, + vote, +} from '../utils/api/delegate'; import { passphraseUsed } from './account'; -import actionTypes from '../constants/actions'; +import { transactionAdded } from './transactions'; import Fees from '../constants/fees'; +import actionTypes from '../constants/actions'; import transactionTypes from '../constants/transactionTypes'; /** @@ -45,6 +49,22 @@ export const voteToggled = data => ({ data, }); + +/** + * Updates vote lookup status of the given delegate name + */ +export const voteLookupStatusUpdated = data => ({ + type: actionTypes.voteLookupStatusUpdated, + data, +}); + +/** + * Clears all vote lookup statuses + */ +export const voteLookupStatusCleared = () => ({ + type: actionTypes.voteLookupStatusCleared, +}); + /** * Makes Api call to register votes * Adds pending state and then after the duration of one round @@ -122,3 +142,17 @@ export const delegatesFetched = ({ activePeer, q, offset, refresh }) => dispatch(delegatesAdded({ list: delegates, totalDelegates: totalCount, refresh })); }); }; + + +/** + * Get list of delegates current account has voted for and dispatch it with votes from url + */ +export const urlVotesFound = ({ activePeer, upvotes, unvotes, address }) => + (dispatch) => { + const processUrlVotes = (votes) => { + dispatch(votesAdded({ list: votes, upvotes, unvotes })); + }; + listAccountDelegates(activePeer, address) + .then(({ delegates }) => { processUrlVotes(delegates); }) + .catch(() => { processUrlVotes([]); }); + }; diff --git a/src/actions/voting.test.js b/src/actions/voting.test.js index 9e6614b18..ab6742902 100644 --- a/src/actions/voting.test.js +++ b/src/actions/voting.test.js @@ -6,8 +6,10 @@ import { votesUpdated, votesAdded, voteToggled, + voteLookupStatusUpdated, votePlaced, votesFetched, + urlVotesFound, delegatesFetched, delegatesAdded, } from './voting'; @@ -36,6 +38,20 @@ describe('actions: voting', () => { }); }); + describe('voteLookupStatusUpdated', () => { + it('should create an action to update lookup status of any given delegate name', () => { + const data = { + label: 'dummy', + }; + const expectedAction = { + data, + type: actionTypes.voteLookupStatusUpdated, + }; + + expect(voteLookupStatusUpdated(data)).to.be.deep.equal(expectedAction); + }); + }); + describe('votesAdded', () => { it('should create an action to remove data from vote list', () => { const data = delegateList; @@ -132,27 +148,45 @@ describe('actions: voting', () => { }); describe('votesFetched', () => { + let delegateApiMock; const data = { activePeer: {}, address: '8096217735672704724L', }; const delegates = delegateList; - const actionFunction = votesFetched(data); + + beforeEach(() => { + delegateApiMock = sinon.stub(delegateApi, 'listAccountDelegates').returnsPromise(); + }); + + afterEach(() => { + delegateApiMock.restore(); + }); + it('should create an action function', () => { + const actionFunction = votesFetched(data); expect(typeof actionFunction).to.be.deep.equal('function'); }); - it.skip('should dispatch votesAdded action if resolved', () => { - const delegateApiMock = sinon.stub(delegateApi, 'listAccountDelegates'); + it('should dispatch votesAdded action when resolved if type !== \'update\'', () => { const dispatch = sinon.spy(); - delegateApiMock.returnsPromise().resolves({ delegates }); + delegateApiMock.resolves({ delegates }); const expectedAction = { list: delegates }; - actionFunction(dispatch); + votesFetched(data)(dispatch); expect(dispatch).to.have.been.calledWith(votesAdded(expectedAction)); - delegateApiMock.restore(); + }); + + it('should dispatch votesUpdated action when resolved if type === \'update\'', () => { + const dispatch = sinon.spy(); + + delegateApiMock.resolves({ delegates }); + const expectedAction = { list: delegates }; + + votesFetched({ ...data, type: 'update' })(dispatch); + expect(dispatch).to.have.been.calledWith(votesUpdated(expectedAction)); }); }); @@ -182,4 +216,54 @@ describe('actions: voting', () => { delegateApiMock.restore(); }); }); + + describe('urlVotesFound', () => { + let delegateApiMock; + const data = { + activePeer: {}, + address: '8096217735672704724L', + upvotes: [], + unvotes: [], + }; + const delegates = delegateList; + let expectedAction = { + list: delegates, + upvotes: [], + unvotes: [], + }; + + beforeEach(() => { + delegateApiMock = sinon.stub(delegateApi, 'listAccountDelegates').returnsPromise(); + }); + + afterEach(() => { + delegateApiMock.restore(); + }); + + it('should create an action function', () => { + expect(typeof urlVotesFound(data)).to.be.deep.equal('function'); + }); + + it('should dispatch votesAdded action when resolved', () => { + const dispatch = sinon.spy(); + + + urlVotesFound(data)(dispatch); + delegateApiMock.resolves({ delegates }); + expect(dispatch).to.have.been.calledWith(votesAdded(expectedAction)); + }); + + it('should dispatch votesAdded action when rejected', () => { + const dispatch = sinon.spy(); + + expectedAction = { + ...expectedAction, + list: [], + }; + + urlVotesFound(data)(dispatch); + delegateApiMock.rejects(); + expect(dispatch).to.have.been.calledWith(votesAdded(expectedAction)); + }); + }); }); diff --git a/src/components/clickToSend/index.js b/src/components/clickToSend/index.js index fea11486a..1d1d713d6 100644 --- a/src/components/clickToSend/index.js +++ b/src/components/clickToSend/index.js @@ -6,12 +6,19 @@ import { fromRawLsk } from '../../utils/lsk'; const ClickToSend = ({ rawAmount, amount, className, recipient, children, disabled }) => { const normalizedAmount = rawAmount ? fromRawLsk(rawAmount) : amount; + const urlParams = new URLSearchParams(); + if (normalizedAmount) { + urlParams.set('amount', normalizedAmount); + } + if (recipient) { + urlParams.set('recipient', recipient); + } return ( disabled ? children : + to={`send?${urlParams}`}> {children} ); diff --git a/src/components/dialog/dialog.css b/src/components/dialog/dialog.css index 24a78f789..63673dbd5 100644 --- a/src/components/dialog/dialog.css +++ b/src/components/dialog/dialog.css @@ -14,8 +14,6 @@ } & header { - margin: -24px; - margin-bottom: 24px; border-radius: 2px 2px 0 0; color: rgba(255, 255, 255, 0.87); @@ -32,12 +30,29 @@ } } +.body { + border-radius: 2px 2px 0 0; + padding: 0px; +} + +.innerBody { + padding: 24px; + max-height: calc(100vh - 64px); /* stylelint-disable-line */ + overflow: auto; +} + @media screen and (min-width: 960px) { .fullscreen { width: 75vw; /* stylelint-disable-line */ } } +@media screen and (min-width: 600px) { + .innerBody { + max-height: calc(100vh - 100px); /* stylelint-disable-line */ + } +} + .error { background-color: #c62828; } @@ -45,3 +60,4 @@ .success { background-color: #7cb342; } + diff --git a/src/components/dialog/dialog.js b/src/components/dialog/dialog.js index dcd640861..141bcebc1 100644 --- a/src/components/dialog/dialog.js +++ b/src/components/dialog/dialog.js @@ -3,6 +3,7 @@ import Dialog from 'react-toolbox/lib/dialog'; import Navigation from 'react-toolbox/lib/navigation'; import AppBar from 'react-toolbox/lib/app_bar'; import { IconButton } from 'react-toolbox/lib/button'; +import { parseSearchParams } from '../../utils/searchParams'; import styles from './dialog.css'; import getDialogs from './dialogs'; import routesReg from '../../utils/routes'; @@ -43,24 +44,13 @@ class DialogElement extends Component { } } - // eslint-disable-next-line class-methods-use-this - parseParams(search) { - return search.replace(/^\?/, '').split('&&').reduce((acc, param) => { - const keyValue = param.split('='); - if (keyValue[0] !== '' && keyValue[1] !== 'undefined') { - acc[keyValue[0]] = keyValue[1]; - } - return acc; - }, {}); - } - open(config, dialog) { clearTimeout(this.timeout); this.setState({ hidden: false }); this.props.dialogDisplayed({ title: dialog.title, childComponent: dialog.component, - childComponentProps: this.parseParams(this.props.history.location.search), + childComponentProps: parseSearchParams(this.props.history.location.search), }); } @@ -88,7 +78,7 @@ class DialogElement extends Component { -
+
{this.props.dialog.childComponent ? ( ( - isAuthenticated ? render(matchProps) : + isAuthenticated ? render(matchProps) : )}/> ); diff --git a/src/components/voteDialog/index.test.js b/src/components/voteDialog/index.test.js index d9cd01aaf..fbe68d4bf 100644 --- a/src/components/voteDialog/index.test.js +++ b/src/components/voteDialog/index.test.js @@ -1,9 +1,9 @@ import React from 'react'; -import { I18nextProvider } from 'react-i18next'; -import { Provider } from 'react-redux'; import chai, { expect } from 'chai'; +import { BrowserRouter as Router } from 'react-router-dom'; import { mount } from 'enzyme'; import chaiEnzyme from 'chai-enzyme'; +import PropTypes from 'prop-types'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import configureMockStore from 'redux-mock-store'; @@ -49,12 +49,16 @@ const store = configureMockStore([])({ describe('VoteDialog HOC', () => { let wrapper; + const options = { + context: { store, history, i18n }, + childContextTypes: { + store: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + i18n: PropTypes.object.isRequired, + }, + }; beforeEach(() => { - wrapper = mount( - - - - ); + wrapper = mount(, options); }); it('should render VoteDialog', () => { @@ -75,5 +79,6 @@ describe('VoteDialog HOC', () => { const actionsSpy = sinon.spy(votingActions, 'voteToggled'); wrapper.find('VoteDialog').props().voteToggled([]); expect(actionsSpy).to.be.calledWith(); + actionsSpy.restore(); }); }); diff --git a/src/components/voteDialog/voteAutocomplete.js b/src/components/voteDialog/voteAutocomplete.js index 7ac34b4fa..d705b2bea 100644 --- a/src/components/voteDialog/voteAutocomplete.js +++ b/src/components/voteDialog/voteAutocomplete.js @@ -196,7 +196,7 @@ export class VoteAutocompleteRaw extends React.Component { return (

{this.props.t('Add vote to')}

-
+
{votedList.map( item =>

{this.props.t('Remove vote from')}

-
+
{unvotedList.map( item =>
+ mount({node}, context); + const ordinaryAccount = { passphrase: 'pass', publicKey: 'key', @@ -15,9 +18,11 @@ const ordinaryAccount = { balance: 10e8, }; const accountWithSecondPassphrase = { - passphrase: 'pass', + passphrase: 'awkward service glimpse punch genre calm grow life bullet boil match like', + secondPassphrase: 'forest around decrease farm vanish permit hotel clay senior matter endorse domain', publicKey: 'key', secondSignature: 1, + balance: 10e8, }; const votes = { username1: { publicKey: 'sample_key', confirmed: true, unconfirmed: false }, @@ -28,14 +33,15 @@ const delegates = [ { username: 'username2', publicKey: '123HG3522345L' }, ]; -const store = configureMockStore([])({ +const state = { account: ordinaryAccount, voting: { votes, delegates, }, peers: { data: {} }, -}); +}; +const store = configureMockStore([])(state); let props; describe('VoteDialog', () => { @@ -60,7 +66,7 @@ describe('VoteDialog', () => { describe('Ordinary account', () => { beforeEach(() => { - wrapper = mount(, options); + wrapper = mountWithRouter(, options); }); it('should render an InfoParagraph', () => { @@ -103,7 +109,8 @@ describe('VoteDialog', () => { votePlaced: () => {}, t: key => key, }; - const mounted = mount(, options); + const mounted = mountWithRouter( + , options); const primaryButton = mounted.find('VoteDialog .primary-button button'); expect(primaryButton.props().disabled).to.be.equal(true); @@ -112,9 +119,20 @@ describe('VoteDialog', () => { describe('Account with second passphrase', () => { it('should fire votePlaced action with the provided secondPassphrase', () => { - wrapper = mount(, options); - const secondPassphrase = 'test second passphrase'; - wrapper.instance().handleChange('secondPassphrase', secondPassphrase); + wrapper = mountWithRouter( + , + { + ...options, + context: { + ...options.context, + store: configureMockStore([])({ + ...state, + account: accountWithSecondPassphrase, + }), + }, + }); + + wrapper.find('.second-passphrase input').simulate('change', { target: { value: accountWithSecondPassphrase.secondPassphrase } }); wrapper.find('.primary-button button').simulate('click'); expect(props.votePlaced).to.have.been.calledWith({ @@ -122,7 +140,7 @@ describe('VoteDialog', () => { account: accountWithSecondPassphrase, votes, passphrase: accountWithSecondPassphrase.passphrase, - secondSecret: secondPassphrase, + secondSecret: accountWithSecondPassphrase.secondPassphrase, }); }); }); @@ -133,7 +151,8 @@ describe('VoteDialog', () => { extraVotes[`standby_${i}`] = { confirmed: false, unconfirmed: true, publicKey: `public_key_${i}` }; } const noVoteProps = Object.assign({}, props, { votes: extraVotes }); - const mounted = mount(, options); + const mounted = mountWithRouter( + , options); const primaryButton = mounted.find('VoteDialog .primary-button button'); expect(primaryButton.props().disabled).to.be.equal(true); diff --git a/src/components/voteUrlProcessor/index.js b/src/components/voteUrlProcessor/index.js new file mode 100644 index 000000000..ff5c2a8e4 --- /dev/null +++ b/src/components/voteUrlProcessor/index.js @@ -0,0 +1,41 @@ +import { connect } from 'react-redux'; +import { translate } from 'react-i18next'; +import { withRouter } from 'react-router'; + +import { + urlVotesFound, + voteLookupStatusCleared, + voteToggled, + votesAdded, +} from '../../actions/voting'; +import VoteUrlProcessor from './voteUrlProcessor'; + +const filterObjectPropsWithValue = (object = {}, value) => ( + Object.keys(object).filter(key => object[key] === value) +); + +const mapStateToProps = state => ({ + votes: state.voting.votes, + urlVoteCount: Object.keys(state.voting.voteLookupStatus || {}).length, + pending: filterObjectPropsWithValue(state.voting.voteLookupStatus, 'pending'), + unvotes: filterObjectPropsWithValue(state.voting.voteLookupStatus, 'unvotes'), + upvotes: filterObjectPropsWithValue(state.voting.voteLookupStatus, 'upvotes'), + alreadyVoted: filterObjectPropsWithValue(state.voting.voteLookupStatus, 'alreadyVoted'), + notVotedYet: filterObjectPropsWithValue(state.voting.voteLookupStatus, 'notVotedYet'), + notFound: filterObjectPropsWithValue(state.voting.voteLookupStatus, 'notFound'), + account: state.account, + activePeer: state.peers.data, +}); + +const mapDispatchToProps = dispatch => ({ + voteToggled: data => dispatch(voteToggled(data)), + votesAdded: data => dispatch(votesAdded(data)), + urlVotesFound: data => dispatch(urlVotesFound(data)), + clearVoteLookupStatus: () => dispatch(voteLookupStatusCleared()), +}); + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)( + translate()(VoteUrlProcessor), + ), +); diff --git a/src/components/voteUrlProcessor/index.test.js b/src/components/voteUrlProcessor/index.test.js new file mode 100644 index 000000000..40e8979a4 --- /dev/null +++ b/src/components/voteUrlProcessor/index.test.js @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { BrowserRouter as Router } from 'react-router-dom'; +import configureMockStore from 'redux-mock-store'; +import sinon from 'sinon'; +import i18n from '../../i18n'; // initialized i18next instance +import * as votingActions from '../../actions/voting'; +import VoteUrlProcessor from './voteUrlProcessor'; +import VoteUrlProcessorHOC from './index'; + + +describe('VoteUrlProcessorHOC', () => { + let wrapper; + const actionData = { + dummy: 'dummy', + }; + const state = { + peers: { data: {} }, + account: {}, + voting: { + voteLookupStatus: { + delegate_name: 'pending', + }, + }, + }; + const store = configureMockStore([])(state); + const options = { + context: { i18n, store }, + childContextTypes: { + i18n: PropTypes.object.isRequired, + store: PropTypes.object.isRequired, + }, + }; + + beforeEach(() => { + wrapper = mount(, options); + }); + + it('should render VoteUrlProcessor', () => { + expect(wrapper.find(VoteUrlProcessor)).to.have.lengthOf(1); + }); + + it('should bind voteToggled action to VoteUrlProcessor props.voteToggled', () => { + const actionsSpy = sinon.spy(votingActions, 'voteToggled'); + wrapper.find(VoteUrlProcessor).props().voteToggled(actionData); + expect(actionsSpy).to.be.calledWith(actionData); + actionsSpy.restore(); + }); + + it('should bind votesAdded action to VoteUrlProcessor props.votesAdded', () => { + const actionsSpy = sinon.spy(votingActions, 'votesAdded'); + wrapper.find(VoteUrlProcessor).props().votesAdded(actionData); + expect(actionsSpy).to.be.calledWith(actionData); + actionsSpy.restore(); + }); + + it('should bind urlVotesFound action to VoteUrlProcessor props.urlVotesFound', () => { + const actionMock = sinon.mock(votingActions); + actionMock.expects('urlVotesFound').withExactArgs(actionData).returns({ type: 'DUMMY' }); + wrapper.find(VoteUrlProcessor).props().urlVotesFound(actionData); + actionMock.restore(); + }); + + it('should bind voteLookupStatusCleared action to VoteUrlProcessor props.clearVoteLookupStatus', () => { + const actionsSpy = sinon.spy(votingActions, 'voteLookupStatusCleared'); + wrapper.find(VoteUrlProcessor).props().clearVoteLookupStatus(); + expect(actionsSpy).to.be.calledWith(); + actionsSpy.restore(); + }); +}); + diff --git a/src/components/voteUrlProcessor/voteUrlProcessor.css b/src/components/voteUrlProcessor/voteUrlProcessor.css new file mode 100644 index 000000000..b5b439135 --- /dev/null +++ b/src/components/voteUrlProcessor/voteUrlProcessor.css @@ -0,0 +1,28 @@ +.error, +.success { + padding: 16px 24px; + margin: 0 -24px; + color: black; + border-bottom: 1px solid #e6e6e6; +} + +.error:first-child, +.success:first-child { + margin-top: -24px; +} + +.error { + background-color: rgb(255, 228, 220) !important; +} + +.success { + background-color: rgb(226, 238, 213) !important; +} + +.chip { + margin: 6px 1px -8px 1px; +} + +.center { + text-align: center; +} diff --git a/src/components/voteUrlProcessor/voteUrlProcessor.js b/src/components/voteUrlProcessor/voteUrlProcessor.js new file mode 100644 index 000000000..ba8d14883 --- /dev/null +++ b/src/components/voteUrlProcessor/voteUrlProcessor.js @@ -0,0 +1,72 @@ +import Chip from 'react-toolbox/lib/chip'; +import ProgressBar from 'react-toolbox/lib/progress_bar'; +import React from 'react'; + +import { parseSearchParams } from '../../utils/searchParams'; +import styles from './voteUrlProcessor.css'; + +export default class VoteUrlProcessor extends React.Component { + componentDidMount() { + this.props.clearVoteLookupStatus(); + const params = parseSearchParams(this.props.history.location.search); + if (params.votes || params.unvotes) { + const upvotes = params.votes ? params.votes.split(',') : []; + const unvotes = params.unvotes ? params.unvotes.split(',') : []; + this.props.urlVotesFound({ + activePeer: this.props.activePeer, + upvotes, + unvotes, + address: this.props.account.address, + }); + } + } + + getProccessedCount() { + return this.props.urlVoteCount - this.props.pending.length; + } + + render() { + const errorMessages = { + notFound: this.props.t('{{count}} of entered delegate names could not be resolved:', + { count: this.props.notFound.length }), + alreadyVoted: this.props.t('{{count}} of delegate names selected for vote were already voted for:', + { count: this.props.alreadyVoted.length }), + notVotedYet: this.props.t('{{count}} of delegate names selected for unvote were not voted for:', + { count: this.props.notVotedYet.length }), + }; + const successMessages = { + upvotes: this.props.t('{{count}} delegate names successfully resolved to add vote to.', + { count: this.props.upvotes.length }), + unvotes: this.props.t('{{count}} delegate names successfully resolved to remove vote from.', + { count: this.props.unvotes.length }), + }; + return ( +
+ {this.getProccessedCount() < this.props.urlVoteCount ? + (
+ +
+ {this.props.t('Processing delegate names: ')} + {this.getProccessedCount()} / {this.props.urlVoteCount} +
+
) : + ({Object.keys(errorMessages).map(list => ( + this.props[list].length ? ( +
+ {errorMessages[list]} + {this.props[list].map((username, i) => ( + {username} + ))} +
+ ) : null + ))} + {Object.keys(successMessages).map(list => ( + this.props[list].length ? ( +
{successMessages[list]}
+ ) : null + ))}
)} +
+ ); + } +} diff --git a/src/components/voteUrlProcessor/voteUrlProcessor.test.js b/src/components/voteUrlProcessor/voteUrlProcessor.test.js new file mode 100644 index 000000000..48c28b88b --- /dev/null +++ b/src/components/voteUrlProcessor/voteUrlProcessor.test.js @@ -0,0 +1,101 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import VoteUrlProcessor from './voteUrlProcessor'; + +describe('VoteUrlProcessor', () => { + let wrapper; + let props; + + beforeEach(() => { + const account = { + balance: 1000e8, + address: '16313739661670634666L', + passphrase: 'recipe bomb asset salon coil symbol tiger engine assist pact pumpkin visit', + }; + + props = { + activePeer: {}, + account, + clearVoteLookupStatus: sinon.spy(), + urlVotesFound: sinon.spy(), + notVotedYet: [], + notFound: [], + alreadyVoted: [], + upvotes: [], + unvotes: [], + pending: [], + history: { + location: { + search: '', + }, + }, + urlVoteCount: 0, + t: key => key, + }; + wrapper = mount(); + }); + + it('renders ProgressBar component if props.pending.length > 0', () => { + wrapper.setProps({ + pending: ['delegate_name'], + urlVoteCount: 1, + }); + expect(wrapper.find('ProgressBar')).to.have.length(1); + }); + + it('calls props.urlVotesFound with upvotes if URL contains ?votes=delegate_name', () => { + wrapper = mount(); + expect(props.urlVotesFound).to.have.been.calledWith({ + activePeer: props.activePeer, + upvotes: ['delegate_name'], + unvotes: [], + address: props.account.address, + }); + }); + + + it('calls props.urlVotesFound with unvotes if URL contains ?unvotes=delegate_name', () => { + wrapper = mount(); + expect(props.urlVotesFound).to.have.been.calledWith({ + activePeer: props.activePeer, + upvotes: [], + unvotes: ['delegate_name'], + address: props.account.address, + }); + }); + + it('renders .upvotes-message element with a message if props.upvotes.length > 0', () => { + wrapper.setProps({ + upvotes: ['delegate_name'], + urlVoteCount: 1, + }); + expect(wrapper.find('.upvotes-message')).to.have.length(1); + expect(wrapper.find('.upvotes-message').text()).to.equal('{{count}} delegate names successfully resolved to add vote to.'); + }); + + it('renders .notFound-message element with a message if props.notFound.length > 0', () => { + wrapper.setProps({ + notFound: ['delegate_name'], + urlVoteCount: 1, + }); + expect(wrapper.find('.notFound-message')).to.have.length(1); + expect(wrapper.find('.notFound-message').text()).to.equal('{{count}} of entered delegate names could not be resolved:delegate_name'); + }); +}); + diff --git a/src/components/voting/votingBar.js b/src/components/voting/votingBar.js index ac8897859..350a7688b 100644 --- a/src/components/voting/votingBar.js +++ b/src/components/voting/votingBar.js @@ -24,7 +24,7 @@ const VotingBar = ({ votes, t }) => { {t('Upvotes:')} {voteList.length} - + {t('Downvotes:')} {unvoteList.length} diff --git a/src/components/voting/votingBar.test.js b/src/components/voting/votingBar.test.js index c8ac18b90..7c1c8f8d9 100644 --- a/src/components/voting/votingBar.test.js +++ b/src/components/voting/votingBar.test.js @@ -16,7 +16,7 @@ describe('VotingBar', () => { confirmed: true, unconfirmed: false, }, - downvote: { + unvote: { confirmed: true, unconfirmed: true, }, @@ -57,11 +57,11 @@ describe('VotingBar', () => { 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 unvotes', () => { + expect(wrapper.find('.unvotes')).to.have.text('Downvotes: 1'); }); - it('should render number of downvotes', () => { + it('should render number of unvotes', () => { expect(wrapper.find('.total-new-votes')).to.have.text('Total new votes: 3 / 33'); }); @@ -69,7 +69,7 @@ describe('VotingBar', () => { expect(wrapper.find('.total-votes')).to.have.text('Total votes: 3 / 101'); }); - it('should not render if no upvotes or downvotes', () => { + it('should not render if no upvotes or unvotes', () => { wrapper.setProps({ votes: {} }); expect(wrapper.html()).to.equal(null); }); diff --git a/src/constants/actions.js b/src/constants/actions.js index 7750db36f..a7446e622 100644 --- a/src/constants/actions.js +++ b/src/constants/actions.js @@ -15,6 +15,8 @@ const actionTypes = { voteToggled: 'VOTE_TOGGLED', votesAdded: 'VOTES_ADDED', votesUpdated: 'VOTES_UPDATED', + voteLookupStatusCleared: 'VOTE_LOOKUP_STATUS_CLEARED', + voteLookupStatusUpdated: 'VOTE_LOOKUP_STATUS_UPDATED', delegatesAdded: 'DELEGATES_ADDED', pendingVotesAdded: 'PENDING_VOTES_ADDED', toastDisplayed: 'TOAST_DISPLAYED', diff --git a/src/i18n-scanner.js b/src/i18n-scanner.js index 8fbbc08ed..702fb5a06 100644 --- a/src/i18n-scanner.js +++ b/src/i18n-scanner.js @@ -5,6 +5,8 @@ const Parser = require('i18next-scanner').Parser; const translationFunctionNames = ['i18next.t', 'props.t', 'this.props.t', 't']; const outputFilePath = './src/locales/en/common.json'; +const translationsSource = JSON.parse(fs.readFileSync(outputFilePath, 'utf8')); + const parser = new Parser({ keySeparator: '>', nsSeparator: '|', @@ -12,13 +14,14 @@ const parser = new Parser({ const customHandler = function (key, options) { - const value = key; + const value = translationsSource[key] || key; if (options.context) { key += `_${options.context}`; } parser.set(key, value); if (options.count !== undefined) { - parser.set(`${key}_plural`); + key = `${key}_plural`; + parser.set(key, translationsSource[key] || ''); } }; diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 0d7c75145..db69d473d 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -80,6 +80,7 @@ "Please click Next, then move around your mouse randomly to generate a random passphrase.": "Please click Next, then move around your mouse randomly to generate a random passphrase.", "Please keep it safe!": "Please keep it safe!", "Press #{key} to copy": "Press #{key} to copy", + "Processing delegate names: ": "Processing delegate names: ", "Proxy Authentication": "Proxy Authentication", "Public Key": "Public Key", "Rank": "Rank", @@ -145,9 +146,9 @@ "Word \"{{invalidWord}}\" is not on the passphrase Word List. Most similar word on the list is \"{{similarWord}}\"": "Word \"{{invalidWord}}\" is not on the passphrase Word List. Most similar word on the list is \"{{similarWord}}\"", "Yes! It's safe": "Yes! It's safe", "You can select up to {{count}} delegates in one voting turn.": "You can select up to {{count}} delegates in one voting turn.", - "You can select up to {{count}} delegates in one voting turn._plural": "", + "You can select up to {{count}} delegates in one voting turn._plural": "You can select up to {{count}} delegates in one voting turn.", "You can vote for up to {{count}} delegates in total.": "You can vote for up to {{count}} delegates in total.", - "You can vote for up to {{count}} delegates in total._plural": "", + "You can vote for up to {{count}} delegates in total._plural": "You can vote for up to {{count}} delegates in total.", "You have not forged any blocks yet": "You have not forged any blocks yet", "You need to become a delegate to start forging. If you already registered to become a delegate, your registration hasn't been processed, yet.": "You need to become a delegate to start forging. If you already registered to become a delegate, your registration hasn't been processed, yet.", "You've received {{value}} LSK.": "You've received {{value}} LSK.", @@ -160,5 +161,15 @@ "my votes": "my votes", "send": "send", "your passphrase will be required for logging in to your account.": "your passphrase will be required for logging in to your account.", - "your second passphrase will be required for all transactions sent from this account": "your second passphrase will be required for all transactions sent from this account" + "your second passphrase will be required for all transactions sent from this account": "your second passphrase will be required for all transactions sent from this account", + "{{count}} delegate names successfully resolved to add vote to.": "{{count}} delegate name successfully resolved to add vote to.", + "{{count}} delegate names successfully resolved to add vote to._plural": "{{count}} delegate names successfully resolved to add vote to.", + "{{count}} delegate names successfully resolved to remove vote from.": "{{count}} delegate name successfully resolved to remove vote from.", + "{{count}} delegate names successfully resolved to remove vote from._plural": "{{count}} delegate names successfully resolved to remove vote from.", + "{{count}} of delegate names selected for unvote were not voted for:": "{{count}} of delegate names selected for unvote were not voted for:", + "{{count}} of delegate names selected for unvote were not voted for:_plural": "{{count}} of delegate names selected for unvote were not voted for:", + "{{count}} of delegate names selected for vote were already voted for:": "{{count}} of delegate names selected for vote were already voted for:", + "{{count}} of delegate names selected for vote were already voted for:_plural": "{{count}} of delegate names selected for vote were already voted for:", + "{{count}} of entered delegate names could not be resolved:": "{{count}} of entered delegate names could not be resolved:", + "{{count}} of entered delegate names could not be resolved:_plural": "{{count}} of entered delegate names could not be resolved:" } diff --git a/src/store/middlewares/account.js b/src/store/middlewares/account.js index 95d41a2ce..e6329c672 100644 --- a/src/store/middlewares/account.js +++ b/src/store/middlewares/account.js @@ -65,7 +65,7 @@ const delegateRegistration = (store, action) => { const state = store.getState(); if (delegateRegistrationTx) { - getDelegate(state.peers.data, state.account.publicKey) + getDelegate(state.peers.data, { publicKey: state.account.publicKey }) .then((delegateData) => { store.dispatch(accountLoggedIn(Object.assign({}, { delegate: delegateData.delegate, isDelegate: true }))); diff --git a/src/store/middlewares/login.js b/src/store/middlewares/login.js index d8033ee79..c4716a8b4 100644 --- a/src/store/middlewares/login.js +++ b/src/store/middlewares/login.js @@ -24,7 +24,7 @@ const loginMiddleware = store => next => (action) => { // redirect to main/transactions return getAccount(activePeer, address).then(accountData => - getDelegate(activePeer, publicKey) + getDelegate(activePeer, { publicKey }) .then((delegateData) => { store.dispatch(accountLoggedIn(Object.assign({}, accountData, accountBasics, { delegate: delegateData.delegate, isDelegate: true }))); diff --git a/src/store/middlewares/voting.js b/src/store/middlewares/voting.js index a9de5e7c3..807d58d38 100644 --- a/src/store/middlewares/voting.js +++ b/src/store/middlewares/voting.js @@ -1,31 +1,93 @@ import i18next from 'i18next'; + import { errorToastDisplayed } from '../../actions/toaster'; +import { getDelegate } from '../../utils/api/delegate'; +import { voteLookupStatusUpdated, voteToggled } from '../../actions/voting'; import actionTypes from '../../constants/actions'; import votingConst from '../../constants/voting'; -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 updateLookupStatus = (store, list, username) => { + store.dispatch(voteLookupStatusUpdated({ + username, status: list, + })); +}; - 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 = i18next.t('Maximum of {{n}} votes in one transaction exceeded.', { n: votingConst.maxCountOfVotesInOneTurn }); - const newAction = errorToastDisplayed({ label }); - store.dispatch(newAction); +const lookupDelegate = (store, username) => { + const state = store.getState(); + const activePeer = state.peers.data; + const delegate = state.voting.delegates.filter(d => d.username === username)[0]; + if (delegate) { + return new Promise((resolve) => { + resolve({ delegate }); + }); + } + return getDelegate(activePeer, { username }); +}; + +const processVote = (store, options, username) => { + updateLookupStatus(store, 'pending', username); + lookupDelegate(store, username).then((data) => { + const vote = store.getState().voting.votes[username]; + if (options.isValid(vote)) { + store.dispatch(voteToggled({ username, publicKey: data.delegate.publicKey })); + updateLookupStatus(store, options.successState, username); + } else { + updateLookupStatus(store, options.invalidState, username); } + }).catch(() => { + updateLookupStatus(store, 'notFound', username); + }); +}; + +const lookupDelegatesFromUrl = (store, action) => { + const { upvotes, unvotes } = action.data; + if (upvotes && unvotes) { + unvotes.forEach(processVote.bind(this, store, { + successState: 'unvotes', + invalidState: 'notVotedYet', + isValid: vote => (vote && vote.confirmed && vote.unconfirmed), + })); + upvotes.forEach(processVote.bind(this, store, { + successState: 'upvotes', + invalidState: 'alreadyVoted', + isValid: vote => (!vote || (!vote.confirmed && !vote.unconfirmed)), + })); + } +}; + +const checkVoteLimits = (store, action) => { + const { votes } = store.getState().voting; + const currentVote = votes[action.data.username] || { unconfirmed: true, confirmed: false }; - const voteCount = Object.keys(votes).filter( - key => (votes[key].confirmed && !votes[key].unconfirmed) || votes[key].unconfirmed).length; - if (voteCount === votingConst.maxCountOfVotes + 1 && + 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 = i18next.t('Maximum of {{n}} votes exceeded.', { n: votingConst.maxCountOfVotes }); - const newAction = errorToastDisplayed({ label }); - store.dispatch(newAction); - } + const label = i18next.t('Maximum of {{n}} votes in one transaction exceeded.', { n: votingConst.maxCountOfVotesInOneTurn }); + 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 = i18next.t('Maximum of {{n}} votes exceeded.', { n: votingConst.maxCountOfVotes }); + const newAction = errorToastDisplayed({ label }); + store.dispatch(newAction); + } +}; + +const votingMiddleware = store => next => (action) => { + next(action); + switch (action.type) { + case actionTypes.voteToggled: + checkVoteLimits(store, action); + break; + case actionTypes.votesAdded: + lookupDelegatesFromUrl(store, action); + break; + default: break; } }; diff --git a/src/store/middlewares/voting.test.js b/src/store/middlewares/voting.test.js index f0bfbeb5e..d8913a2c2 100644 --- a/src/store/middlewares/voting.test.js +++ b/src/store/middlewares/voting.test.js @@ -1,8 +1,12 @@ import { expect } from 'chai'; -import { spy, stub } from 'sinon'; +import { spy, stub, mock } from 'sinon'; + + import { errorToastDisplayed } from '../../actions/toaster'; -import middleware from './voting'; +import { voteLookupStatusUpdated } from '../../actions/voting'; +import * as delegateApi from '../../utils/api/delegate'; import actionTypes from '../../constants/actions'; +import middleware from './voting'; import votingConst from '../../constants/voting'; describe('voting middleware', () => { @@ -51,53 +55,201 @@ describe('voting middleware', () => { 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 })); - }); + describe('on voteToggled action', () => { + 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 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 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 })); + }); }); - 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', + describe('on votesAdded action', () => { + const state = { + voting: { + delegates: [ + { + username: 'delegate_in_store', + publicKey: 'some publicKey', + }, + ], + votes: { + delegate_voted: { + unconfirmed: true, + confirmed: true, + }, + delegate_unvoted: { + unconfirmed: false, + confirmed: false, + }, + }, + }, + peers: { + data: {}, }, }; - middleware(store)(next)(givenAction); - expect(store.dispatch).to.not.have.been.calledWith(errorToastDisplayed({ label: label2 })); + let getDelegateMock; + + beforeEach(() => { + getDelegateMock = mock(delegateApi).expects('getDelegate').returnsPromise(); + store.getState = () => (state); + }); + + afterEach(() => { + getDelegateMock.restore(); + }); + + it('should do nothing if !action.upvotes or !action.dowvotes ', () => { + const givenAction = { + type: actionTypes.votesAdded, + data: { }, + }; + middleware(store)(next)(givenAction); + expect(store.dispatch).to.not.have.been.calledWith(); + }); + + it('should dispatch voteLookupStatusUpdated with username from action.data.upvotes and status \'upvotes\'', () => { + const username = 'delegate_unvoted'; + const status = 'upvotes'; + const givenAction = { + type: actionTypes.votesAdded, + data: { + upvotes: [username], + unvotes: [], + }, + }; + + middleware(store)(next)(givenAction); + getDelegateMock.resolves({ delegate: { username, publicKey: 'whatever' } }); + + expect(store.dispatch).to.have.been.calledWith(voteLookupStatusUpdated({ username, status })); + }); + + it('should dispatch voteLookupStatusUpdated with username from action.data.upvotes and status \'alreadyVoted\'', () => { + const username = 'delegate_voted'; + const status = 'alreadyVoted'; + const givenAction = { + type: actionTypes.votesAdded, + data: { + upvotes: [username], + unvotes: [], + }, + }; + + middleware(store)(next)(givenAction); + getDelegateMock.resolves({ delegate: { username, publicKey: 'whatever' } }); + + expect(store.dispatch).to.have.been.calledWith(voteLookupStatusUpdated({ username, status })); + }); + + it('should dispatch voteLookupStatusUpdated with username from action.data.unvotes and status \'unvotes\'', () => { + const username = 'delegate_voted'; + const status = 'unvotes'; + const givenAction = { + type: actionTypes.votesAdded, + data: { + upvotes: [], + unvotes: [username], + }, + }; + + middleware(store)(next)(givenAction); + getDelegateMock.resolves({ delegate: { username, publicKey: 'whatever' } }); + + expect(store.dispatch).to.have.been.calledWith(voteLookupStatusUpdated({ username, status })); + }); + + it('should dispatch voteLookupStatusUpdated with username from action.data.unvotes and status \'notVotedYet\'', () => { + const username = 'delegate_unvoted'; + const status = 'notVotedYet'; + const givenAction = { + type: actionTypes.votesAdded, + data: { + upvotes: [], + unvotes: [username], + }, + }; + + middleware(store)(next)(givenAction); + getDelegateMock.resolves({ delegate: { username, publicKey: 'whatever' } }); + + expect(store.dispatch).to.have.been.calledWith(voteLookupStatusUpdated({ username, status })); + }); + + it('should dispatch voteLookupStatusUpdated with username from action.data.unvotes and status \'notFound\' if delegate not found', () => { + const username = 'delegate_invalid'; + const status = 'notFound'; + const givenAction = { + type: actionTypes.votesAdded, + data: { + upvotes: [], + unvotes: [username], + }, + }; + + middleware(store)(next)(givenAction); + getDelegateMock.rejects(); + + expect(store.dispatch).to.have.been.calledWith(voteLookupStatusUpdated({ username, status })); + }); + + it('should not call getDelegate API if given delegate is in store', () => { + const username = 'delegate_in_store'; + const givenAction = { + type: actionTypes.votesAdded, + data: { + upvotes: [username], + unvotes: [], + }, + }; + + middleware(store)(next)(givenAction); + getDelegateMock.rejects(); + + expect(getDelegateMock).to.not.have.been.calledWith(); + }); }); }); diff --git a/src/store/reducers/voting.js b/src/store/reducers/voting.js index 01926943e..e7395681b 100644 --- a/src/store/reducers/voting.js +++ b/src/store/reducers/voting.js @@ -37,7 +37,12 @@ const mergeVotes = (newList, oldDict) => { * @param {Object} state * @param {Object} action */ -const voting = (state = { votes: {}, delegates: [], totalDelegates: 0 }, action) => { +const voting = (state = { + votes: {}, + delegates: [], + totalDelegates: 0, + voteLookupStatus: {}, +}, action) => { switch (action.type) { case actionTypes.votesAdded: return Object.assign({}, state, { @@ -120,6 +125,21 @@ const voting = (state = { votes: {}, delegates: [], totalDelegates: 0 }, action) }, {}), }); + case actionTypes.voteLookupStatusUpdated: + return { + ...state, + voteLookupStatus: { + ...state.voteLookupStatus, + [action.data.username]: action.data.status, + }, + }; + + case actionTypes.voteLookupStatusCleared: + return { + ...state, + voteLookupStatus: { }, + }; + default: return state; } diff --git a/src/store/reducers/voting.test.js b/src/store/reducers/voting.test.js index 129f31c24..f7ba5203b 100644 --- a/src/store/reducers/voting.test.js +++ b/src/store/reducers/voting.test.js @@ -181,4 +181,49 @@ describe('Reducer: voting(state, action)', () => { expect(changedState).to.be.deep.equal(expectedState); }); + + it('should set voteLookupStatus of given username to given status, with action: voteLookupStatusUpdated', () => { + const action = { + type: actionTypes.voteLookupStatusUpdated, + data: { + username: 'username1', + status: 'upvoted', + }, + }; + const state = { + voteLookupStatus: { + [action.data.username]: 'pending', + username2: 'pending', + }, + }; + + const expectedState = { + voteLookupStatus: { + [action.data.username]: action.data.status, + username2: 'pending', + }, + }; + const changedState = voting(state, action); + + expect(changedState).to.be.deep.equal(expectedState); + }); + + it('should set voteLookupStatus to {}, with action: voteLookupStatusCleared', () => { + const action = { + type: actionTypes.voteLookupStatusCleared, + }; + const state = { + voteLookupStatus: { + username1: 'upvoted', + username2: 'unvoted', + }, + }; + + const expectedState = { + voteLookupStatus: {}, + }; + const changedState = voting(state, action); + + expect(changedState).to.be.deep.equal(expectedState); + }); }); diff --git a/src/utils/api/delegate.js b/src/utils/api/delegate.js index 1f8532a4a..56e6c5e80 100644 --- a/src/utils/api/delegate.js +++ b/src/utils/api/delegate.js @@ -7,8 +7,8 @@ export const listAccountDelegates = (activePeer, address) => export const listDelegates = (activePeer, options) => requestToActivePeer(activePeer, `delegates/${options.q ? 'search' : ''}`, options); -export const getDelegate = (activePeer, publicKey) => - requestToActivePeer(activePeer, 'delegates/get', { publicKey }); +export const getDelegate = (activePeer, options) => + requestToActivePeer(activePeer, 'delegates/get', options); export const vote = (activePeer, secret, publicKey, voteList, unvoteList, secondSecret = null) => requestToActivePeer(activePeer, 'accounts/delegates', { diff --git a/src/utils/api/delegate.test.js b/src/utils/api/delegate.test.js index f4ef70bcb..2e86123c6 100644 --- a/src/utils/api/delegate.test.js +++ b/src/utils/api/delegate.test.js @@ -63,7 +63,7 @@ describe('Utils: Delegate', () => { const mockedPromise = new Promise((resolve) => { resolve(); }); peersMock.expects('requestToActivePeer').withArgs(activePeer, 'delegates/get', options).returns(mockedPromise); - const returnedPromise = getDelegate(activePeer, options.publicKey); + const returnedPromise = getDelegate(activePeer, options); expect(returnedPromise).to.equal(mockedPromise); }); }); diff --git a/src/utils/searchParams.js b/src/utils/searchParams.js new file mode 100644 index 000000000..c2d0d7b82 --- /dev/null +++ b/src/utils/searchParams.js @@ -0,0 +1,11 @@ + +// eslint-disable-next-line import/prefer-default-export +export const parseSearchParams = (search) => { + const searchParams = new URLSearchParams(search); + const parsedParams = {}; + // eslint-disable-next-line no-restricted-syntax + for (const [key, value] of searchParams.entries()) { + parsedParams[key] = value; + } + return parsedParams; +};