Dapp Account Selection & Defaults (#4355)

* 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

* Pr gumbles
This commit is contained in:
Jaco Greeff 2017-01-31 17:04:41 +01:00 committed by GitHub
parent 12aadc3e2a
commit a935a04449
10 changed files with 253 additions and 225 deletions

View File

@ -15,33 +15,60 @@
/* along with Parity. If not, see <http://www.gnu.org/licenses/>. /* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/ */
.modal {
flex-direction: column;
}
.container {
margin-top: 1.5em;
overflow-y: auto;
}
.item { .item {
.info { display: flex;
display: inline-block; flex: 1;
position: relative;
.address { .overlay {
opacity: 0.75; position: absolute;
} right: 0.5em;
top: 0.5em;
.description {
margin-top: 0.5em;
opacity: 0.75;
}
.name {
margin: 0.5em 0;
text-transform: uppercase;
}
} }
} }
.selected, .unselected { .selected, .unselected {
margin-bottom: 0.25em; margin-bottom: 0.25em;
&:focus {
outline: none;
}
}
.unselected {
background: rgba(0, 0, 0, 0.4) !important;
} }
.selected { .selected {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15) !important;
&.default {
background: rgba(255, 255, 255, 0.35) !important;
}
} }
.unselected { .unselected {
} }
.iconDisabled {
opacity: 0.15;
}
.legend {
opacity: 0.75;
margin-top: 1em;
span {
line-height: 24px;
vertical-align: top;
}
}

View File

@ -14,14 +14,12 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>. // along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { Checkbox } from 'material-ui';
import { List, ListItem } from 'material-ui/List';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Button, IdentityIcon, Modal } from '~/ui'; import { AccountCard, ContainerTitle, Portal, SectionList } from '~/ui';
import { DoneIcon } from '~/ui/Icons'; import { CheckIcon, StarIcon, StarOutlineIcon } from '~/ui/Icons';
import styles from './dappPermissions.css'; import styles from './dappPermissions.css';
@ -39,79 +37,81 @@ export default class DappPermissions extends Component {
} }
return ( return (
<Modal <Portal
actions={ [ className={ styles.modal }
<Button onClose={ store.closeModal }
icon={ <DoneIcon /> } open
key='done'
label={
<FormattedMessage
id='dapps.permissions.button.done'
defaultMessage='Done'
/>
}
onClick={ store.closeModal }
/>
] }
compact
title={
<FormattedMessage
id='dapps.permissions.label'
defaultMessage='visible dapp accounts'
/>
}
visible
> >
<List> <ContainerTitle
{ this.renderListItems() } title={
</List> <FormattedMessage
</Modal> id='dapps.permissions.label'
defaultMessage='visible dapp accounts'
/>
}
/>
<div className={ styles.container }>
<SectionList
items={ store.accounts }
noStretch
renderItem={ this.renderAccount }
/>
</div>
<div className={ styles.legend }>
<FormattedMessage
id='dapps.permissions.description'
defaultMessage='{activeIcon} account is available to application, {defaultIcon} account is the default account'
values={ {
activeIcon: <CheckIcon />,
defaultIcon: <StarIcon />
} }
/>
</div>
</Portal>
); );
} }
renderListItems () { renderAccount = (account) => {
const { store } = this.props; const { store } = this.props;
return store.accounts.map((account) => { const onMakeDefault = () => {
const onCheck = () => { store.setDefaultAccount(account.address);
store.selectAccount(account.address); };
};
// TODO: Once new modal & account selection is in, this should be updated const onSelect = () => {
// to conform to the new (as of this code WIP) look & feel for selection. store.selectAccount(account.address);
// For now in the current/old style, not as pretty but consistent. };
return (
<ListItem let className;
className={
account.checked if (account.checked) {
? styles.selected className = account.default
: styles.unselected ? `${styles.selected} ${styles.default}`
} : styles.selected;
key={ account.address } } else {
leftCheckbox={ className = styles.unselected;
<Checkbox }
checked={ account.checked }
onCheck={ onCheck } return (
/> <div className={ styles.item }>
} <AccountCard
primaryText={ account={ account }
<div className={ styles.item }> className={ className }
<IdentityIcon address={ account.address } /> onClick={ onSelect }
<div className={ styles.info }>
<h3 className={ styles.name }>
{ account.name }
</h3>
<div className={ styles.address }>
{ account.address }
</div>
<div className={ styles.description }>
{ account.description }
</div>
</div>
</div>
}
/> />
); <div className={ styles.overlay }>
}); {
account.checked && account.default
? <StarIcon />
: <StarOutlineIcon className={ styles.iconDisabled } onClick={ onMakeDefault } />
}
{
account.checked
? <CheckIcon onClick={ onSelect } />
: <CheckIcon className={ styles.iconDisabled } onClick={ onSelect } />
}
</div>
</div>
);
} }
} }

