diff --git a/README.md b/README.md index 971ec6df8..db2420844 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ npm run dist:win Build package for Mac OS X. ``` -npm run dist:osx +npm run dist:mac ``` ### Linux diff --git a/app/package.json b/app/package.json index 252424c68..71cc3264c 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "lisk-nano", - "version": "0.1.2", + "version": "0.2.0", "description": "Lisk Nano", "main": "main.js", "author":{ diff --git a/package.json b/package.json index c55b9f912..dbca0950d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lisk-nano", - "version": "0.1.2", + "version": "0.2.0", "description": "Lisk Nano", "homepage": "https://github.com/LiskHQ/lisk-nano", "bugs": "https://github.com/LiskHQ/lisk-nano/issues", @@ -8,7 +8,7 @@ "scripts": { "start": "electron app", "dist:win": "build --win", - "dist:osx": "build --osx", + "dist:mac": "build --mac", "dist:linux": "build --linux" }, "author": "Lisk Foundation , lightcurve GmbH ", @@ -18,16 +18,15 @@ "url": "https://github.com/LiskHQ/lisk-nano" }, "devDependencies": { - "electron": "=1.4.4", - "electron-builder": "=7.14.2" + "electron": "=1.6.2", + "electron-builder": "=16.8.3" }, "build": { "appId": "io.lisk.nano", - "category": "finance", "productName": "Lisk Nano", "win": { "target": "nsis" } }, - "license": "MIT" + "license": "GPL-3.0" } diff --git a/src/app/app.js b/src/app/app.js index 957688db9..1eda7f21d 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -3,5 +3,6 @@ export default angular.module('app', [ 'ngMaterial', 'ngAnimate', 'ngCookies', + 'infinite-scroll', 'md.data.table', ]); diff --git a/src/app/components/delegates/delegates.js b/src/app/components/delegates/delegates.js new file mode 100644 index 000000000..419d5b4d9 --- /dev/null +++ b/src/app/components/delegates/delegates.js @@ -0,0 +1,276 @@ +import './delegates.less'; + +const UPDATE_INTERVAL = 10000; + +app.component('delegates', { + template: require('./delegates.pug')(), + bindings: { + account: '=', + passphrase: '<', + }, + controller: class delegates { + constructor($scope, $peers, $mdDialog, $mdMedia, $mdToast, $timeout) { + this.$scope = $scope; + this.$peers = $peers; + this.$mdDialog = $mdDialog; + this.$mdMedia = $mdMedia; + this.$mdToast = $mdToast; + this.$timeout = $timeout; + + this.$scope.search = ''; + this.voteList = []; + this.votedDict = {}; + this.votedList = []; + this.unvoteList = []; + this.loading = true; + this.usernameInput = ''; + this.usernameSeparator = '\n'; + + this.updateAll(); + + this.$scope.$watch('search', (search, oldValue) => { + this.delegatesDisplayedCount = 20; + if (search || oldValue) { + this.loadDelegates(0, search, true); + } + }); + + this.$scope.$on('peerUpdate', () => { + this.updateAll(); + }); + } + + updateAll() { + this.delegates = []; + this.delegatesDisplayedCount = 20; + this.$peers.listAccountDelegates({ + address: this.account.address, + }).then((data) => { + this.votedList = data.delegates || []; + (this.votedList).forEach((delegate) => { + this.votedDict[delegate.username] = delegate; + }); + this.loadDelegates(0, this.$scope.search); + }); + } + + loadDelegates(offset, search, replace) { + this.loading = true; + this.$peers.listDelegates({ + offset, + limit: '100', + q: search, + }).then((data) => { + this.addDelegates(data, replace); + }); + this.lastSearch = search; + } + + addDelegates(data, replace) { + if (data.success) { + if (replace) { + this.delegates = []; + } + this.delegates = this.delegates.concat(data.delegates.map((delegate) => { + const voted = this.votedDict[delegate.username] !== undefined; + const changed = this.voteList.concat(this.unvoteList) + .map(d => d.username).indexOf(delegate.username) !== -1; + delegate.status = { // eslint-disable-line no-param-reassign + selected: (voted && !changed) || (!voted && changed), + voted, + changed, + }; + return delegate; + })); + this.delegatesTotalCount = data.totalCount; + this.loading = false; + } + } + + showMore() { + if (this.delegatesDisplayedCount < this.delegates.length) { + this.delegatesDisplayedCount += 20; + } + if (this.delegates.length - this.delegatesDisplayedCount <= 20 && + this.delegates.length < this.delegatesTotalCount && + !this.loading) { + this.loadDelegates(this.delegates.length, this.$scope.search); + } + } + + selectionChange(delegate) { + // eslint-disable-next-line no-param-reassign + delegate.status.changed = delegate.status.voted !== delegate.status.selected; + const list = delegate.status.voted ? this.unvoteList : this.voteList; + if (delegate.status.changed) { + list.push(delegate); + } else { + list.splice(list.indexOf(delegate), 1); + } + } + + clearSearch() { + this.$scope.search = ''; + } + + addToUnvoteList(vote) { + const delegate = this.delegates.filter(d => d.username === vote.username)[0] || vote; + if (delegate.status.selected) { + this.unvoteList.push(delegate); + } + delegate.status.selected = false; + } + + setPendingVotes() { + this.voteList.forEach((delegate) => { + /* eslint-disable no-param-reassign */ + delegate.status.changed = false; + delegate.status.voted = true; + delegate.status.pending = true; + }); + this.votePendingList = this.voteList.splice(0, this.voteList.length); + + this.unvoteList.forEach((delegate) => { + delegate.status.changed = false; + delegate.status.voted = false; + delegate.status.pending = true; + /* eslint-enable no-param-reassign */ + }); + this.unvotePendingList = this.unvoteList.splice(0, this.unvoteList.length); + this.checkPendingVotes(); + } + + checkPendingVotes() { + this.$timeout(() => { + this.$peers.listAccountDelegates({ + address: this.account.address, + }).then((data) => { + this.votedList = data.delegates || []; + this.votedDict = {}; + (this.votedList).forEach((delegate) => { + this.votedDict[delegate.username] = delegate; + }); + this.votePendingList = this.votePendingList.filter((vote) => { + if (this.votedDict[vote.username]) { + // eslint-disable-next-line no-param-reassign + vote.status.pending = false; + return false; + } + return true; + }); + this.unvotePendingList = this.unvotePendingList.filter((vote) => { + if (!this.votedDict[vote.username]) { + // eslint-disable-next-line no-param-reassign + vote.status.pending = false; + return false; + } + return true; + }); + if (this.votePendingList.length + this.unvotePendingList.length > 0) { + this.checkPendingVotes(); + } + }); + }, UPDATE_INTERVAL); + } + + parseVoteListFromInput() { + this._parseListFromInput('voteList'); + } + + parseUnvoteListFromInput() { + this._parseListFromInput('unvoteList'); + } + + _parseListFromInput(listName) { + const list = this[listName]; + this.invalidUsernames = []; + this.pendingRequests = 0; + this.usernameList = this.usernameInput.trim().split(this.usernameSeparator); + this.usernameList.forEach((username) => { + if ((listName === 'voteList' && !this.votedDict[username.trim()]) || + (listName === 'unvoteList' && this.votedDict[username.trim()])) { + this._setSelected(username.trim(), list); + } + }); + + if (this.pendingRequests === 0) { + this._selectFinish(true, list); + } + } + + _selectFinish(success, list) { + if (list.length !== 0) { + this.usernameListActive = false; + this.usernameInput = ''; + this.openVoteDialog(); + } else { + const toast = this.$mdToast.simple(); + toast.toastClass('lsk-toast-error'); + toast.textContent('No delegate usernames could be parsed from the input'); + this.$mdToast.show(toast); + } + } + + _setSelected(username, list) { + const delegate = this.delegates.filter(d => d.username === username)[0]; + if (delegate) { + this._selectDelegate(delegate, list); + } else { + this.pendingRequests++; + this.$peers.getDelegate({ username, + }).then((data) => { + this._selectDelegate(data.delegate, list); + }).catch(() => { + this.invalidUsernames.push(username); + }).finally(() => { + this.pendingRequests--; + if (this.pendingRequests === 0) { + this._selectFinish(this.invalidUsernames.length === 0, list); + } + }); + } + } + + // eslint-disable-next-line class-methods-use-this + _selectDelegate(delegate, list) { + // eslint-disable-next-line no-param-reassign + delegate.status = delegate.status || {}; + // eslint-disable-next-line no-param-reassign + delegate.status.selected = true; + if (list.indexOf(delegate) === -1) { + list.push(delegate); + } + } + + openVoteDialog() { + this.$mdDialog.show({ + controllerAs: '$ctrl', + controller: class voteDialog { + constructor($scope, account, passphrase, voteList, unvoteList) { + this.$scope = $scope; + this.$scope.account = account; + this.$scope.passphrase = passphrase; + this.$scope.voteList = voteList; + this.$scope.unvoteList = unvoteList; + } + }, + template: + '' + + '' + + '' + + '', + fullscreen: (this.$mdMedia('sm') || this.$mdMedia('xs')) && this.$scope.customFullscreen, + locals: { + account: this.account, + passphrase: this.passphrase, + voteList: this.voteList, + unvoteList: this.unvoteList, + }, + }).then((() => { + this.setPendingVotes(); + })); + } + }, +}); + diff --git a/src/app/components/delegates/delegates.less b/src/app/components/delegates/delegates.less new file mode 100644 index 000000000..6714e9901 --- /dev/null +++ b/src/app/components/delegates/delegates.less @@ -0,0 +1,86 @@ +delegates { + .pull-right { + float: right; + } + + .right-action-buttons { + margin: -8px 0; + } + + button { + margin: -10px; + } + + i { + vertical-align: inherit; + margin-left: 8px; + margin-right: 4px; + } + + .green-link { + color: #7cb342; + } + .red-link { + color: #c62828; + } + .remove-votes-link { + margin-right: -2px; + } + + .upvote { + background-color: rgb(226, 238, 213); + } + .downvote { + background-color: rgb(255, 228, 220); + } + .pending { + background-color: #eaeae9; + } + + md-card-title { + md-input-container { + margin: 0; + padding: 0; + } + .md-errors-spacer { + min-height: 0; + } + } + + .search-append { + color: #aaa; + cursor: pointer; + margin-left: -30px; + z-index: 10; + } + + .label { + font-weight: bold; + background-color: #5f696e; + color: #fff; + border-radius: 3px; + padding: 4px 9px; + white-space: nowrap; + } + + .status { + line-height: 2em; + } + + .filter-select md-select{ + display: inline-block; + margin: 0 10px + } +} + +.lsk-vote-remove-button { + float: right; + position: absolute; + right: 4px; + top: 4px; + + .material-icons { + vertical-align: inherit; + } +} + diff --git a/src/app/components/delegates/delegates.pug b/src/app/components/delegates/delegates.pug new file mode 100644 index 000000000..2e3ceaa39 --- /dev/null +++ b/src/app/components/delegates/delegates.pug @@ -0,0 +1,74 @@ +div.offline-hide + md-card(flex-gt-xs=100, ng-if='$ctrl.usernameListActive') + md-card-content(layout='column') + md-input-container + label Insert list of delegate usernames separated by new line + textarea(ng-model='$ctrl.usernameInput') + div + md-button(ng-click='$ctrl.usernameListActive = false') + span Cancel + span.pull-right + md-button(ng-click='$ctrl.parseVoteListFromInput("voteList")', ng-disabled='$ctrl.pendingRequests || !$ctrl.usernameInput') + span Add to vote list + md-button(ng-click='$ctrl.parseUnvoteListFromInput(")', ng-disabled='$ctrl.pendingRequests || !$ctrl.usernameInput') + span Add to unvote list + md-progress-linear(md-mode='indeterminate', ng-show='$ctrl.pendingRequests') + md-card(flex-gt-xs=100) + md-card-title + md-card-title-text + span.md-title(layout='row') + md-input-container.md-block + label Search + input(type='text', name='name', ng-model='search', ng-model-options='{ debounce: 200 }') + i.material-icons.search-append(ng-click='$ctrl.clearSearch()', ng-if='search') close + i.material-icons.search-append(ng-hide='search') search + span.pull-right.right-action-buttons + md-button(ng-click='$ctrl.usernameListActive = true') + i.material-icons list + span Input Names + md-menu.pull-right.right-action-buttons + md-button.pull-right(ng-click='$mdOpenMenu()') + i.material-icons visibility + span My votes ({{$ctrl.votedList.length}}) + md-menu-content(width='4') + md-menu-item.vote-list-item(ng-repeat='(username, delegate) in $ctrl.votedDict') + md-button(ng-click='$ctrl.addToUnvoteList(delegate)') + div + span(ng-bind='username') + md-button.md-icon-button.lsk-vote-remove-button(ng-click='$ctrl.unselect(username)') + i.material-icons close + span.pull-right.right-action-buttons + md-button(ng-click='$ctrl.openVoteDialog()') + i.material-icons done + span Vote + span(ng-if='$ctrl.voteList.length || $ctrl.unvoteList.length') + span ( + span.green-link(ng-if='$ctrl.voteList.length') +{{$ctrl.voteList.length}} + span(ng-if='$ctrl.voteList.length && $ctrl.unvoteList.length') / + span.red-link(ng-if='$ctrl.unvoteList.length') -{{$ctrl.unvoteList.length}} + span ) + md-content(layout='column') + md-table-container + table(md-table) + thead(md-head) + tr(md-row) + th(md-column) Vote + th(md-column) Rank + th(md-column) Name + th(md-column) Lisk Address + th(md-column) Uptime + th(md-column) Approval + tbody(md-body, infinite-scroll='$ctrl.showMore()', infinite-scroll-distance='1') + tr(md-row, ng-hide='$ctrl.filteredDelegates.length || $ctrl.loading') + td(md-cell, colspan='6') No delegates found + tr(md-row, ng-repeat="delegate in ($ctrl.filteredDelegates = ($ctrl.delegates | filter : {username: search} )) | limitTo : $ctrl.delegatesDisplayedCount", ng-class='{"downvote": delegate.status.voted && !delegate.status.selected, "upvote": !delegate.status.voted && delegate.status.selected, "pending": delegate.status.pending}') + td(md-cell) + md-checkbox(ng-model='delegate.status.selected', ng-change='$ctrl.selectionChange(delegate)', ng-disabled='delegate.status.pending', aria-label='delegate selected for voting') + td(md-cell, ng-bind='delegate.rank') + td(md-cell, ng-bind='delegate.username') + td(md-cell, ng-bind='delegate.address') + td(md-cell, ng-bind='delegate.productivity') + td(md-cell, ng-bind='delegate.approval') + md-button.more(ng-show='$ctrl.delegatesDisplayedCount < $ctrl.filteredDelegates.length', ng-click='$ctrl.showMore()') Show More + .loading + md-progress-linear(md-mode='indeterminate', ng-show='$ctrl.loading') diff --git a/src/app/components/delegates/vote.js b/src/app/components/delegates/vote.js new file mode 100644 index 000000000..a65905a49 --- /dev/null +++ b/src/app/components/delegates/vote.js @@ -0,0 +1,59 @@ +import './vote.less'; + +app.component('vote', { + template: require('./vote.pug')(), + bindings: { + account: '=', + passphrase: '<', + voteList: '=', + unvoteList: '=', + }, + controller: class vote { + constructor($scope, $mdDialog, $mdToast, $peers) { + this.$mdDialog = $mdDialog; + this.$mdToast = $mdToast; + this.$peers = $peers; + } + + vote() { + this.votingInProgress = true; + this.$peers.sendRequestPromise('accounts/delegates', { + secret: this.passphrase, + publicKey: this.account.publicKey, + secondSecret: this.secondPassphrase, + delegates: this.voteList.map(delegate => `+${delegate.publicKey}`).concat( + this.unvoteList.map(delegate => `-${delegate.publicKey}`)), + }).then(() => { + this.$mdDialog.hide(this.voteList, this.unvoteList); + const toast = this.$mdToast.simple(); + toast.toastClass('lsk-toast-success'); + toast.textContent('Voting succesfull'); + this.$mdToast.show(toast); + }).catch((response) => { + const toast = this.$mdToast.simple(); + toast.toastClass('lsk-toast-error'); + toast.textContent(response.message || 'Voting failed'); + this.$mdToast.show(toast); + }).finally(() => { + this.votingInProgress = false; + }); + } + + canVote() { + const totalVotes = this.voteList.length + this.unvoteList.length; + return totalVotes > 0 && totalVotes <= 33 && + !this.votingInProgress && + (!this.account.secondSignature || this.secondPassphrase); + } + + // eslint-disable-next-line class-methods-use-this + removeVote(list, index) { + /* eslint-disable no-param-reassign */ + list[index].status.selected = list[index].status.voted; + list[index].status.changed = false; + /* eslint-enable no-param-reassign */ + list.splice(index, 1); + } + }, +}); + diff --git a/src/app/components/delegates/vote.less b/src/app/components/delegates/vote.less new file mode 100644 index 000000000..d5f82c211 --- /dev/null +++ b/src/app/components/delegates/vote.less @@ -0,0 +1,48 @@ +.dialog-vote { + .info-icon-wrapper { + margin: 24px 24px 0 0; + } + + h4 { + margin-bottom: 0; + } + + md-divider { + margin: 0 -24px; + clear: both; + } + + md-dialog-actions .md-button { + margin-left: -8px; + } + + .pull-right { + float: right; + } + + .remove-button { + float: right; + position: absolute; + right: 4px; + top: 4px; + + .material-icons { + vertical-align: inherit; + } + } + + .vote-list-item { + margin: 0 -24px; + } + .vote-list-item .md-list-item-inner { + padding: 0 8px; + } + + .vote-list-item:hover { + cursor: pointer; + + .remove-button { + color: #c62828; + } + } +} diff --git a/src/app/components/delegates/vote.pug b/src/app/components/delegates/vote.pug new file mode 100644 index 000000000..0b1a4a35e --- /dev/null +++ b/src/app/components/delegates/vote.pug @@ -0,0 +1,42 @@ +div.dialog-vote(aria-label='Vote for delegates') + form + md-toolbar + .md-toolbar-tools + h2 Vote for delegates + md-dialog-content + .md-dialog-content + div(ng-if='$ctrl.voteList.length') + h4 Add vote to: + md-list + md-list-item.vote-list-item(ng-repeat='delegate in $ctrl.voteList', ng-click='$ctrl.removeVote($ctrl.voteList, $index)') + div + span {{delegate.username}} + md-button.md-icon-button.remove-button(ng-click='$ctrl.removeVote($ctrl.voteList, $index)') + i.material-icons close + md-divider(ng-if='$ctrl.voteList.length && $ctrl.unvoteList.length') + div(ng-if='$ctrl.unvoteList.length') + h4 Remove vote from: + md-list + md-list-item.vote-list-item(ng-repeat='delegate in $ctrl.unvoteList', ng-click='$ctrl.removeVote($ctrl.unvoteList, $index)') + div + span {{delegate.username}} + md-button.md-icon-button.remove-button(ng-click='$ctrl.removeVote($ctrl.unvoteList, $index)') + i.material-icons close + p.pull-right Fee: 1 LSK + md-divider + div(layout='row') + p.info-icon-wrapper + i.material-icons info + p + span You can select up to 33 delegates in one voting turn. + br + span You can vote for up to 101 delegates in total. + md-divider + md-input-container.md-block(ng-if='$ctrl.account.secondSignature') + label Second Passphrase + input(type='password', ng-model='$ctrl.secondPassphrase') + md-divider + md-dialog-actions(layout='row') + md-button(ng-click="$ctrl.$mdDialog.cancel()") Cancel + span(flex) + md-button(ng-disabled='!$ctrl.canVote()', ng-click="$ctrl.vote()") {{$ctrl.votingInProgress ? 'Voting...' : 'Confirm vote'}} diff --git a/src/app/components/login/login.js b/src/app/components/login/login.js index bbbbbd31b..3f7a87213 100644 --- a/src/app/components/login/login.js +++ b/src/app/components/login/login.js @@ -49,10 +49,12 @@ app.component('login', { } doTheLogin() { - this.passphrase = login.fixCaseAndWhitespace(this.input_passphrase); + if (this.isValidPassphrase(this.input_passphrase) === 1) { + this.passphrase = login.fixCaseAndWhitespace(this.input_passphrase); - this.reset(); - this.$timeout(this.onLogin); + this.reset(); + this.$timeout(this.onLogin); + } } isValidPassphrase(value) { @@ -65,6 +67,7 @@ app.component('login', { } else { this.valid = 1; } + return this.valid; } startGenratingNewPassphrase() { diff --git a/src/app/components/main/main.less b/src/app/components/main/main.less index cb36cc197..dad696519 100644 --- a/src/app/components/main/main.less +++ b/src/app/components/main/main.less @@ -40,3 +40,15 @@ main { opacity: 0.5; } } + +md-toast.lsk-toast-success { + .md-toast-content { + background-color: #7cb342; + } +} + +md-toast.lsk-toast-error { + .md-toast-content { + background-color: #c62828; + } +} diff --git a/src/app/components/main/main.pug b/src/app/components/main/main.pug index e1bb88139..6504b68ae 100644 --- a/src/app/components/main/main.pug +++ b/src/app/components/main/main.pug @@ -11,4 +11,16 @@ md-content(layout='row', id="main") login(passphrase='$ctrl.passphrase', on-login='$ctrl.login()') div(ng-if='$ctrl.logged', ng-class='{ online: $ctrl.$peers.online, offline: !$ctrl.$peers.online }') top(account='$ctrl.account') - transactions(account='$ctrl.account') \ No newline at end of file + md-tabs(md-selected='selectedIndex', md-dynamic-height='true', md-stretch-tabs='always') + md-tab(md-on-select='onTabSelected(tab)', md-on-deselect='announceDeselected(tab)', ng-disabled='tab.disabled') + md-tab-label Transactions + md-tab-body + transactions(account='$ctrl.account') + md-tab(md-on-select='onTabSelected(tab)', md-on-deselect='announceDeselected(tab)', ng-disabled='tab.disabled') + md-tab-label Voting + md-tab-body + delegates(account='$ctrl.account', passphrase='$ctrl.passphrase') + md-tab(ng-if='$ctrl.isDelegate') + md-tab-label Forging + md-tab-body + forging(account='$ctrl.account') diff --git a/src/app/components/transactions/transactions.js b/src/app/components/transactions/transactions.js index 9b136af89..9d9eaae13 100644 --- a/src/app/components/transactions/transactions.js +++ b/src/app/components/transactions/transactions.js @@ -58,7 +58,7 @@ app.component('transactions', { limit = 10; } - return this.$peers.active.listTransactionsPromise(this.account.address, limit) + return this.$peers.listTransactions(this.account.address, limit) .then(this._processTransactionsResponse.bind(this)) .catch(() => { this.transactions = []; diff --git a/src/app/libs.js b/src/app/libs.js index e6f761d09..b9ee06b23 100644 --- a/src/app/libs.js +++ b/src/app/libs.js @@ -9,5 +9,7 @@ import 'angular-material'; import 'angular-material/angular-material.css'; import 'angular-material-data-table/dist/md-data-table'; import 'angular-material-data-table/dist/md-data-table.css'; +import 'ng-infinite-scroll'; + import 'babel-polyfill'; diff --git a/src/app/lisk-nano.js b/src/app/lisk-nano.js index 07b2d2a02..fc9ad201d 100644 --- a/src/app/lisk-nano.js +++ b/src/app/lisk-nano.js @@ -12,6 +12,8 @@ import './components/transactions/transactions'; import './components/timestamp/timestamp'; import './components/lsk/lsk'; import './components/forging/forging'; +import './components/delegates/delegates'; +import './components/delegates/vote'; import './services/peers/peers'; import './services/lsk'; diff --git a/src/app/services/peers/peers.js b/src/app/services/peers/peers.js index 82d8248a7..106eeac75 100644 --- a/src/app/services/peers/peers.js +++ b/src/app/services/peers/peers.js @@ -53,35 +53,54 @@ app.factory('$peers', ($timeout, $cookies, $location, $q) => { this.check(); } + sendRequestPromise(api, urlParams) { + const deferred = $q.defer(); + this.active.sendRequest(api, urlParams, (data) => { + if (data.success) { + return deferred.resolve(data); + } + return deferred.reject(data); + }); + return deferred.promise; + } + + listAccountDelegates(urlParams) { + return this.sendRequestPromise('accounts/delegates', urlParams); + } + + listDelegates(urlParams) { + return this.sendRequestPromise(`delegates/${urlParams.q ? 'search' : ''}`, urlParams); + } + + getDelegate(urlParams) { + return this.sendRequestPromise('delegates/get', urlParams); + } + + listTransactions(address, limit, offset) { + return this.sendRequestPromise('transactions', { + senderId: address, + recipientId: address, + limit: limit || 20, + offset: offset || 0, + }); + } + setPeerAPIObject(config) { this.active = lisk.api(config); - this.active.getStatusPromise = () => { - const deferred = $q.defer(); - this.active.sendRequest('loader/status', {}, (data) => { - if (data.success) { - return deferred.resolve(); - } - return deferred.reject(); - }); - return deferred.promise; - }; + this.active.getStatusPromise = () => this.sendRequestPromise('loader/status', {}); this.active.getAccountPromise = (address) => { const deferred = $q.defer(); this.active.getAccount(address, (data) => { if (data.success) { deferred.resolve(data.account); + } else { + deferred.resolve({ + address, + balance: 0, + }); } - this.active.sendRequest('accounts/getBalance', { address }, (balanceData) => { - if (balanceData.success) { - deferred.resolve({ - address, - balance: balanceData.balance, - }); - } - deferred.reject(balanceData); - }); }); return deferred.promise; }; @@ -96,17 +115,6 @@ app.factory('$peers', ($timeout, $cookies, $location, $q) => { }); return deferred.promise; }; - - this.active.listTransactionsPromise = (address, limit, offset) => { - const deferred = $q.defer(); - this.active.listTransactions(address, limit, offset, (data) => { - if (data.success) { - return deferred.resolve(data); - } - return deferred.reject(data); - }); - return deferred.promise; - }; } check() { diff --git a/src/package.json b/src/package.json index 1b6553031..f74e02e50 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "lisk-nano", - "version": "0.1.2", + "version": "0.2.0", "description": "Lisk Nano", "scripts": { "build": "webpack --profile --progress --display-modules --display-exclude --display-chunks --display-cached --display-cached-assets", @@ -24,9 +24,11 @@ "bitcore-mnemonic": "=1.1.1", "debug": "=2.2.0", "jquery": "=2.2.4", - "lisk-js": "git://github.com/liskHQ/lisk-js.git#development", + "lisk-js": "=0.4.0", "lodash": "=4.16.4", - "moment": "=2.15.1" + "moment": "=2.15.1", + "ng-infinite-scroll": "=1.3.0", + "numeral": "=1.5.3" }, "devDependencies": { "angular-mocks": "=1.5.8", diff --git a/src/test/components/delegates/delegates.spec.js b/src/test/components/delegates/delegates.spec.js new file mode 100644 index 000000000..e02eb9ad2 --- /dev/null +++ b/src/test/components/delegates/delegates.spec.js @@ -0,0 +1,321 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +const expect = chai.expect; +chai.use(sinonChai); + +describe('Delegates component', () => { + let $compile; + let $rootScope; + let element; + let $scope; + let $peers; + let lsk; + + beforeEach(angular.mock.module('app')); + + beforeEach(inject((_$compile_, _$rootScope_, _$peers_, _lsk_) => { + $compile = _$compile_; + $rootScope = _$rootScope_; + $peers = _$peers_; + lsk = _lsk_; + })); + + beforeEach(() => { + $peers.active = { sendRequest() {} }; + const mock = sinon.mock($peers.active); + mock.expects('sendRequest').withArgs('accounts/delegates').callsArgWith(2, { + success: true, + delegates: Array.from({ length: 10 }, (v, k) => ({ + username: `genesis_${k}`, + })), + }); + mock.expects('sendRequest').withArgs('delegates/').callsArgWith(2, { + success: true, + delegates: Array.from({ length: 100 }, (v, k) => ({ + username: `genesis_${k}`, + })), + }); + + $scope = $rootScope.$new(); + $scope.passphrase = 'robust swift grocery peasant forget share enable convince deputy road keep cheap'; + $scope.account = { + address: '8273455169423958419L', + balance: lsk.from(100), + }; + element = $compile('')($scope); + $scope.$digest(); + }); + + const BUTTON_LABEL = 'Vote'; + it(`should contain button saying "${BUTTON_LABEL}"`, () => { + expect(element.find('md-card-title button').text()).to.contain(BUTTON_LABEL); + }); +}); + +describe('delegates component controller', () => { + beforeEach(angular.mock.module('app')); + + let $rootScope; + let $scope; + let controller; + let $componentController; + let activePeerMock; + let $peers; + let delegates; + let $q; + + beforeEach(inject((_$componentController_, _$rootScope_, _$q_, _$peers_) => { + $componentController = _$componentController_; + $rootScope = _$rootScope_; + $peers = _$peers_; + $q = _$q_; + })); + + beforeEach(() => { + delegates = Array.from({ length: 100 }, (v, k) => ({ + username: `genesis_${k}`, + status: {}, + })); + + $peers.active = { sendRequest() {} }; + activePeerMock = sinon.mock($peers.active); + + $scope = $rootScope.$new(); + controller = $componentController('delegates', $scope, { + account: { + address: '8273455169423958419L', + balance: '10000', + }, + }); + controller.delegates = delegates; + controller.voteList = delegates.slice(1, 3); + controller.delegatesTotalCount = delegates.length + 100; + }); + + describe('constructor()', () => { + it('sets $watch on $scope.search that fetches delegates matching the search term', () => { + activePeerMock.expects('sendRequest').withArgs('delegates/search').callsArgWith(2, { + success: true, + delegates, + }); + controller.$scope.$digest(); + controller.$scope.search = 'genesis_42'; + controller.$scope.$digest(); + }); + + it('sets to run this.updateALl() $on "peerUpdate" is $emited', () => { + const mock = sinon.mock(controller); + mock.expects('updateAll').withArgs(); + controller.$scope.$emit('peerUpdate'); + mock.verify(); + mock.restore(); + }); + }); + + describe('showMore()', () => { + it('increases this.delegatesDisplayedCount by 20 if this.delegatesDisplayedCount < this.delegates.length', () => { + const initialCount = controller.delegatesDisplayedCount; + controller.showMore(); + expect(controller.delegatesDisplayedCount).to.equal(initialCount + 20); + }); + + it('fetches more delegates if this.delegatesDisplayedCount - this.delegates.length <= 20', () => { + activePeerMock.expects('sendRequest').withArgs('delegates/').callsArgWith(2, { + success: true, + delegates, + }); + + controller.delegatesDisplayedCount = 100; + controller.loading = false; + const initialCount = controller.delegatesDisplayedCount; + controller.showMore(); + expect(controller.delegatesDisplayedCount).to.equal(initialCount); + }); + }); + + describe('selectionChange(delegate)', () => { + it('pushes delegate to this.unvoteList if delegate.status.voted && !delegate.status.selected', () => { + const delegate = { + status: { + voted: true, + selected: false, + }, + }; + controller.selectionChange(delegate); + expect(controller.unvoteList).to.contain(delegate); + }); + + it('pushes delegate to this.voteList if !delegate.status.voted && delegate.status.selected', () => { + const delegate = { + status: { + voted: false, + selected: true, + }, + }; + controller.selectionChange(delegate); + expect(controller.voteList).to.contain(delegate); + }); + + it('removes delegate from this.unvoteList if delegate.status.voted && delegate.status.selected', () => { + const delegate = { + status: { + voted: true, + selected: true, + }, + }; + controller.unvoteList = [delegate]; + controller.selectionChange(delegate); + expect(controller.unvoteList).to.not.contain(delegate); + }); + + it('removes delegate from this.voteList if !delegate.status.voted && !delegate.status.selected', () => { + const delegate = { + status: { + voted: false, + selected: false, + }, + }; + controller.voteList = [delegate]; + controller.selectionChange(delegate); + expect(controller.voteList).to.not.contain(delegate); + }); + }); + + describe('clearSearch()', () => { + it('sets this.$scope.search to empty string', () => { + controller.$scope.search = 'non-empty string'; + controller.clearSearch(); + expect(controller.$scope.search).to.equal(''); + }); + }); + + describe('openVoteDialog()', () => { + it('opens vote dialog', () => { + const spy = sinon.spy(controller.$mdDialog, 'show'); + controller.openVoteDialog(); + expect(spy).to.have.been.calledWith(); + }); + }); + + describe('addToUnvoteList()', () => { + it('adds delegate to unvoteList', () => { + const delegate = { + username: 'test', + status: { + voted: true, + selected: true, + }, + }; + controller.addToUnvoteList(delegate); + expect(controller.unvoteList.length).to.equal(1); + expect(controller.unvoteList[0]).to.deep.equal(delegate); + }); + + it('does not add delegate to unvoteList if already there', () => { + const delegate = { + username: 'genesis_42', + status: { + voted: true, + selected: false, + }, + }; + controller.unvoteList = [delegate]; + controller.addToUnvoteList(delegate); + expect(controller.unvoteList.length).to.equal(1); + }); + }); + + describe('setPendingVotes()', () => { + it('clears this.voteList and this.unvoteList', () => { + controller.unvoteList = controller.delegates.slice(10, 13); + expect(controller.voteList.length).to.not.equal(0); + expect(controller.unvoteList.length).to.not.equal(0); + + controller.setPendingVotes(); + + expect(controller.voteList.length).to.equal(0); + expect(controller.unvoteList.length).to.equal(0); + }); + }); + + describe('parseVoteListFromInput(list)', () => { + let peersMock; + + beforeEach(() => { + peersMock = sinon.mock(controller.$peers); + }); + + it('parses this.usernameInput to list of delegates and opens vote dialog if all delegates were immediately resolved', () => { + const spy = sinon.spy(controller, 'openVoteDialog'); + controller.usernameInput = 'genesis_20\ngenesis_42\ngenesis_46\n'; + controller.parseVoteListFromInput(controller.unvoteList); + expect(spy).to.have.been.calledWith(); + }); + + it('parses this.usernameInput to list of delegates and opens vote dialog if all delegates were resolved immediately or from server', () => { + const username = 'not_fetched_yet'; + const deffered = $q.defer(); + peersMock.expects('sendRequestPromise').withArgs('delegates/get', { username }).returns(deffered.promise); + const spy = sinon.spy(controller, 'openVoteDialog'); + controller.usernameInput = `${username}\ngenesis_42\ngenesis_46`; + + controller.parseVoteListFromInput(controller.unvoteList); + + deffered.resolve({ + success: true, + delegate: { + username, + }, + }); + $scope.$apply(); + expect(spy).to.have.been.calledWith(); + }); + + it('parses this.usernameInput to list of delegates and opens vote dialog if any delegates were resolved', () => { + const username = 'invalid_name'; + const deffered = $q.defer(); + peersMock.expects('sendRequestPromise').withArgs('delegates/get', { username }).returns(deffered.promise); + const spy = sinon.spy(controller, 'openVoteDialog'); + controller.usernameInput = `${username}\ngenesis_42\ngenesis_46`; + + controller.parseVoteListFromInput(controller.unvoteList); + + deffered.reject({ success: false }); + $scope.$apply(); + expect(spy).to.have.been.calledWith(); + }); + + it('parses this.usernameInput to list of delegates and shows error toast if no delegates were resolved', () => { + const username = 'invalid_name'; + const deffered = $q.defer(); + peersMock.expects('sendRequestPromise').withArgs('delegates/get', { username }).returns(deffered.promise); + const toastSpy = sinon.spy(controller.$mdToast, 'show'); + const dialogSpy = sinon.spy(controller, 'openVoteDialog'); + controller.usernameInput = username; + controller.voteList = []; + + controller.parseVoteListFromInput(controller.unvoteList); + + deffered.reject({ success: false }); + $scope.$apply(); + expect(toastSpy).to.have.been.calledWith(); + expect(dialogSpy).to.not.have.been.calledWith(); + }); + }); + + describe('parseUnvoteListFromInput(list)', () => { + it('parses this.usernameInput to list of delegates and opens vote dialog if all delegates were immediately resolved', () => { + const delegate = { + username: 'genesis_20', + status: {}, + }; + const spy = sinon.spy(controller, 'openVoteDialog'); + controller.votedDict[delegate.username] = delegate; + controller.usernameInput = `${delegate.username}\ngenesis_42\ngenesis_46\n`; + controller.parseUnvoteListFromInput(controller.unvoteList); + expect(spy).to.have.been.calledWith(); + }); + }); +}); diff --git a/src/test/components/delegates/vote.spec.js b/src/test/components/delegates/vote.spec.js new file mode 100644 index 000000000..8468eb3ad --- /dev/null +++ b/src/test/components/delegates/vote.spec.js @@ -0,0 +1,139 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +const expect = chai.expect; +chai.use(sinonChai); + +describe('Vote component', () => { + let $compile; + let $rootScope; + let element; + let $scope; + let $peers; + let lsk; + + beforeEach(angular.mock.module('app')); + + beforeEach(inject((_$compile_, _$rootScope_, _$peers_, _lsk_) => { + $compile = _$compile_; + $rootScope = _$rootScope_; + $peers = _$peers_; + lsk = _lsk_; + })); + + beforeEach(() => { + $peers.active = { sendRequest() {} }; + + $scope = $rootScope.$new(); + $scope.passphrase = 'robust swift grocery peasant forget share enable convince deputy road keep cheap'; + $scope.account = { + address: '8273455169423958419L', + balance: lsk.from(100), + }; + $scope.voteList = Array.from({ length: 10 }, (v, k) => ({ + username: `genesis_${k}`, + })); + $scope.unvoteList = Array.from({ length: 3 }, (v, k) => ({ + username: `genesis_${k}`, + })); + element = $compile('')($scope); + $scope.$digest(); + }); + + const DIALOG_TITLE = 'Vote for delegates'; + it(`should contain a title saying "${DIALOG_TITLE}"`, () => { + expect(element.find('h2').text()).to.equal(DIALOG_TITLE); + }); +}); + +describe('Vote component controller', () => { + beforeEach(angular.mock.module('app')); + + let $rootScope; + let $scope; + let controller; + let $componentController; + let peersMock; + let $peers; + let $q; + + beforeEach(inject((_$componentController_, _$rootScope_, _$peers_, _$q_) => { + $componentController = _$componentController_; + $rootScope = _$rootScope_; + $peers = _$peers_; + $q = _$q_; + })); + + beforeEach(() => { + peersMock = sinon.mock($peers); + + $scope = $rootScope.$new(); + controller = $componentController('vote', $scope, { + account: { + address: '8273455169423958419L', + balance: '10000', + }, + }); + controller.voteList = Array.from({ length: 10 }, (v, k) => ({ + username: `genesis_${k}`, + status: { + selected: true, + voted: false, + changed: true, + }, + })); + controller.unvoteList = Array.from({ length: 3 }, (v, k) => ({ + username: `genesis_${k}`, + status: { + selected: true, + voted: true, + changed: true, + }, + })); + }); + + describe('vote()', () => { + let deffered; + let mdToastMock; + + beforeEach(() => { + deffered = $q.defer(); + peersMock.expects('sendRequestPromise').withArgs('accounts/delegates').returns(deffered.promise); + mdToastMock = sinon.mock(controller.$mdToast); + mdToastMock.expects('show'); + }); + + afterEach(() => { + mdToastMock.verify(); + peersMock.verify(); + }); + + it('shows an error $mdToast if request fails', () => { + controller.vote(); + deffered.reject({ success: false }); + $scope.$apply(); + }); + + it('shows a success $mdToast if request succeeds', () => { + controller.vote(); + deffered.resolve({ success: true }); + $scope.$apply(); + }); + }); + + describe('removeVote(list, index)', () => { + it('removes vote at index from the list', () => { + const index = 2; + const vote = controller.voteList[index]; + + controller.removeVote(controller.voteList, index); + + expect(vote.status.changed).to.equal(false); + expect(vote.status.selected).to.equal(false); + expect(controller.voteList).to.not.contain(vote); + }); + }); +}); + diff --git a/src/test/components/login/login.spec.js b/src/test/components/login/login.spec.js index 38889290a..7721e3a2e 100644 --- a/src/test/components/login/login.spec.js +++ b/src/test/components/login/login.spec.js @@ -59,6 +59,7 @@ describe('Login controller', () => { let $scope; let controller; let $componentController; + let testPassphrase; beforeEach(inject((_$componentController_, _$rootScope_) => { $componentController = _$componentController_; @@ -66,6 +67,7 @@ describe('Login controller', () => { })); beforeEach(() => { + testPassphrase = 'glow two glimpse camp aware tip brief confirm similar code float defense'; $scope = $rootScope.$new(); controller = $componentController('login', $scope, { }); controller.onLogin = function () {}; @@ -151,18 +153,20 @@ describe('Login controller', () => { describe('$scope.doTheLogin()', () => { it('sets this.phassphrase as this.input_passphrase processed by fixCaseAndWhitespace', () => { - controller.input_passphrase = '\tTEST PassPHrASe '; + controller.input_passphrase = '\tGLOW two GliMpse camp aware tip brief confirm similar code float defense '; controller.doTheLogin(); - expect(controller.passphrase).to.equal('test passphrase'); + expect(controller.passphrase).to.equal('glow two glimpse camp aware tip brief confirm similar code float defense'); }); it('calls this.reset()', () => { + controller.input_passphrase = testPassphrase; const spy = sinon.spy(controller, 'reset'); controller.doTheLogin(); expect(spy).to.have.been.calledWith(); }); it('sets timeout with this.onLogin', () => { + controller.input_passphrase = testPassphrase; const spy = sinon.spy(controller, '$timeout'); controller.doTheLogin(); expect(spy).to.have.been.calledWith(controller.onLogin); @@ -200,7 +204,6 @@ describe('Login controller', () => { describe('$scope.devTestAccount()', () => { it('sets input_passphrase from cookie called passphrase if present', () => { - const testPassphrase = 'test passphrase'; const mock = sinon.mock(controller.$cookies); mock.expects('get').returns(testPassphrase); controller.devTestAccount(); @@ -208,7 +211,6 @@ describe('Login controller', () => { }); it('does nothing if cooke called passphrase not present', () => { - const testPassphrase = 'test passphrase'; controller.input_passphrase = testPassphrase; const mock = sinon.mock(controller.$cookies); diff --git a/src/test/components/transactions/transactions.spec.js b/src/test/components/transactions/transactions.spec.js index d8d2ea3a2..9cd8cefc4 100644 --- a/src/test/components/transactions/transactions.spec.js +++ b/src/test/components/transactions/transactions.spec.js @@ -50,9 +50,9 @@ describe('transactions component controller', () => { let mock; beforeEach(() => { - controller.$peers = { active: { listTransactionsPromise() {} } }; - mock = sinon.mock(controller.$peers.active); - mock.expects('listTransactionsPromise').returns($q(() => {})); + controller.$peers.listTransactions = () => {}; + mock = sinon.mock(controller.$peers); + mock.expects('listTransactions').returns($q(() => {})); }); it('sets this.loading = true', () => { @@ -76,16 +76,16 @@ describe('transactions component controller', () => { expect(spy).to.have.been.calledWith(controller.timeout); }); - it('calls this.$peers.active.listTransactionsPromise(this.account.address, limit) with limit = 10 by default', () => { - controller.$peers = { active: { listTransactionsPromise() {} } }; - mock = sinon.mock(controller.$peers.active); + it('calls this.$peers.listTransactions(this.account.address, limit) with limit = 10 by default', () => { + controller.$peers.listTransactions = () => {}; + mock = sinon.mock(controller.$peers); const transactionsDeferred = $q.defer(); - mock.expects('listTransactionsPromise').withArgs(controller.account.address, 10).returns(transactionsDeferred.promise); + mock.expects('listTransactions').withArgs(controller.account.address, 10).returns(transactionsDeferred.promise); controller.update(); transactionsDeferred.reject(); // Mock because $scope.apply() will call update() again - mock.expects('listTransactionsPromise').withArgs(controller.account.address, 10).returns(transactionsDeferred.promise); + mock.expects('listTransactions').withArgs(controller.account.address, 10).returns(transactionsDeferred.promise); $scope.$apply(); mock.verify(); mock.restore(); diff --git a/src/test/test.js b/src/test/test.js index f01e7e133..d4cce0f8b 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -1,4 +1,6 @@ require('./components/forging/forging.spec'); +require('./components/delegates/delegates.spec'); +require('./components/delegates/vote.spec'); require('./components/login/login.spec'); require('./components/main/main.spec'); require('./components/send/send.spec');