Add SelectionList component to DRY up (#4639)

* Added SelectionList component for selections

* Use SelectionList in DappPermisions

* AddDapps uses SelectionList

* Fix AccountCard to consistent height

* Convert Signer defaults to SelectionList

* Subtle selection border

* Convert VaultAccounts to SelectionList

* Add tests for SectionList component

* Apply scroll fixes from lates commit in #4621

* Remove unneeded logs

* Remove extra div, fixing ParityBar overflow
This commit is contained in:
Jaco Greeff 2017-02-24 14:37:56 +01:00 committed by GitHub
parent 5817cfdf41
commit a6ed3dc5dc
17 changed files with 378 additions and 268 deletions

View File

@ -19,10 +19,6 @@
flex-direction: column;
}
.container {
overflow-y: auto;
}
.description {
margin-top: .5em !important;
}
@ -49,26 +45,3 @@
opacity: 0.75;
}
}
.selectIcon {
position: absolute;
right: 0.5em;
top: 0.5em;
}
.selected,
.unselected {
position: relative;
}
.unselected {
background: rgba(0, 0, 0, 0.4) !important;
.selectIcon {
opacity: 0.15;
}
}
.selected {
background: rgba(255, 255, 255, 0.15) !important;
}

View File

@ -18,8 +18,7 @@ import { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { DappCard, Portal, SectionList } from '~/ui';
import { CheckIcon } from '~/ui/Icons';
import { DappCard, Portal, SelectionList } from '~/ui';
import styles from './addDapps.css';
@ -48,45 +47,43 @@ export default class AddDapps extends Component {
/>
}
>
<div className={ styles.container }>
<div className={ styles.warning } />
{
this.renderList(store.sortedLocal, store.displayApps,
<FormattedMessage
id='dapps.add.local.label'
defaultMessage='Applications locally available'
/>,
<FormattedMessage
id='dapps.add.local.desc'
defaultMessage='All applications installed locally on the machine by the user for access by the Parity client.'
/>
)
}
{
this.renderList(store.sortedBuiltin, store.displayApps,
<FormattedMessage
id='dapps.add.builtin.label'
defaultMessage='Applications bundled with Parity'
/>,
<FormattedMessage
id='dapps.add.builtin.desc'
defaultMessage='Experimental applications developed by the Parity team to show off dapp capabilities, integration, experimental features and to control certain network-wide client behaviour.'
/>
)
}
{
this.renderList(store.sortedNetwork, store.displayApps,
<FormattedMessage
id='dapps.add.network.label'
defaultMessage='Applications on the global network'
/>,
<FormattedMessage
id='dapps.add.network.desc'
defaultMessage='These applications are not affiliated with Parity nor are they published by Parity. Each remain under the control of their respective authors. Please ensure that you understand the goals for each application before interacting.'
/>
)
}
</div>
<div className={ styles.warning } />
{
this.renderList(store.sortedLocal, store.displayApps,
<FormattedMessage
id='dapps.add.local.label'
defaultMessage='Applications locally available'
/>,
<FormattedMessage
id='dapps.add.local.desc'
defaultMessage='All applications installed locally on the machine by the user for access by the Parity client.'
/>
)
}
{
this.renderList(store.sortedBuiltin, store.displayApps,
<FormattedMessage
id='dapps.add.builtin.label'
defaultMessage='Applications bundled with Parity'
/>,
<FormattedMessage
id='dapps.add.builtin.desc'
defaultMessage='Experimental applications developed by the Parity team to show off dapp capabilities, integration, experimental features and to control certain network-wide client behaviour.'
/>
)
}
{
this.renderList(store.sortedNetwork, store.displayApps,
<FormattedMessage
id='dapps.add.network.label'
defaultMessage='Applications on the global network'
/>,
<FormattedMessage
id='dapps.add.network.desc'
defaultMessage='These applications are not affiliated with Parity nor are they published by Parity. Each remain under the control of their respective authors. Please ensure that you understand the goals for each application before interacting.'
/>
)
}
</Portal>
);
}
@ -102,9 +99,11 @@ export default class AddDapps extends Component {
<div className={ styles.header }>{ header }</div>
<div className={ styles.byline }>{ byline }</div>
</div>
<SectionList
<SelectionList
isChecked={ this.isVisible }
items={ items }
noStretch
onSelectClick={ this.onSelect }
renderItem={ this.renderApp }
/>
</div>
@ -112,30 +111,27 @@ export default class AddDapps extends Component {
}
renderApp = (app) => {
const { store } = this.props;
const isVisible = store.displayApps[app.id].visible;
const onClick = () => {
if (isVisible) {
store.hideApp(app.id);
} else {
store.showApp(app.id);
}
};
return (
<DappCard
app={ app }
className={
isVisible
? styles.selected
: styles.unselected
}
key={ app.id }
onClick={ onClick }
>
<CheckIcon className={ styles.selectIcon } />
</DappCard>
/>
);
}
isVisible = (app) => {
const { store } = this.props;
return store.displayApps[app.id].visible;
}
onSelect = (app) => {
const { store } = this.props;
if (this.isVisible(app)) {
store.hideApp(app.id);
} else {
store.showApp(app.id);
}
}
}

