49fdd23d58
* Move secureApi to shell * Extract isTestnet test * Use mobx + subscriptions for status * Re-add status indicator * Add lerna * Move intial packages to js/packages * Move 3rdparty/{email,sms}-verification to correct location * Move package.json & README to library src * Move tests for library packages * Move views & dapps to packages * Move i18n to root * Move shell to actual src (main app) * Remove ~ references * Change ~ to root (explicit imports) * Finalise convert of ~ * Move views into dapps as well * Move dapps to packages/ * Fix references * Update css * Update test spec locations * Update tests * Case fix * Skip flakey tests * Update enzyme * Skip previously ignored tests * Allow empty api for hw * Re-add theme for embed
640 lines
16 KiB
JavaScript
640 lines
16 KiB
JavaScript
// 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 } from 'react';
|
|
import PropTypes from 'prop-types';
|
|
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 apiutil from '@parity/api/util';
|
|
import { nodeOrStringProptype } from '@parity/shared/util/proptypes';
|
|
import { parseI18NString } from '@parity/shared/util/messages';
|
|
import { validateAddress } from '@parity/shared/util/validation';
|
|
|
|
import AccountCard from '../../AccountCard';
|
|
import CopyToClipboard from '../../CopyToClipboard';
|
|
import Loading from '../../Loading';
|
|
import Portal from '../../Portal';
|
|
import InputAddress from '../InputAddress';
|
|
import LabelWrapper from '../LabelWrapper';
|
|
|
|
import AddressSelectStore from './addressSelectStore';
|
|
import styles from './addressSelect.css';
|
|
|
|
// Current Form ID
|
|
let currentId = 1;
|
|
|
|
@observer
|
|
class AddressSelect extends Component {
|
|
static contextTypes = {
|
|
intl: React.PropTypes.object.isRequired,
|
|
api: PropTypes.object.isRequired
|
|
};
|
|
|
|
static propTypes = {
|
|
// Required props
|
|
onChange: PropTypes.func.isRequired,
|
|
|
|
// Redux props
|
|
accountsInfo: PropTypes.object,
|
|
accounts: PropTypes.object,
|
|
contacts: PropTypes.object,
|
|
contracts: PropTypes.object,
|
|
tokens: PropTypes.object,
|
|
reverse: PropTypes.object,
|
|
|
|
// Optional props
|
|
allowCopy: PropTypes.bool,
|
|
allowInput: PropTypes.bool,
|
|
className: PropTypes.string,
|
|
disabled: PropTypes.bool,
|
|
error: nodeOrStringProptype(),
|
|
hint: nodeOrStringProptype(),
|
|
label: nodeOrStringProptype(),
|
|
readOnly: PropTypes.bool,
|
|
value: nodeOrStringProptype()
|
|
};
|
|
|
|
static defaultProps = {
|
|
value: ''
|
|
};
|
|
|
|
store = new AddressSelectStore(this.context.api);
|
|
|
|
state = {
|
|
expanded: false,
|
|
focused: false,
|
|
focusedCat: null,
|
|
focusedItem: null,
|
|
inputFocused: false,
|
|
inputValue: ''
|
|
};
|
|
|
|
componentWillMount () {
|
|
this.setValues();
|
|
}
|
|
|
|
componentWillReceiveProps (nextProps) {
|
|
if (this.store.values && this.store.values.length > 0) {
|
|
return;
|
|
}
|
|
|
|
this.setValues(nextProps);
|
|
}
|
|
|
|
setValues (props = this.props) {
|
|
this.store.setValues(props);
|
|
}
|
|
|
|
render () {
|
|
const input = this.renderInput();
|
|
const content = this.renderContent();
|
|
|
|
return (
|
|
<div className={ styles.main }>
|
|
{ input }
|
|
{ content }
|
|
</div>
|
|
);
|
|
}
|
|
|
|
renderInput () {
|
|
const { focused } = this.state;
|
|
const { accountsInfo, allowCopy, className, disabled, error, hint, label, readOnly, value } = this.props;
|
|
|
|
const input = (
|
|
<InputAddress
|
|
accountsInfo={ accountsInfo }
|
|
allowCopy={ (disabled || readOnly) && allowCopy ? allowCopy : false }
|
|
className={ className }
|
|
disabled={ disabled || readOnly }
|
|
error={ error }
|
|
hint={ hint }
|
|
focused={ focused }
|
|
label={ label }
|
|
readOnly
|
|
tabIndex={ -1 }
|
|
text
|
|
value={ value }
|
|
/>
|
|
);
|
|
|
|
if (disabled || readOnly) {
|
|
return input;
|
|
}
|
|
|
|
return (
|
|
<div className={ styles.inputAddressContainer }>
|
|
{ this.renderCopyButton() }
|
|
<div
|
|
className={ styles.inputAddress }
|
|
onBlur={ this.handleMainBlur }
|
|
onClick={ this.handleFocus }
|
|
onFocus={ this.handleMainFocus }
|
|
onKeyDown={ this.handleInputAddresKeydown }
|
|
ref='inputAddress'
|
|
tabIndex={ 0 }
|
|
>
|
|
{ input }
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
renderCopyButton () {
|
|
const { allowCopy, value } = this.props;
|
|
|
|
if (!allowCopy) {
|
|
return null;
|
|
}
|
|
|
|
const text = typeof allowCopy === 'string'
|
|
? allowCopy
|
|
: value.toString();
|
|
|
|
return (
|
|
<div className={ styles.copy }>
|
|
<CopyToClipboard data={ text } />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
renderContent () {
|
|
const { hint, disabled, label, readOnly } = this.props;
|
|
const { expanded } = this.state;
|
|
|
|
if (disabled || readOnly) {
|
|
return null;
|
|
}
|
|
|
|
const id = `addressSelect_${++currentId}`;
|
|
const ilHint = parseI18NString(this.context, hint);
|
|
|
|
return (
|
|
<Portal
|
|
className={ styles.inputContainer }
|
|
isChildModal
|
|
onClick={ this.handleClose }
|
|
onClose={ this.handleClose }
|
|
onKeyDown={ this.handleKeyDown }
|
|
open={ expanded }
|
|
title={
|
|
<LabelWrapper
|
|
className={ styles.title }
|
|
htmlFor={ id }
|
|
label={ label }
|
|
>
|
|
<div className={ styles.outerInput }>
|
|
<input
|
|
id={ id }
|
|
className={ styles.input }
|
|
placeholder={ ilHint }
|
|
onBlur={ this.handleInputBlur }
|
|
onClick={ this.stopEvent }
|
|
onFocus={ this.handleInputFocus }
|
|
onChange={ this.handleChange }
|
|
ref={ this.setInputRef }
|
|
/>
|
|
{
|
|
this.store.loading && (
|
|
<Loading
|
|
className={ styles.loader }
|
|
size='small'
|
|
/>
|
|
)
|
|
}
|
|
</div>
|
|
</LabelWrapper>
|
|
}
|
|
>
|
|
{ this.renderCurrentInput() }
|
|
{ this.renderRegistryValues() }
|
|
{ this.renderAccounts() }
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
renderCurrentInput () {
|
|
const { inputValue } = this.state;
|
|
|
|
if (!this.props.allowInput || !inputValue) {
|
|
return null;
|
|
}
|
|
|
|
const { address, addressError } = validateAddress(inputValue);
|
|
const { registryValues } = this.store;
|
|
|
|
if (addressError || registryValues.length > 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={ styles.container }>
|
|
{ this.renderAccountCard({ address, index: 'currentInput_0' }) }
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 className={ styles.container }>
|
|
{ accounts }
|
|
</div>
|
|
);
|
|
}
|
|
|
|
renderAccounts () {
|
|
const { values } = this.store;
|
|
|
|
if (values.length === 0) {
|
|
return (
|
|
<div className={ styles.categories }>
|
|
<div className={ styles.empty }>
|
|
<FormattedMessage
|
|
id='addressSelect.noAccount'
|
|
defaultMessage='No account matches this query...'
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const categories = values.map((category, index) => {
|
|
return this.renderCategory(category, index);
|
|
});
|
|
|
|
return (
|
|
<div className={ styles.categories }>
|
|
{ categories }
|
|
</div>
|
|
);
|
|
}
|
|
|
|
renderCategory (category, index) {
|
|
const { label, key, values = [] } = category;
|
|
let content;
|
|
|
|
if (values.length === 0) {
|
|
content = (
|
|
<p>
|
|
<FormattedMessage
|
|
id='addressSelect.noAccount'
|
|
defaultMessage='No account matches this query...'
|
|
/>
|
|
</p>
|
|
);
|
|
} else {
|
|
const cards = values
|
|
.map((account) => this.renderAccountCard(account));
|
|
|
|
content = (
|
|
<div className={ styles.cards }>
|
|
{ cards }
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={ styles.category } key={ `${key}_${index}` }>
|
|
<div className={ styles.title }>
|
|
<h3>{ label }</h3>
|
|
</div>
|
|
{ content }
|
|
</div>
|
|
);
|
|
}
|
|
|
|
renderAccountCard (_account) {
|
|
const { accountsInfo } = this.props;
|
|
const { address, index = null } = _account;
|
|
|
|
const account = {
|
|
...accountsInfo[address],
|
|
..._account
|
|
};
|
|
|
|
return (
|
|
<AccountCard
|
|
account={ account }
|
|
className={ styles.account }
|
|
key={ `account_${index}` }
|
|
onClick={ this.handleClick }
|
|
onFocus={ this.focusItem }
|
|
ref={ `account_${index}` }
|
|
/>
|
|
);
|
|
}
|
|
|
|
setInputRef = (refId) => {
|
|
this.inputRef = refId;
|
|
}
|
|
|
|
validateCustomInput = () => {
|
|
const { allowInput } = this.props;
|
|
const { inputValue } = this.state;
|
|
const { values } = this.store;
|
|
|
|
// If input is HEX and allowInput === true, send it
|
|
if (allowInput && inputValue && /^(0x)?([0-9a-f])+$/i.test(inputValue)) {
|
|
return this.handleClick(inputValue);
|
|
}
|
|
|
|
// If only one value, select it
|
|
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);
|
|
}
|
|
}
|
|
|
|
stopEvent = (event) => {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
handleInputAddresKeydown = (event) => {
|
|
const code = keycode(event);
|
|
|
|
// Simulate click on input address if enter is pressed
|
|
if (code === 'enter') {
|
|
return this.handleDOMAction('inputAddress', 'click');
|
|
}
|
|
}
|
|
|
|
handleKeyDown = (event) => {
|
|
const codeName = keycode(event);
|
|
|
|
if (event.ctrlKey) {
|
|
return event;
|
|
}
|
|
|
|
switch (codeName) {
|
|
case 'enter':
|
|
const index = this.state.focusedItem;
|
|
|
|
if (!index) {
|
|
return this.validateCustomInput();
|
|
}
|
|
|
|
return this.handleDOMAction(`account_${index}`, 'click');
|
|
|
|
case 'left':
|
|
case 'right':
|
|
case 'up':
|
|
case 'down':
|
|
return this.handleNavigation(codeName, event);
|
|
|
|
default:
|
|
const code = codes[codeName];
|
|
|
|
// @see https://github.com/timoxley/keycode/blob/master/index.js
|
|
// lower case chars
|
|
if (code >= (97 - 32) && code <= (122 - 32)) {
|
|
return this.handleDOMAction(this.inputRef, 'focus');
|
|
}
|
|
|
|
// numbers
|
|
if (code >= 48 && code <= 57) {
|
|
return this.handleDOMAction(this.inputRef, 'focus');
|
|
}
|
|
|
|
return event;
|
|
}
|
|
}
|
|
|
|
handleDOMAction = (ref, method) => {
|
|
const refItem = typeof ref === 'string' ? this.refs[ref] : ref;
|
|
const element = ReactDOM.findDOMNode(refItem);
|
|
|
|
if (!element || typeof element[method] !== 'function') {
|
|
console.warn('could not find', ref, 'or method', method);
|
|
return;
|
|
}
|
|
|
|
return element[method]();
|
|
}
|
|
|
|
focusItem = (index) => {
|
|
this.setState({ focusedItem: index });
|
|
return this.handleDOMAction(`account_${index}`, 'focus');
|
|
}
|
|
|
|
handleNavigation = (direction, event) => {
|
|
const { focusedItem, focusedCat } = this.state;
|
|
const { values } = this.store;
|
|
|
|
// Don't do anything if no values
|
|
if (values.reduce((cur, cat) => cur + cat.values.length, 0) === 0) {
|
|
return event;
|
|
}
|
|
|
|
// Focus on the first element if none selected yet if going down
|
|
if (!focusedItem) {
|
|
if (direction !== 'down') {
|
|
return event;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
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);
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
// Find the previous focused category
|
|
const prevCategoryIndex = values.findIndex((category) => {
|
|
return category.values.find((value) => value.index === focusedItem);
|
|
});
|
|
const prevFocusIndex = values[prevCategoryIndex].values.findIndex((a) => a.index === focusedItem);
|
|
|
|
let nextCategory = prevCategoryIndex;
|
|
let nextFocusIndex;
|
|
|
|
// If down: increase index if possible
|
|
if (direction === 'down') {
|
|
const prevN = values[prevCategoryIndex].values.length;
|
|
|
|
nextFocusIndex = Math.min(prevFocusIndex + 1, prevN - 1);
|
|
}
|
|
|
|
// If up: decrease index if possible
|
|
if (direction === 'up') {
|
|
// Focus on search if at the top
|
|
if (prevFocusIndex === 0) {
|
|
return this.handleDOMAction(this.inputRef, 'focus');
|
|
}
|
|
|
|
nextFocusIndex = prevFocusIndex - 1;
|
|
}
|
|
|
|
// If right: next category
|
|
if (direction === 'right') {
|
|
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') {
|
|
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
|
|
if (direction === 'left' || direction === 'right') {
|
|
this.setState({ focusedCat: nextCategory });
|
|
nextFocusIndex = Math.min(prevFocusIndex, values[nextCategory].values.length - 1);
|
|
}
|
|
|
|
const nextFocus = values[nextCategory].values[nextFocusIndex].index;
|
|
|
|
return this.focusItem(nextFocus);
|
|
}
|
|
|
|
handleClick = (address) => {
|
|
// Don't do anything if it's only text-selection
|
|
if (window.getSelection && window.getSelection().type === 'Range') {
|
|
return;
|
|
}
|
|
|
|
this.props.onChange(null, address);
|
|
this.handleClose();
|
|
}
|
|
|
|
handleMainBlur = () => {
|
|
if (this.props.readOnly) {
|
|
return;
|
|
}
|
|
|
|
if (window.document.hasFocus() && !this.state.expanded) {
|
|
this.closing = false;
|
|
this.setState({ focused: false });
|
|
}
|
|
}
|
|
|
|
handleMainFocus = () => {
|
|
if (this.state.focused || this.props.readOnly) {
|
|
return;
|
|
}
|
|
|
|
this.setState({ focused: true }, () => {
|
|
if (this.closing) {
|
|
this.closing = false;
|
|
return;
|
|
}
|
|
|
|
this.handleFocus();
|
|
});
|
|
}
|
|
|
|
handleFocus = () => {
|
|
const { disabled, readOnly } = this.props;
|
|
|
|
if (disabled || readOnly) {
|
|
return;
|
|
}
|
|
|
|
this.setState({ expanded: true, focusedItem: null, focusedCat: null }, () => {
|
|
window.setTimeout(() => {
|
|
this.handleDOMAction(this.inputRef, 'focus');
|
|
});
|
|
});
|
|
}
|
|
|
|
handleClose = () => {
|
|
this.closing = true;
|
|
|
|
if (this.refs.inputAddress) {
|
|
this.handleDOMAction('inputAddress', 'focus');
|
|
}
|
|
|
|
this.store.resetRegistryValues();
|
|
this.store.handleChange('');
|
|
|
|
this.setState({
|
|
expanded: false,
|
|
focusedItem: null,
|
|
inputValue: ''
|
|
});
|
|
}
|
|
|
|
handleInputBlur = () => {
|
|
this.setState({ inputFocused: false });
|
|
}
|
|
|
|
handleInputFocus = () => {
|
|
this.setState({ focusedItem: null, inputFocused: true });
|
|
}
|
|
|
|
handleChange = (event = { target: {} }) => {
|
|
const { value = '' } = event.target;
|
|
|
|
this.store.handleChange(value);
|
|
|
|
this.setState({
|
|
focusedItem: null,
|
|
inputValue: value
|
|
});
|
|
|
|
if (apiutil.isAddressValid(value)) {
|
|
this.handleClick(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
function mapStateToProps (state) {
|
|
const { accountsInfo } = state.personal;
|
|
const { reverse } = state.registry;
|
|
|
|
return {
|
|
accountsInfo,
|
|
reverse
|
|
};
|
|
}
|
|
|
|
export default connect(
|
|
mapStateToProps
|
|
)(AddressSelect);
|