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/>.
*/
.modal {
flex-direction: column;
}
.container {
margin-top: 1.5em;
overflow-y: auto;
}
.item {
.info {
display: inline-block;
display: flex;
flex: 1;
position: relative;
.address {
opacity: 0.75;
}
.description {
margin-top: 0.5em;
opacity: 0.75;
}
.name {
margin: 0.5em 0;
text-transform: uppercase;
}
.overlay {
position: absolute;
right: 0.5em;
top: 0.5em;
}
}
.selected, .unselected {
margin-bottom: 0.25em;
&:focus {
outline: none;
}
}
.unselected {
background: rgba(0, 0, 0, 0.4) !important;
}
.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 {
}
.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
// 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 React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { Button, IdentityIcon, Modal } from '~/ui';
import { DoneIcon } from '~/ui/Icons';
import { AccountCard, ContainerTitle, Portal, SectionList } from '~/ui';
import { CheckIcon, StarIcon, StarOutlineIcon } from '~/ui/Icons';
import styles from './dappPermissions.css';
@ -39,79 +37,81 @@ export default class DappPermissions extends Component {
}
return (
<Modal
actions={ [
<Button
icon={ <DoneIcon /> }
key='done'
label={
<FormattedMessage
id='dapps.permissions.button.done'
defaultMessage='Done'
/>
}
onClick={ store.closeModal }
/>
] }
compact
<Portal
className={ styles.modal }
onClose={ store.closeModal }
open
>
<ContainerTitle
title={
<FormattedMessage
id='dapps.permissions.label'
defaultMessage='visible dapp accounts'
/>
}
visible
>
<List>
{ this.renderListItems() }
</List>
</Modal>
/>
<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;
return store.accounts.map((account) => {
const onCheck = () => {
const onMakeDefault = () => {
store.setDefaultAccount(account.address);
};
const onSelect = () => {
store.selectAccount(account.address);
};
// TODO: Once new modal & account selection is in, this should be updated
// to conform to the new (as of this code WIP) look & feel for selection.
// For now in the current/old style, not as pretty but consistent.
let className;
if (account.checked) {
className = account.default
? `${styles.selected} ${styles.default}`
: styles.selected;
} else {
className = styles.unselected;
}
return (
<ListItem
className={
account.checked
? styles.selected
: styles.unselected
}
key={ account.address }
leftCheckbox={
<Checkbox
checked={ account.checked }
onCheck={ onCheck }
/>
}
primaryText={
<div className={ styles.item }>
<IdentityIcon address={ account.address } />
<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>
}
<AccountCard
account={ account }
className={ className }
onClick={ onSelect }
/>
<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', () => {
expect(
renderShallow({ modalOpen: false }).find('Connect(Modal)')
renderShallow({ modalOpen: false }).find('Portal')
).to.have.length(0);
});
it('does render the modal with modalOpen = true', () => {
expect(
renderShallow({ modalOpen: true, accounts: [] }).find('Connect(Modal)')
renderShallow({ modalOpen: true, accounts: [] }).find('Portal')
).to.have.length(1);
});
});

View File

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

View File

@ -23,13 +23,12 @@ const ACCOUNTS = {
'456': { address: '456', name: '456', meta: { description: '456' } },
'789': { address: '789', name: '789', meta: { description: '789' } }
};
const WHITELIST = ['123', '456'];
const WHITELIST = ['456', '789'];
describe('modals/DappPermissions/store', () => {
let api;
let store;
let api;
let store;
beforeEach(() => {
function create () {
api = {
parity: {
getNewDappsWhitelist: sinon.stub().resolves(WHITELIST),
@ -38,6 +37,11 @@ describe('modals/DappPermissions/store', () => {
};
store = new Store(api);
}
describe('modals/DappPermissions/store', () => {
beforeEach(() => {
create();
});
describe('constructor', () => {
@ -51,49 +55,71 @@ describe('modals/DappPermissions/store', () => {
});
describe('@actions', () => {
describe('openModal', () => {
beforeEach(() => {
store.openModal(ACCOUNTS);
});
describe('openModal', () => {
it('sets the modalOpen status', () => {
expect(store.modalOpen).to.be.true;
});
it('sets accounts with checked interfaces', () => {
expect(store.accounts.peek()).to.deep.equal([
{ address: '123', name: '123', description: '123', checked: true },
{ address: '456', name: '456', description: '456', checked: true },
{ address: '789', name: '789', description: '789', checked: false }
{ address: '123', name: '123', description: '123', default: false, checked: false },
{ address: '456', name: '456', description: '456', default: true, checked: true },
{ address: '789', name: '789', description: '789', default: false, checked: true }
]);
});
});
describe('closeModal', () => {
beforeEach(() => {
store.openModal(ACCOUNTS);
store.selectAccount('789');
store.setDefaultAccount('789');
store.closeModal();
});
it('calls setNewDappsWhitelist', () => {
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', () => {
beforeEach(() => {
store.openModal(ACCOUNTS);
store.selectAccount('123');
store.selectAccount('789');
});
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', () => {
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 SendIcon from 'material-ui/svg-icons/content/send';
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 VisibleIcon from 'material-ui/svg-icons/image/remove-red-eye';
import VpnIcon from 'material-ui/svg-icons/notification/vpn-lock';
@ -67,6 +70,9 @@ export {
SaveIcon,
SendIcon,
SnoozeIcon,
StarIcon,
StarCircleIcon,
StarOutlineIcon,
VerifyIcon,
VisibleIcon,
VpnIcon

View File

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

View File

@ -35,40 +35,11 @@ export default class Portal extends Component {
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 () {
const { children, className, isChildModal } = this.props;
const { expanded } = this.state;
const backClasses = [ styles.backOverlay ];
const classes = [
styles.overlay,
isChildModal
? styles.popover
: styles.modal,
className
];
const { children, className, isChildModal, open } = this.props;
if (expanded) {
classes.push(styles.expanded);
backClasses.push(styles.expanded);
if (!open) {
return null;
}
return (
@ -77,53 +48,37 @@ export default class Portal extends Component {
onClose={ this.handleClose }
>
<div
className={ backClasses.join(' ') }
className={ styles.backOverlay }
onClick={ this.handleClose }
>
<div
className={ classes.join(' ') }
className={
[
styles.overlay,
isChildModal
? styles.popover
: styles.modal,
className
].join(' ')
}
onClick={ this.stopEvent }
onKeyDown={ this.handleKeyDown }
>
<ParityBackground className={ styles.parityBackground } />
{ this.renderBindings() }
{ this.renderCloseIcon() }
{ children }
</div>
</div>
</ReactPortal>
);
}
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 (
<ParityBackground className={ styles.parityBackground } />
<div
className={ styles.closeIcon }
onClick={ this.handleClose }
>
<CloseIcon />
</div>
{ children }
</div>
</div>
</ReactPortal>
);
}

View File

@ -39,8 +39,8 @@
.item {
box-sizing: border-box;
cursor: pointer;
display: flex;
flex: 0 1 33.33%;
height: 100%;
opacity: 0.75;
padding: 0.25em;
transition: all 0.75s cubic-bezier(0.23, 1, 0.32, 1);
@ -56,7 +56,6 @@
}
&:hover {
flex: 0 0 50%;
opacity: 1;
z-index: 100;
@ -67,6 +66,13 @@
& [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,
items: arrayOrObjectProptype().isRequired,
renderItem: PropTypes.func.isRequired,
noStretch: PropTypes.bool,
overlay: nodeOrStringProptype()
}
};
static defaultProps = {
noStretch: false
};
render () {
const { className, items } = this.props;
@ -75,7 +80,7 @@ export default class SectionList extends Component {
}
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)
// 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.
return (
<div
className={ styles.item }
className={ [
styles.item,
styles[`stretch-${noStretch ? 'off' : 'on'}`]
].join(' ') }
key={ `item_${index}` }
>
{ renderItem(item, index) }