View File

@ -15,51 +15,6 @@
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.container {
overflow-y: auto;
}
.item {
display: flex;
flex: 1;
position: relative;
.overlay {
position: absolute;
right: 0.5em;
top: 0.5em;
}
}
.selected,
.unselected {
margin-bottom: 0.25em;
width: 100%;
&:focus {
outline: none;
}
}
.unselected {
background: rgba(0, 0, 0, 0.4) !important;
}
.selected {
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;

View File

@ -19,8 +19,8 @@ import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { AccountCard, Portal, SectionList } from '~/ui';
import { CheckIcon, StarIcon, StarOutlineIcon } from '~/ui/Icons';
import { AccountCard, Portal, SelectionList } from '~/ui';
import { CheckIcon, StarIcon } from '~/ui/Icons';
import styles from './dappPermissions.css';
@ -61,60 +61,34 @@ class DappPermissions extends Component {
/>
}
>
<div className={ styles.container }>
<SectionList
items={ permissionStore.accounts }
noStretch
renderItem={ this.renderAccount }
/>
</div>
<SelectionList
items={ permissionStore.accounts }
noStretch
onDefaultClick={ this.onMakeDefault }
onSelectClick={ this.onSelect }
renderItem={ this.renderAccount }
/>
</Portal>
);
}
onMakeDefault = (account) => {
this.props.permissionStore.setDefaultAccount(account.address);
}
onSelect = (account) => {
this.props.permissionStore.selectAccount(account.address);
}
renderAccount = (account) => {
const { balances, permissionStore } = this.props;
const { balances } = this.props;
const balance = balances[account.address];
const onMakeDefault = () => {
permissionStore.setDefaultAccount(account.address);
};
const onSelect = () => {
permissionStore.selectAccount(account.address);
};
let className;
if (account.checked) {
className = account.default
? `${styles.selected} ${styles.default}`
: styles.selected;
} else {
className = styles.unselected;
}
return (
<div className={ styles.item }>
<AccountCard
account={ account }
balance={ balance }
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>
<AccountCard
account={ account }
balance={ balance }
/>
);
}
}

View File

@ -22,11 +22,9 @@ import { bindActionCreators } from 'redux';
import { newError } from '~/redux/actions';
import { personalAccountsInfo } from '~/redux/providers/personalActions';
import { AccountCard, Button, Portal, SectionList } from '~/ui';
import { AccountCard, Button, Portal, SelectionList } from '~/ui';
import { CancelIcon, CheckIcon } from '~/ui/Icons';
import styles from './vaultAccounts.css';
@observer
class VaultAccounts extends Component {
static contextTypes = {
@ -92,55 +90,47 @@ class VaultAccounts extends Component {
/>
}
>
<SectionList
items={ vaultAccounts }
noStretch
renderItem={ this.renderAccount }
selectedAccounts={ selectedAccounts }
/>
{ this.renderList(vaultAccounts, selectedAccounts) }
</Portal>
);
}
// TODO: There are a lot of similarities between the dapp permissions selector
// (although that has defaults) and this one. A genrerix multi-select component
// would be applicable going forward. (Originals passed in, new selections back)
renderList (vaultAccounts) {
return (
<SelectionList
isChecked={ this.isSelected }
items={ vaultAccounts }
noStretch
onSelectClick={ this.onSelect }
renderItem={ this.renderAccount }
/>
);
}
renderAccount = (account) => {
const { balances } = this.props;
const { vaultName, selectedAccounts } = this.props.vaultStore;
const balance = balances[account.address];
const isInVault = account.meta.vault === vaultName;
const isSelected = isInVault
? !selectedAccounts[account.address]
: selectedAccounts[account.address];
const onSelect = () => {
this.props.vaultStore.toggleSelectedAccount(account.address);
};
return (
<div className={ styles.item }>
<AccountCard
account={ account }
balance={ balance }
className={
isSelected
? styles.selected
: styles.unselected
}
onClick={ onSelect }
/>
<div className={ styles.overlay }>
{
isSelected
? <CheckIcon onClick={ onSelect } />
: <CheckIcon className={ styles.iconDisabled } onClick={ onSelect } />
}
</div>
</div>
<AccountCard
account={ account }
balance={ balance }
/>
);
}
isSelected = (account) => {
const { vaultName, selectedAccounts } = this.props.vaultStore;
return account.meta.vault === vaultName
? !selectedAccounts[account.address]
: selectedAccounts[account.address];
}
onSelect = (account) => {
this.props.vaultStore.toggleSelectedAccount(account.address);
}
onClose = () => {
this.props.vaultStore.closeAccountsModal();
}