View File

@ -33,13 +33,13 @@ describe('modals/DappPermissions', () => {
it('does not render the modal with modalOpen = false', () => { it('does not render the modal with modalOpen = false', () => {
expect( expect(
renderShallow({ modalOpen: false }).find('Connect(Modal)') renderShallow({ modalOpen: false }).find('Portal')
).to.have.length(0); ).to.have.length(0);
}); });
it('does render the modal with modalOpen = true', () => { it('does render the modal with modalOpen = true', () => {
expect( expect(
renderShallow({ modalOpen: true, accounts: [] }).find('Connect(Modal)') renderShallow({ modalOpen: true, accounts: [] }).find('Portal')
).to.have.length(1); ).to.have.length(1);
}); });
}); });

View File

@ -29,12 +29,17 @@ export default class Store {
@action closeModal = () => { @action closeModal = () => {
transaction(() => { transaction(() => {
const accounts = this.accounts let addresses = null;
.filter((account) => account.checked) const checkedAccounts = this.accounts.filter((account) => account.checked);
.map((account) => account.address);
if (checkedAccounts.length) {
addresses = checkedAccounts.filter((account) => account.default)
.concat(checkedAccounts.filter((account) => !account.default))
.map((account) => account.address);
}
this.modalOpen = false; this.modalOpen = false;
this.updateWhitelist(accounts.length === this.accounts.length ? null : accounts); this.updateWhitelist(addresses);
}); });
} }
@ -42,12 +47,15 @@ export default class Store {
transaction(() => { transaction(() => {
this.accounts = Object this.accounts = Object
.values(accounts) .values(accounts)
.map((account) => { .map((account, index) => {
return { return {
address: account.address, address: account.address,
checked: this.whitelist checked: this.whitelist
? this.whitelist.includes(account.address) ? this.whitelist.includes(account.address)
: true, : true,
default: this.whitelist
? this.whitelist[0] === account.address
: index === 0,
description: account.meta.description, description: account.meta.description,
name: account.name name: account.name
}; };
@ -57,9 +65,31 @@ export default class Store {
} }
@action selectAccount = (address) => { @action selectAccount = (address) => {
transaction(() => {
this.accounts = this.accounts.map((account) => {
if (account.address === address) {
account.checked = !account.checked;
account.default = false;
}
return account;
});
this.setDefaultAccount((
this.accounts.find((account) => account.default) ||
this.accounts.find((account) => account.checked) ||
{}
).address);
});
}
@action setDefaultAccount = (address) => {
this.accounts = this.accounts.map((account) => { this.accounts = this.accounts.map((account) => {
if (account.address === address) { if (account.address === address) {
account.checked = !account.checked; account.checked = true;
account.default = true;
} else if (account.default) {
account.default = false;
} }
return account; return account;

View File

@ -23,21 +23,25 @@ const ACCOUNTS = {
'456': { address: '456', name: '456', meta: { description: '456' } }, '456': { address: '456', name: '456', meta: { description: '456' } },
'789': { address: '789', name: '789', meta: { description: '789' } } '789': { address: '789', name: '789', meta: { description: '789' } }
}; };
const WHITELIST = ['123', '456']; const WHITELIST = ['456', '789'];
let api;
let store;
function create () {
api = {
parity: {
getNewDappsWhitelist: sinon.stub().resolves(WHITELIST),
setNewDappsWhitelist: sinon.stub().resolves(true)
}
};
store = new Store(api);
}
describe('modals/DappPermissions/store', () => { describe('modals/DappPermissions/store', () => {
let api;
let store;
beforeEach(() => { beforeEach(() => {
api = { create();
parity: {
getNewDappsWhitelist: sinon.stub().resolves(WHITELIST),
setNewDappsWhitelist: sinon.stub().resolves(true)
}
};
store = new Store(api);
}); });
describe('constructor', () => { describe('constructor', () => {
@ -51,49 +55,71 @@ describe('modals/DappPermissions/store', () => {
}); });
describe('@actions', () => { describe('@actions', () => {
describe('openModal', () => { beforeEach(() => {
beforeEach(() => { store.openModal(ACCOUNTS);
store.openModal(ACCOUNTS); });
});
describe('openModal', () => {
it('sets the modalOpen status', () => { it('sets the modalOpen status', () => {
expect(store.modalOpen).to.be.true; expect(store.modalOpen).to.be.true;
}); });
it('sets accounts with checked interfaces', () => { it('sets accounts with checked interfaces', () => {
expect(store.accounts.peek()).to.deep.equal([ expect(store.accounts.peek()).to.deep.equal([
{ address: '123', name: '123', description: '123', checked: true }, { address: '123', name: '123', description: '123', default: false, checked: false },
{ address: '456', name: '456', description: '456', checked: true }, { address: '456', name: '456', description: '456', default: true, checked: true },
{ address: '789', name: '789', description: '789', checked: false } { address: '789', name: '789', description: '789', default: false, checked: true }
]); ]);
}); });
}); });
describe('closeModal', () => { describe('closeModal', () => {
beforeEach(() => { beforeEach(() => {
store.openModal(ACCOUNTS); store.setDefaultAccount('789');
store.selectAccount('789');
store.closeModal(); store.closeModal();
}); });
it('calls setNewDappsWhitelist', () => { it('calls setNewDappsWhitelist', () => {
expect(api.parity.setNewDappsWhitelist).to.have.been.calledOnce; expect(api.parity.setNewDappsWhitelist).to.have.been.calledOnce;
}); });
it('has the default account in first position', () => {
expect(api.parity.setNewDappsWhitelist).to.have.been.calledWith(['789', '456']);
});
}); });
describe('selectAccount', () => { describe('selectAccount', () => {
beforeEach(() => { beforeEach(() => {
store.openModal(ACCOUNTS);
store.selectAccount('123'); store.selectAccount('123');
store.selectAccount('789'); store.selectAccount('789');
}); });
it('unselects previous selected accounts', () => { it('unselects previous selected accounts', () => {
expect(store.accounts.find((account) => account.address === '123').checked).to.be.false; expect(store.accounts.find((account) => account.address === '123').checked).to.be.true;
}); });
it('selects previous unselected accounts', () => { it('selects previous unselected accounts', () => {
expect(store.accounts.find((account) => account.address === '789').checked).to.be.true; expect(store.accounts.find((account) => account.address === '789').checked).to.be.false;
});
it('sets a new default when default was unselected', () => {
store.selectAccount('456');
expect(store.accounts.find((account) => account.address === '456').default).to.be.false;
expect(store.accounts.find((account) => account.address === '123').default).to.be.true;
});
});
describe('setDefaultAccount', () => {
beforeEach(() => {
store.setDefaultAccount('789');
});
it('unselects previous default', () => {
expect(store.accounts.find((account) => account.address === '456').default).to.be.false;
});
it('selects new default', () => {
expect(store.accounts.find((account) => account.address === '789').default).to.be.true;
}); });
}); });
}); });

View File

@ -38,6 +38,9 @@ import RefreshIcon from 'material-ui/svg-icons/action/autorenew';
import SaveIcon from 'material-ui/svg-icons/content/save'; import SaveIcon from 'material-ui/svg-icons/content/save';
import SendIcon from 'material-ui/svg-icons/content/send'; import SendIcon from 'material-ui/svg-icons/content/send';
import SnoozeIcon from 'material-ui/svg-icons/av/snooze'; import SnoozeIcon from 'material-ui/svg-icons/av/snooze';
import StarCircleIcon from 'material-ui/svg-icons/action/stars';
import StarIcon from 'material-ui/svg-icons/toggle/star';
import StarOutlineIcon from 'material-ui/svg-icons/toggle/star-border';
import VerifyIcon from 'material-ui/svg-icons/action/verified-user'; import VerifyIcon from 'material-ui/svg-icons/action/verified-user';
import VisibleIcon from 'material-ui/svg-icons/image/remove-red-eye'; import VisibleIcon from 'material-ui/svg-icons/image/remove-red-eye';
import VpnIcon from 'material-ui/svg-icons/notification/vpn-lock'; import VpnIcon from 'material-ui/svg-icons/notification/vpn-lock';
@ -67,6 +70,9 @@ export {
SaveIcon, SaveIcon,
SendIcon, SendIcon,
SnoozeIcon, SnoozeIcon,
StarIcon,
StarCircleIcon,
StarOutlineIcon,
VerifyIcon, VerifyIcon,
VisibleIcon, VisibleIcon,
VpnIcon VpnIcon

View File

@ -33,23 +33,13 @@ $popoverTop: 20vh;
$popoverZ: 3600; $popoverZ: 3600;
.backOverlay { .backOverlay {
background-color: rgba(255, 255, 255, 0.25); background-color: rgba(255, 255, 255, 0.35);
opacity: 0;
position: fixed; position: fixed;
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
transform-origin: 100% 0; z-index: $modalBackZ;
transition-duration: 0.25s;
transition-property: opacity, z-index;
transition-timing-function: ease-out;
z-index: -10;
&.expanded {
opacity: 1;
z-index: $modalBackZ;
}
} }
.parityBackground { .parityBackground {
@ -59,21 +49,14 @@ $popoverZ: 3600;
left: 0; left: 0;
right: 0; right: 0;
opacity: 0.25; opacity: 0.25;
z-index: -1;
} }
.overlay { .overlay {
background-color: rgba(0, 0, 0, 1); background-color: rgba(0, 0, 0, 1);
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
opacity: 0;
padding: 1.5em; padding: 1.5em;
position: fixed; position: fixed;
transform-origin: 100% 0;
transition-duration: 0.25s;
transition-property: opacity, z-index;
transition-timing-function: ease-out;
z-index: -10;
* { * {
min-width: 0; min-width: 0;
@ -84,6 +67,7 @@ $popoverZ: 3600;
left: $modalLeft; left: $modalLeft;
right: $modalRight; right: $modalRight;
top: $modalTop; top: $modalTop;
z-index: $modalZ;
} }
&.popover { &.popover {
@ -91,18 +75,7 @@ $popoverZ: 3600;
top: $popoverTop; top: $popoverTop;
height: calc(100vh - $popoverTop - $popoverBottom); height: calc(100vh - $popoverTop - $popoverBottom);
width: calc(100vw - $popoverLeft - $popoverRight); width: calc(100vw - $popoverLeft - $popoverRight);
} z-index: $popoverZ;
&.expanded {
opacity: 1;
&.popover {
z-index: $popoverZ;
}
&.modal {
z-index: $modalZ;
}
} }
} }
@ -111,9 +84,6 @@ $popoverZ: 3600;
position: absolute; position: absolute;
right: 1rem; right: 1rem;
top: 0.5rem; top: 0.5rem;
transition-duration: 0.25s;
transition-property: opacity;
transition-timing-function: ease-out;
z-index: 100; z-index: 100;
&, * { &, * {

View File

@ -35,40 +35,11 @@ export default class Portal extends Component {
onKeyDown: PropTypes.func onKeyDown: PropTypes.func
}; };
state = {
expanded: false
}
componentWillReceiveProps (nextProps) {
if (this.props.open !== nextProps.open) {
const opening = nextProps.open;
const closing = !opening;
if (opening) {
return this.setState({ expanded: true });
}
if (closing) {
return this.setState({ expanded: false });
}
}
}
render () { render () {
const { children, className, isChildModal } = this.props; const { children, className, isChildModal, open } = this.props;
const { expanded } = this.state;
const backClasses = [ styles.backOverlay ];
const classes = [
styles.overlay,
isChildModal
? styles.popover
: styles.modal,
className
];
if (expanded) { if (!open) {
classes.push(styles.expanded); return null;
backClasses.push(styles.expanded);
} }
return ( return (
@ -77,17 +48,33 @@ export default class Portal extends Component {
onClose={ this.handleClose } onClose={ this.handleClose }
> >
<div <div
className={ backClasses.join(' ') } className={ styles.backOverlay }
onClick={ this.handleClose } onClick={ this.handleClose }
> >
<div <div
className={ classes.join(' ') } className={
[
styles.overlay,
isChildModal
? styles.popover
: styles.modal,
className
].join(' ')
}
onClick={ this.stopEvent } onClick={ this.stopEvent }
onKeyDown={ this.handleKeyDown } onKeyDown={ this.handleKeyDown }
> >
<EventListener
target='window'
onKeyUp={ this.handleKeyUp }
/>
<ParityBackground className={ styles.parityBackground } /> <ParityBackground className={ styles.parityBackground } />
{ this.renderBindings() } <div
{ this.renderCloseIcon() } className={ styles.closeIcon }
onClick={ this.handleClose }
>
<CloseIcon />
</div>
{ children } { children }
</div> </div>
</div> </div>
@ -95,38 +82,6 @@ export default class Portal extends Component {
); );
} }
renderBindings () {
const { expanded } = this.state;
if (!expanded) {
return null;
}
return (
<EventListener
target='window'
onKeyUp={ this.handleKeyUp }
/>
);
}
renderCloseIcon () {
const { expanded } = this.state;
if (!expanded) {
return null;
}
return (
<div
className={ styles.closeIcon }
onClick={ this.handleClose }
>
<CloseIcon />
</div>
);
}
stopEvent = (event) => { stopEvent = (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();

View File

@ -39,8 +39,8 @@
.item { .item {
box-sizing: border-box; box-sizing: border-box;
cursor: pointer; cursor: pointer;
display: flex;
flex: 0 1 33.33%; flex: 0 1 33.33%;
height: 100%;
opacity: 0.75; opacity: 0.75;
padding: 0.25em; padding: 0.25em;
transition: all 0.75s cubic-bezier(0.23, 1, 0.32, 1); transition: all 0.75s cubic-bezier(0.23, 1, 0.32, 1);
@ -56,7 +56,6 @@
} }
&:hover { &:hover {
flex: 0 0 50%;
opacity: 1; opacity: 1;
z-index: 100; z-index: 100;
@ -67,6 +66,13 @@
& [data-hover="show"] { & [data-hover="show"] {
} }
} }
&.stretch-on:hover {
flex: 0 0 50%;
}
&.stretch-off:hover {
}
} }
} }
} }

View File

@ -31,8 +31,13 @@ export default class SectionList extends Component {
className: PropTypes.string, className: PropTypes.string,
items: arrayOrObjectProptype().isRequired, items: arrayOrObjectProptype().isRequired,
renderItem: PropTypes.func.isRequired, renderItem: PropTypes.func.isRequired,
noStretch: PropTypes.bool,
overlay: nodeOrStringProptype() overlay: nodeOrStringProptype()
} };
static defaultProps = {
noStretch: false
};
render () { render () {
const { className, items } = this.props; const { className, items } = this.props;
@ -75,7 +80,7 @@ export default class SectionList extends Component {
} }
renderItem = (item, index) => { renderItem = (item, index) => {
const { renderItem } = this.props; const { noStretch, renderItem } = this.props;
// NOTE: Any children that is to be showed or hidden (depending on hover state) // NOTE: Any children that is to be showed or hidden (depending on hover state)
// should have the data-hover="show|hide" attributes. For the current implementation // should have the data-hover="show|hide" attributes. For the current implementation
@ -85,7 +90,10 @@ export default class SectionList extends Component {
// CSS-only solution to let the browser do all the work via selectors. // CSS-only solution to let the browser do all the work via selectors.
return ( return (
<div <div
className={ styles.item } className={ [
styles.item,
styles[`stretch-${noStretch ? 'off' : 'on'}`]
].join(' ') }
key={ `item_${index}` } key={ `item_${index}` }
> >
{ renderItem(item, index) } { renderItem(item, index) }