Add Email and Registry lookups to Address Selector (#3992)

* Move AccountSelect values to MobX store

* Mail registry + small fixes

* Add Registry to AddressSelect lookups + Nav fixes

* Fix linting

* PR Grumbles

* Fix tests and propTypes
This commit is contained in:
Nicolas Gotchac 2016-12-29 19:47:53 +01:00 committed by Gav Wood
parent 486f7ae684
commit 9814251a28
7 changed files with 327 additions and 112 deletions

View File

@ -22,7 +22,7 @@ import { ContextProvider, muiTheme } from '~/ui';
import DetailsStep from './';
import { STORE, CONTRACT } from '../executeContract.test.js';
import { createApi, STORE, CONTRACT } from '../executeContract.test.js';
let component;
let onAmountChange;
@ -41,7 +41,7 @@ function render (props) {
onValueChange = sinon.stub();
component = mount(
<ContextProvider api={ {} } muiTheme={ muiTheme } store={ STORE }>
<ContextProvider api={ createApi() } muiTheme={ muiTheme } store={ STORE }>
<DetailsStep
{ ...props }
contract={ CONTRACT }

View File

@ -20,7 +20,7 @@ import sinon from 'sinon';
import ExecuteContract from './';
import { CONTRACT, STORE } from './executeContract.test.js';
import { createApi, CONTRACT, STORE } from './executeContract.test.js';
let component;
let onClose;
@ -36,7 +36,7 @@ function render (props) {
contract={ CONTRACT }
onClose={ onClose }
onFromAddressChange={ onFromAddressChange } />,
{ context: { api: {}, store: STORE } }
{ context: { api: createApi(), store: STORE } }
).find('ExecuteContract').shallow();
return component;

View File

@ -64,7 +64,19 @@ const STORE = {
}
};
function createApi (result = true) {
return {
parity: {
registryAddress: sinon.stub().resolves('0x0000000000000000000000000000000000000000')
},
util: {
sha3: sinon.stub().resolves('0x0000000000000000000000000000000000000000')
}
};
}
export {
createApi,
CONTRACT,
STORE
};

View File

@ -52,6 +52,11 @@
}
}
.description {
font-size: 0.75em;
color: rgba(255, 255, 255, 0.5);
}
.accountInfo {
flex: 1;

View File

@ -43,7 +43,7 @@ export default class AccountCard extends Component {
const { account } = this.props;
const { copied } = this.state;
const { address, name, meta = {} } = account;
const { address, name, description, meta = {} } = account;
const displayName = (name && name.toUpperCase()) || address;
const { tags = [] } = meta;
@ -70,6 +70,7 @@ export default class AccountCard extends Component {
</div>
{ this.renderTags(tags, address) }
{ this.renderDescription(description) }
{ this.renderAddress(displayName, address) }
{ this.renderBalance(address) }
</div>
@ -77,6 +78,18 @@ export default class AccountCard extends Component {
);
}
renderDescription (description) {
if (!description) {
return null;
}
return (
<div className={ styles.description }>
<span>{ description }</span>
</div>
);
}
renderAddress (name, address) {
if (name === address) {
return null;

View File

@ -19,6 +19,7 @@ import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import keycode, { codes } from 'keycode';
import { FormattedMessage } from 'react-intl';
import { observer } from 'mobx-react';
import TextFieldUnderline from 'material-ui/TextField/TextFieldUnderline';
@ -26,7 +27,9 @@ import AccountCard from '~/ui/AccountCard';
import InputAddress from '~/ui/Form/InputAddress';
import Portal from '~/ui/Portal';
import { validateAddress } from '~/util/validation';
import { nodeOrStringProptype } from '~/util/proptypes';
import AddressSelectStore from './addressSelectStore';
import styles from './addressSelect.css';
const BOTTOM_BORDER_STYLE = { borderBottom: 'solid 3px' };
@ -34,8 +37,10 @@ const BOTTOM_BORDER_STYLE = { borderBottom: 'solid 3px' };
// Current Form ID
let currentId = 1;
@observer
class AddressSelect extends Component {
static contextTypes = {
api: PropTypes.object.isRequired,
muiTheme: PropTypes.object.isRequired
};
@ -55,24 +60,25 @@ class AddressSelect extends Component {
// Optional props
allowInput: PropTypes.bool,
disabled: PropTypes.bool,
error: PropTypes.string,
hint: PropTypes.string,
label: PropTypes.string,
value: PropTypes.string
error: nodeOrStringProptype(),
hint: nodeOrStringProptype(),
label: nodeOrStringProptype(),
value: nodeOrStringProptype()
};
static defaultProps = {
value: ''
};
store = new AddressSelectStore(this.context.api);
state = {
expanded: false,
focused: false,
focusedCat: null,
focusedItem: null,
inputFocused: false,
inputValue: '',
values: []
inputValue: ''
};
componentWillMount () {
@ -80,7 +86,7 @@ class AddressSelect extends Component {
}
componentWillReceiveProps (nextProps) {
if (this.values && this.values.length > 0) {
if (this.store.values && this.store.values.length > 0) {
return;
}
@ -88,36 +94,7 @@ class AddressSelect extends Component {
}
setValues (props = this.props) {
const { accounts = {}, contracts = {}, contacts = {}, wallets = {} } = props;
const accountsN = Object.keys(accounts).length;
const contractsN = Object.keys(contracts).length;
const contactsN = Object.keys(contacts).length;
const walletsN = Object.keys(wallets).length;
if (accountsN + contractsN + contactsN + walletsN === 0) {
return;
}
this.values = [
{
label: 'accounts',
values: [].concat(
Object.values(wallets),
Object.values(accounts)
)
},
{
label: 'contacts',
values: Object.values(contacts)
},
{
label: 'contracts',
values: Object.values(contracts)
}
].filter((cat) => cat.values.length > 0);
this.handleChange();
this.store.setValues(props);
}
render () {
@ -216,6 +193,7 @@ class AddressSelect extends Component {
</div>
{ this.renderCurrentInput() }
{ this.renderRegistryValues() }
{ this.renderAccounts() }
</Portal>
);
@ -241,8 +219,28 @@ class AddressSelect extends Component {
);
}
renderRegistryValues () {
const { registryValues } = this.store;
if (registryValues.length === 0) {
return null;
}
const accounts = registryValues
.map((registryValue, index) => {
const account = { ...registryValue, index: `${registryValue.address}_${index}` };
return this.renderAccountCard(account);
});
return (
<div>
{ accounts }
</div>
);
}
renderAccounts () {
const { values } = this.state;
const { values } = this.store;
if (values.length === 0) {
return (
@ -257,8 +255,8 @@ class AddressSelect extends Component {
);
}
const categories = values.map((category) => {
return this.renderCategory(category.label, category.values);
const categories = values.map((category, index) => {
return this.renderCategory(category, index);
});
return (
@ -268,7 +266,8 @@ class AddressSelect extends Component {
);
}
renderCategory (name, values = []) {
renderCategory (category, index) {
const { label, key, values = [] } = category;
let content;
if (values.length === 0) {
@ -292,8 +291,8 @@ class AddressSelect extends Component {
}
return (
<div className={ styles.category } key={ name }>
<div className={ styles.title }>{ name }</div>
<div className={ styles.category } key={ `${key}_${index}` }>
<div className={ styles.title }>{ label }</div>
{ content }
</div>
);
@ -306,7 +305,7 @@ class AddressSelect extends Component {
const balance = balances[address];
const account = {
...accountsInfo[address],
address, index
..._account
};
return (
@ -325,9 +324,10 @@ class AddressSelect extends Component {
this.inputRef = refId;
}
handleCustomInput = () => {
validateCustomInput = () => {
const { allowInput } = this.props;
const { inputValue, values } = this.state;
const { inputValue } = this.store;
const { values } = this.store;
// If input is HEX and allowInput === true, send it
if (allowInput && inputValue && /^(0x)?([0-9a-f])+$/i.test(inputValue)) {
@ -335,8 +335,8 @@ class AddressSelect extends Component {
}
// If only one value, select it
if (values.length === 1 && values[0].values.length === 1) {
const value = values[0].values[0];
if (values.reduce((cur, cat) => cur + cat.values.length, 0) === 1) {
const value = values.find((cat) => cat.values.length > 0).values[0];
return this.handleClick(value.address);
}
}
@ -361,7 +361,7 @@ class AddressSelect extends Component {
case 'enter':
const index = this.state.focusedItem;
if (!index) {
return this.handleCustomInput();
return this.validateCustomInput();
}
return this.handleDOMAction(`account_${index}`, 'click');
@ -408,10 +408,11 @@ class AddressSelect extends Component {
}
handleNavigation = (direction, event) => {
const { focusedItem, focusedCat, values } = this.state;
const { focusedItem, focusedCat } = this.state;
const { values } = this.store;
// Don't do anything if no values
if (values.length === 0) {
if (values.reduce((cur, cat) => cur + cat.values.length, 0) === 0) {
return event;
}
@ -423,7 +424,12 @@ class AddressSelect extends Component {
event.preventDefault();
const nextValues = values[focusedCat || 0];
const firstCat = values.findIndex((cat) => cat.values.length > 0);
const nextCat = focusedCat && values[focusedCat].values.length > 0
? focusedCat
: firstCat;
const nextValues = values[nextCat];
const nextFocus = nextValues ? nextValues.values[0] : null;
return this.focusItem(nextFocus && nextFocus.index || 1);
}
@ -457,12 +463,21 @@ class AddressSelect extends Component {
// If right: next category
if (direction === 'right') {
nextCategory = Math.min(prevCategoryIndex + 1, values.length - 1);
const categoryShift = values
.slice(prevCategoryIndex + 1, values.length)
.findIndex((cat) => cat.values.length > 0) + 1;
nextCategory = Math.min(prevCategoryIndex + categoryShift, values.length - 1);
}
// If right: previous category
if (direction === 'left') {
nextCategory = Math.max(prevCategoryIndex - 1, 0);
const categoryShift = values
.slice(0, prevCategoryIndex)
.reverse()
.findIndex((cat) => cat.values.length > 0) + 1;
nextCategory = Math.max(prevCategoryIndex - categoryShift, 0);
}
// If left or right: try to keep the horizontal index
@ -525,43 +540,6 @@ class AddressSelect extends Component {
this.setState({ expanded: false });
}
/**
* Filter the given values based on the given
* filter
*/
filterValues = (values = [], _filter = '') => {
const filter = _filter.toLowerCase();
return values
// Remove empty accounts
.filter((a) => a)
.filter((account) => {
const address = account.address.toLowerCase();
const inAddress = address.includes(filter);
if (!account.name || inAddress) {
return inAddress;
}
const name = account.name.toLowerCase();
const inName = name.includes(filter);
const { meta = {} } = account;
if (!meta.tags || inName) {
return inName;
}
const tags = (meta.tags || []).join('');
return tags.includes(filter);
})
.sort((accA, accB) => {
const nameA = accA.name || accA.address;
const nameB = accB.name || accB.address;
return nameA.localeCompare(nameB);
});
}
handleInputBlur = () => {
this.setState({ inputFocused: false });
}
@ -572,25 +550,10 @@ class AddressSelect extends Component {
handleChange = (event = { target: {} }) => {
const { value = '' } = event.target;
let index = 0;
const values = this.values
.map((category) => {
const filteredValues = this
.filterValues(category.values, value)
.map((value) => {
index++;
return { ...value, index: parseInt(index) };
});
return {
label: category.label,
values: filteredValues
};
});
this.store.handleChange(value);
this.setState({
values,
focusedItem: null,
inputValue: value
});

View File

@ -0,0 +1,222 @@
// Copyright 2015, 2016 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 from 'react';
import { observable, action } from 'mobx';
import { flatMap } from 'lodash';
import { FormattedMessage } from 'react-intl';
import Contracts from '~/contracts';
import { sha3 } from '~/api/util/sha3';
export default class AddressSelectStore {
@observable values = [];
@observable registryValues = [];
initValues = [];
regLookups = [];
constructor (api) {
this.api = api;
const { registry } = Contracts.create(api);
registry
.getContract('emailverification')
.then((emailVerification) => {
this.regLookups.push({
lookup: (value) => {
return emailVerification
.instance
.reverse.call({}, [ sha3(value) ]);
},
describe: (value) => (
<FormattedMessage
id='addressSelect.fromEmail'
defaultMessage='Verified using email {value}'
values={ {
value
} }
/>
)
});
});
registry
.getInstance()
.then((registryInstance) => {
this.regLookups.push({
lookup: (value) => {
return registryInstance
.getAddress.call({}, [ sha3(value), 'A' ]);
},
describe: (value) => (
<FormattedMessage
id='addressSelect.fromRegistry'
defaultMessage='{value} (from registry)'
values={ {
value
} }
/>
)
});
});
}
@action setValues (props) {
const { accounts = {}, contracts = {}, contacts = {}, wallets = {} } = props;
const accountsN = Object.keys(accounts).length;
const contractsN = Object.keys(contracts).length;
const contactsN = Object.keys(contacts).length;
const walletsN = Object.keys(wallets).length;
if (accountsN + contractsN + contactsN + walletsN === 0) {
return;
}
this.initValues = [
{
key: 'accounts',
label: (
<FormattedMessage
id='addressSelect.labels.accounts'
defaultMessage='accounts'
/>
),
values: [].concat(
Object.values(wallets),
Object.values(accounts)
)
},
{
key: 'contacts',
label: (
<FormattedMessage
id='addressSelect.labels.contacts'
defaultMessage='contacts'
/>
),
values: Object.values(contacts)
},
{
key: 'contracts',
label: (
<FormattedMessage
id='addressSelect.labels.contracts'
defaultMessage='contracts'
/>
),
values: Object.values(contracts)
}
].filter((cat) => cat.values.length > 0);
this.handleChange();
}
@action handleChange = (value = '') => {
let index = 0;
this.values = this.initValues
.map((category) => {
const filteredValues = this
.filterValues(category.values, value)
.map((value) => {
index++;
return {
index: parseInt(index),
...value
};
});
return {
label: category.label,
values: filteredValues
};
});
// Registries Lookup
this.registryValues = [];
const lookups = this.regLookups.map((regLookup) => regLookup.lookup(value));
Promise
.all(lookups)
.then((results) => {
return results
.map((result, index) => {
if (/^(0x)?0*$/.test(result)) {
return;
}
const lowercaseResult = result.toLowerCase();
const account = flatMap(this.initValues, (cat) => cat.values)
.find((account) => account.address.toLowerCase() === lowercaseResult);
return {
description: this.regLookups[index].describe(value),
address: result,
name: account && account.name || value
};
})
.filter((data) => data);
})
.then((registryValues) => {
this.registryValues = registryValues;
});
}
/**
* Filter the given values based on the given
* filter
*/
filterValues = (values = [], _filter = '') => {
const filter = _filter.toLowerCase();
return values
// Remove empty accounts
.filter((a) => a)
.filter((account) => {
const address = account.address.toLowerCase();
const inAddress = address.includes(filter);
if (!account.name || inAddress) {
return inAddress;
}
const name = account.name.toLowerCase();
const inName = name.includes(filter);
const { meta = {} } = account;
if (!meta.tags || inName) {
return inName;
}
const tags = (meta.tags || []).join('');
return tags.includes(filter);
})
.sort((accA, accB) => {
const nameA = accA.name || accA.address;
const nameB = accB.name || accB.address;
return nameA.localeCompare(nameB);
});
}
}