View File

@ -130,11 +130,11 @@ describe('modals/VaultAccounts', () => {
});
describe('components', () => {
describe('SectionList', () => {
describe('SelectionList', () => {
let sectionList;
beforeEach(() => {
sectionList = component.find('SectionList');
sectionList = component.find('SelectionList');
});
it('has the filtered accounts', () => {

View File

@ -20,7 +20,7 @@
background-color: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: row;
margin: 0.5em 0;
height: 100%;
overflow: hidden;
transition: transform ease-out 0.1s;
transform: scale(1);

View File

@ -39,7 +39,7 @@ export default class AccountCard extends Component {
};
render () {
const { account, balance, className } = this.props;
const { account, balance, className, onFocus } = this.props;
const { copied } = this.state;
const { address, description, meta = {}, name } = account;
const { tags = [] } = meta;
@ -49,14 +49,18 @@ export default class AccountCard extends Component {
classes.push(styles.copied);
}
const props = onFocus
? { tabIndex: 0 }
: {};
return (
<div
key={ address }
tabIndex={ 0 }
className={ classes.join(' ') }
onClick={ this.onClick }
onFocus={ this.onFocus }
onKeyDown={ this.handleKeyDown }
{ ...props }
>
<div className={ styles.mainContainer }>
<div className={ styles.infoContainer }>

View File

@ -146,4 +146,8 @@
margin: 1em 0;
}
.account {
margin: 0.5em 0;
}
}

View File

@ -346,6 +346,7 @@ class AddressSelect extends Component {
<AccountCard
account={ account }
balance={ balance }
className={ styles.account }
key={ `account_${index}` }
onClick={ this.handleClick }
onFocus={ this.focusItem }

View File

@ -0,0 +1,17 @@
// 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/>.
export default from './selectionList';

View File

@ -15,16 +15,25 @@
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
/* TODO: These overlap with DappPermissions now, make DRY */
/* (selection component or just styles?) */
.iconDisabled {
opacity: 0.15;
}
.item {
display: flex;
flex: 1;
height: 100%;
position: relative;
width: 100%;
&:hover {
box-shadow: inset 0 0 0 2px rgb(255, 255, 255);
}
.content {
height: 100%;
width: 100%;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
.overlay {
position: absolute;
@ -33,16 +42,16 @@
}
}
.selected,
.unselected {
margin-bottom: 0.25em;
width: 100%;
&:focus {
outline: none;
}
}
.selected {
background: rgba(255, 255, 255, 0.15) !important;
box-shadow: inset 0 0 0 2px rgb(255, 255, 255);
filter: none;
}
.unselected {
filter: grayscale(100%);
opacity: 0.5;
}
.iconDisabled {
opacity: 0.15;
}

View File

@ -0,0 +1,93 @@
// 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 React, { Component, PropTypes } from 'react';
import { CheckIcon, StarIcon, StarOutlineIcon } from '~/ui/Icons';
import SectionList from '~/ui/SectionList';
import { arrayOrObjectProptype } from '~/util/proptypes';
import styles from './selectionList.css';
export default class SelectionList extends Component {
static propTypes = {
isChecked: PropTypes.func,
items: arrayOrObjectProptype().isRequired,
noStretch: PropTypes.bool,
onDefaultClick: PropTypes.func,
onSelectClick: PropTypes.func.isRequired,
renderItem: PropTypes.func.isRequired
}
render () {
const { items, noStretch } = this.props;
return (
<SectionList
items={ items }
noStretch={ noStretch }
renderItem={ this.renderItem }
/>
);
}
renderItem = (item, index) => {
const { isChecked, onDefaultClick, onSelectClick, renderItem } = this.props;
const isSelected = isChecked
? isChecked(item)
: item.checked;
const makeDefault = () => {
onDefaultClick(item);
return false;
};
const selectItem = () => {
onSelectClick(item);
return false;
};
let defaultIcon = null;
if (onDefaultClick) {
defaultIcon = isSelected && item.default
? <StarIcon />
: <StarOutlineIcon className={ styles.iconDisabled } onClick={ makeDefault } />;
}
const classes = isSelected
? [styles.item, styles.selected]
: [styles.item, styles.unselected];
return (
<div className={ classes.join(' ') }>
<div
className={ styles.content }
onClick={ selectItem }
>
{ renderItem(item, index) }
</div>
<div className={ styles.overlay }>
{ defaultIcon }
{
isSelected
? <CheckIcon onClick={ selectItem } />
: <CheckIcon className={ styles.iconDisabled } onClick={ selectItem } />
}
</div>
</div>
);
}
}

View File

@ -0,0 +1,100 @@
// 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 { shallow } from 'enzyme';
import React from 'react';
import sinon from 'sinon';
import SelectionList from './';
const ITEMS = ['A', 'B', 'C'];
let component;
let instance;
let renderItem;
let onDefaultClick;
let onSelectClick;
function render (props = {}) {
renderItem = sinon.stub();
onDefaultClick = sinon.stub();
onSelectClick = sinon.stub();
component = shallow(
<SelectionList
items={ ITEMS }
noStretch='testNoStretch'
onDefaultClick={ onDefaultClick }
onSelectClick={ onSelectClick }
renderItem={ renderItem }
/>
);
instance = component.instance();
return component;
}
describe('ui/SelectionList', () => {
beforeEach(() => {
render();
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
describe('SectionList', () => {
let section;
beforeEach(() => {
section = component.find('SectionList');
});
it('renders the SectionList', () => {
expect(section.get(0)).to.be.ok;
});
it('passes the items through', () => {
expect(section.props().items).to.deep.equal(ITEMS);
});
it('passes internal render method', () => {
expect(section.props().renderItem).to.equal(instance.renderItem);
});
it('passes noStretch prop through', () => {
expect(section.props().noStretch).to.equal('testNoStretch');
});
});
describe('instance methods', () => {
describe('renderItem', () => {
let result;
beforeEach(() => {
result = instance.renderItem('testItem', 'testIndex');
});
it('renders', () => {
expect(result).to.be.ok;
});
it('calls into parent renderItem', () => {
expect(renderItem).to.have.been.calledWith('testItem', 'testIndex');
});
});
});
});

View File

@ -46,6 +46,7 @@ export ParityBackground from './ParityBackground';
export Portal from './Portal';
export QrCode from './QrCode';
export SectionList from './SectionList';
export SelectionList from './SelectionList';
export ShortenedHash from './ShortenedHash';
export SignerIcon from './SignerIcon';
export Tags from './Tags';

View File

@ -37,7 +37,7 @@ export default class AccountStore {
@action setDefaultAccount = (defaultAccount) => {
transaction(() => {
this.accounts = this.accounts.map((account) => {
account.default = account.address === defaultAccount;
account.checked = account.address === defaultAccount;
return account;
});
@ -90,7 +90,7 @@ export default class AccountStore {
const account = accounts[address];
account.address = address;
account.default = address === this.defaultAccount;
account.checked = address === this.defaultAccount;
return account;
})

View File

@ -24,7 +24,7 @@ import { connect } from 'react-redux';
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 { AccountCard, Badge, Button, ContainerTitle, IdentityIcon, ParityBackground, SelectionList } from '~/ui';
import { CancelIcon, FingerprintIcon } from '~/ui/Icons';
import DappsStore from '~/views/Dapps/dappsStore';
import { Embedded as Signer } from '~/views/Signer';
@ -328,10 +328,11 @@ class ParityBar extends Component {
{
displayType === DISPLAY_ACCOUNTS
? (
<SectionList
<SelectionList
className={ styles.accountsSection }
items={ this.accountStore.accounts }
noStretch
onSelectClick={ this.onMakeDefault }
renderItem={ this.renderAccount }
/>
)
@ -344,31 +345,23 @@ class ParityBar extends Component {
);
}
onMakeDefault = (account) => {
this.toggleAccountsDisplay();
return this.accountStore
.makeDefaultAccount(account.address)
.then(() => this.accountStore.loadAccounts());
}
renderAccount = (account) => {
const { balances } = this.props;
const balance = balances[account.address];
const makeDefaultAccount = () => {
this.toggleAccountsDisplay();
return this.accountStore
.makeDefaultAccount(account.address)
.then(() => this.accountStore.loadAccounts());
};
return (
<div
className={ styles.account }
onClick={ makeDefaultAccount }
>
<AccountCard
account={ account }
balance={ balance }
className={
account.default
? styles.selected
: styles.unselected
}
/>
</div>
<AccountCard
account={ account }
balance={ balance }
/>
);
}