Default Account selector in Signer overlay (#4375)

* Manage default accounts

* Portal

* Portal

* Allow Portal to be used in as both top-level and popover

* modal/popover variable naming

* Move to Portal

* export Portal in ~/ui

* WIP

* Tags handle empty values

* Export AccountCard in ~/ui

* Allow ETH-only & zero display

* Use ui/Balance for balance display

* Add tests for Balance & Tags component availability

* WIP

* Default overlay display to block (not flex)

* Revert block

* WIP

* Add className, optional handlers only

* WIP

* Properly handle optional onKeyDown

* Selection updated

* Align margins

* Remove old code

* Remove debug logging

* TransitionGroup for animations

* No anim

* Cleanups

* Revert addons removal

* Fix tests

* defaultAccount

* Selection actually selects

* WIP tests

* tests WIP

* Expand tests

* Container for scrollbars

* Add parity_defaultAccount RPC (with subscription)

* Add jsonrpc interface
This commit is contained in:
Jaco Greeff 2017-02-01 16:18:11 +01:00 committed by Gav Wood
parent a414729de9
commit 3bdd32f9ec
6 changed files with 442 additions and 26 deletions

View File

@ -0,0 +1,101 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { action, observable, transaction } from 'mobx';
export default class AccountStore {
@observable accounts = [];
@observable defaultAccount = null;
@observable isLoading = false;
constructor (api) {
this._api = api;
this.loadAccounts();
this.subscribeDefaultAccount();
}
@action setAccounts = (accounts) => {
this.accounts = accounts;
}
@action setDefaultAccount = (defaultAccount) => {
this.defaultAccount = defaultAccount;
}
@action setLoading = (isLoading) => {
this.isLoading = isLoading;
}
makeDefaultAccount = (address) => {
const accounts = [address].concat(
this.accounts
.filter((account) => account.address !== address)
.map((account) => account.address)
);
return this._api.parity
.setNewDappsWhitelist(accounts)
.catch((error) => {
console.warn('makeDefaultAccount', error);
});
}
loadAccounts () {
this.setLoading(true);
return Promise
.all([
this._api.parity.getNewDappsWhitelist(),
this._api.parity.allAccountsInfo()
])
.then(([whitelist, accounts]) => {
transaction(() => {
this.setLoading(false);
this.setAccounts(
Object
.keys(accounts)
.filter((address) => {
const isAccount = accounts[address].uuid;
const isWhitelisted = !whitelist || whitelist.includes(address);
return isAccount && isWhitelisted;
})
.map((address) => {
const account = accounts[address];
account.address = address;
account.default = address === this.defaultAccount;
return account;
})
);
});
})
.catch((error) => {
this.setLoading(false);
console.warn('loadAccounts', error);
});
}
subscribeDefaultAccount () {
return this._api.subscribe('parity_defaultAccount', (error, defaultAccount) => {
if (!error) {
this.setDefaultAccount(defaultAccount);
}
});
}
}

View File

@ -0,0 +1,104 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import sinon from 'sinon';
import AccountStore from './accountStore';
import { ACCOUNT_DEFAULT, ACCOUNT_FIRST, ACCOUNT_NEW, createApi } from './parityBar.test.js';
let api;
let store;
function create () {
api = createApi();
store = new AccountStore(api);
return store;
}
describe('views/ParityBar/AccountStore', () => {
beforeEach(() => {
create();
});
describe('constructor', () => {
it('subscribes to defaultAccount', () => {
expect(api.subscribe).to.have.been.calledWith('parity_defaultAccount');
});
});
describe('@action', () => {
describe('setAccounts', () => {
it('sets the accounts', () => {
store.setAccounts('testing');
expect(store.accounts).to.equal('testing');
});
});
describe('setDefaultAccount', () => {
it('sets the default account', () => {
store.setDefaultAccount('testing');
expect(store.defaultAccount).to.equal('testing');
});
});
describe('setLoading', () => {
it('sets the loading status', () => {
store.setLoading('testing');
expect(store.isLoading).to.equal('testing');
});
});
});
describe('operations', () => {
describe('loadAccounts', () => {
beforeEach(() => {
sinon.spy(store, 'setAccounts');
return store.loadAccounts();
});
afterEach(() => {
store.setAccounts.restore();
});
it('calls into parity_getNewDappsWhitelist', () => {
expect(api.parity.getNewDappsWhitelist).to.have.been.called;
});
it('calls into parity_allAccountsInfo', () => {
expect(api.parity.allAccountsInfo).to.have.been.called;
});
it('sets the accounts', () => {
expect(store.setAccounts).to.have.been.called;
});
});
describe('makeDefaultAccount', () => {
beforeEach(() => {
return store.makeDefaultAccount(ACCOUNT_NEW);
});
it('calls into parity_setNewDappsWhitelist (with ordering)', () => {
expect(api.parity.setNewDappsWhitelist).to.have.been.calledWith([
ACCOUNT_NEW, ACCOUNT_FIRST, ACCOUNT_DEFAULT
]);
});
});
});
});

View File

@ -15,6 +15,44 @@
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.account {
display: flex;
flex: 1;
position: relative;
.accountOverlay {
position: absolute;
right: 0.5em;
top: 0.5em;
}
.iconDisabled {
opacity: 0.15;
}
.selected,
.unselected {
margin: 0.125em 0;
&:focus {
outline: none;
}
}
.unselected {
background: rgba(0, 0, 0, 0.4) !important;
}
.selected {
background: rgba(255, 255, 255, 0.35) !important;
}
}
.container {
display: flex;
flex-direction: column;
}
.overlay {
position: fixed;
top: 0;
@ -26,7 +64,8 @@
user-select: none;
}
.bar, .expanded {
.bar,
.expanded {
position: fixed;
font-size: 16px;
font-family: 'Roboto', sans-serif;
@ -110,7 +149,9 @@
margin-left: 1em;
}
.button, .parityButton {
.button,
.iconButton,
.parityButton {
overflow: visible !important;
}
@ -123,6 +164,14 @@
fill: white !important;
}
.iconButton {
min-width: 2em !important;
img {
margin: 6px 0.5em 0 0.5em;
}
}
.label {
position: relative;
display: inline-block;
@ -151,7 +200,8 @@
}
}
.header, .corner {
.header,
.corner {
button {
color: white !important;
}
@ -180,7 +230,8 @@
}
}
.parityIcon, .signerIcon {
.parityIcon,
.signerIcon {
width: 24px;
height: 24px;
vertical-align: middle;

View File

@ -14,25 +14,30 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { throttle } from 'lodash';
import { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router';
import { connect } from 'react-redux';
import { throttle } from 'lodash';
import store from 'store';
import imagesEthcoreBlock from '~/../assets/images/parity-logo-white-no-text.svg';
import { AccountCard, Badge, Button, ContainerTitle, IdentityIcon, ParityBackground, SectionList } from '~/ui';
import { CancelIcon, FingerprintIcon } from '~/ui/Icons';
import { Badge, Button, ContainerTitle, ParityBackground } from '~/ui';
import { Embedded as Signer } from '../Signer';
import DappsStore from '~/views/Dapps/dappsStore';
import { Embedded as Signer } from '~/views/Signer';
import AccountStore from './accountStore';
import styles from './parityBar.css';
const LS_STORE_KEY = '_parity::parityBar';
const DEFAULT_POSITION = { right: '1em', bottom: 0 };
const DISPLAY_ACCOUNTS = 'accounts';
const DISPLAY_SIGNER = 'signer';
@observer
class ParityBar extends Component {
app = null;
measures = null;
@ -49,6 +54,7 @@ class ParityBar extends Component {
};
state = {
displayType: DISPLAY_SIGNER,
moving: false,
opened: false,
position: DEFAULT_POSITION
@ -67,6 +73,8 @@ class ParityBar extends Component {
componentWillMount () {
const { api } = this.context;
this.accountStore = new AccountStore(api);
// Hook to the dapp loaded event to position the
// Parity Bar accordingly
DappsStore.get(api).on('loaded', (app) => {
@ -84,14 +92,14 @@ class ParityBar extends Component {
}
if (count < newCount) {
this.setOpened(true);
this.setOpened(true, DISPLAY_SIGNER);
} else if (newCount === 0 && count === 1) {
this.setOpened(false);
}
}
setOpened (opened) {
this.setState({ opened });
setOpened (opened, displayType = DISPLAY_SIGNER) {
this.setState({ displayType, opened });
if (!this.bar) {
return;
@ -186,9 +194,19 @@ class ParityBar extends Component {
}
return (
<div
className={ styles.cornercolor }
>
<div className={ styles.cornercolor }>
<Button
className={ styles.iconButton }
icon={
<IdentityIcon
address={ this.accountStore.defaultAccount }
button
center
inline
/>
}
onClick={ this.toggleAccountsDisplay }
/>
{
this.renderLink(
<Button
@ -214,7 +232,7 @@ class ParityBar extends Component {
className={ styles.button }
icon={ <FingerprintIcon /> }
label={ this.renderSignerLabel() }
onClick={ this.toggleDisplay }
onClick={ this.toggleSignerDisplay }
/>
{ this.renderDrag() }
</div>
@ -267,27 +285,85 @@ class ParityBar extends Component {
}
renderExpanded () {
const { displayType } = this.state;
return (
<div>
<div className={ styles.container }>
<div className={ styles.header }>
<div className={ styles.title }>
<ContainerTitle title='Parity Signer: Pending' />
<ContainerTitle
title={
displayType === DISPLAY_ACCOUNTS
? (
<FormattedMessage
id='parityBar.title.accounts'
defaultMessage='Default Account'
/>
)
: (
<FormattedMessage
id='parityBar.title.signer'
defaultMessage='Parity Signer: Pending'
/>
)
}
/>
</div>
<div className={ styles.actions }>
<Button
icon={ <CancelIcon /> }
label='Close'
onClick={ this.toggleDisplay }
label={
<FormattedMessage
id='parityBar.button.close'
defaultMessage='Close'
/>
}
onClick={ this.toggleSignerDisplay }
/>
</div>
</div>
<div className={ styles.content }>
<Signer />
{
displayType === DISPLAY_ACCOUNTS
? (
<SectionList
items={ this.accountStore.accounts }
noStretch
renderItem={ this.renderAccount }
/>
)
: (
<Signer />
)
}
</div>
</div>
);
}
renderAccount = (account) => {
const onMakeDefault = () => {
this.toggleAccountsDisplay();
this.accountStore.makeDefaultAccount(account.address);
};
return (
<div
className={ styles.account }
onClick={ onMakeDefault }
>
<AccountCard
account={ account }
className={
account.default
? styles.selected
: styles.unselected
}
/>
</div>
);
}
renderLabel (name, bubble) {
return (
<div className={ styles.label }>
@ -497,10 +573,20 @@ class ParityBar extends Component {
this.savePosition(position);
}
toggleDisplay = () => {
toggleAccountsDisplay = () => {
const { opened } = this.state;
this.setOpened(!opened);
this.setOpened(!opened, DISPLAY_ACCOUNTS);
if (!opened) {
this.accountStore.loadAccounts();
}
}
toggleSignerDisplay = () => {
const { opened } = this.state;
this.setOpened(!opened, DISPLAY_SIGNER);
}
get config () {
@ -542,13 +628,22 @@ class ParityBar extends Component {
stringToPosition (value) {
switch (value) {
case 'top-left':
return { top: 0, left: '1em' };
return {
left: '1em',
top: 0
};
case 'top-right':
return { top: 0, right: '1em' };
return {
right: '1em',
top: 0
};
case 'bottom-left':
return { bottom: 0, left: '1em' };
return {
bottom: 0,
left: '1em'
};
case 'bottom-right':
default:

View File

@ -20,6 +20,9 @@ import sinon from 'sinon';
import ParityBar from './';
import { createApi } from './parityBar.test.js';
let api;
let component;
let instance;
let store;
@ -35,6 +38,7 @@ function createRedux (state = {}) {
}
function render (props = {}, state = {}) {
api = createApi();
component = shallow(
<ParityBar { ...props } />,
{
@ -42,7 +46,7 @@ function render (props = {}, state = {}) {
store: createRedux(state)
}
}
).find('ParityBar').shallow({ context: { api: {} } });
).find('ParityBar').shallow({ context: { api } });
instance = component.instance();
return component;
@ -77,8 +81,14 @@ describe('views/ParityBar', () => {
expect(bar.find('div')).not.to.have.length(0);
});
it('renders the Account selector button', () => {
const icon = bar.find('Button').first().props().icon;
expect(icon.type.displayName).to.equal('Connect(IdentityIcon)');
});
it('renders the Parity button', () => {
const label = shallow(bar.find('Button').first().props().label);
const label = shallow(bar.find('Button').at(1).props().label);
expect(label.find('FormattedMessage').props().id).to.equal('parityBar.label.parity');
});

View File

@ -0,0 +1,55 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import sinon from 'sinon';
const ACCOUNT_DEFAULT = '0x2345678901';
const ACCOUNT_FIRST = '0x1234567890';
const ACCOUNT_NEW = '0x0987654321';
const ACCOUNTS = {
[ACCOUNT_FIRST]: { uuid: 123 },
[ACCOUNT_DEFAULT]: { uuid: 234 },
'0x3456789012': {},
[ACCOUNT_NEW]: { uuid: 456 }
};
function createApi () {
const api = {
subscribe: (params, callback) => {
callback(null, ACCOUNT_DEFAULT);
return Promise.resolve(1);
},
parity: {
defaultAccount: sinon.stub().resolves(ACCOUNT_DEFAULT),
allAccountsInfo: sinon.stub().resolves(ACCOUNTS),
getNewDappsWhitelist: sinon.stub().resolves(null),
setNewDappsWhitelist: sinon.stub().resolves(true)
}
};
sinon.spy(api, 'subscribe');
return api;
}
export {
ACCOUNT_DEFAULT,
ACCOUNT_FIRST,
ACCOUNT_NEW,
ACCOUNTS,
createApi
};