Custom Nodes - Final Touches (#501)

* Add warning about matching nodes, only allow one url:port combination of nodes.

* Fix up alert styling.

* Custom network form.

* Add custom network to redux store. Setup infrastructure for removal and display.

* Persist custom networks to LS, show them in display.

* Force chain id, make typing happy.

* Display custom networks in network dropdown.

* Fix form validation, purge unused custom networks.
This commit is contained in:
William O'Beirne 2017-12-01 08:09:51 -08:00 committed by Daniel Ternyak
parent eb490a78b3
commit b638b746de
17 changed files with 406 additions and 87 deletions

View File

@ -1,6 +1,6 @@
import * as interfaces from './actionTypes'; import * as interfaces from './actionTypes';
import { TypeKeys } from './constants'; import { TypeKeys } from './constants';
import { NodeConfig, CustomNodeConfig } from 'config/data'; import { NodeConfig, CustomNodeConfig, CustomNetworkConfig } from 'config/data';
export type TForceOfflineConfig = typeof forceOfflineConfig; export type TForceOfflineConfig = typeof forceOfflineConfig;
export function forceOfflineConfig(): interfaces.ForceOfflineAction { export function forceOfflineConfig(): interfaces.ForceOfflineAction {
@ -80,6 +80,26 @@ export function removeCustomNode(
}; };
} }
export type TAddCustomNetwork = typeof addCustomNetwork;
export function addCustomNetwork(
payload: CustomNetworkConfig
): interfaces.AddCustomNetworkAction {
return {
type: TypeKeys.CONFIG_ADD_CUSTOM_NETWORK,
payload
};
}
export type TRemoveCustomNetwork = typeof removeCustomNetwork;
export function removeCustomNetwork(
payload: CustomNetworkConfig
): interfaces.RemoveCustomNetworkAction {
return {
type: TypeKeys.CONFIG_REMOVE_CUSTOM_NETWORK,
payload
};
}
export type TSetLatestBlock = typeof setLatestBlock; export type TSetLatestBlock = typeof setLatestBlock;
export function setLatestBlock( export function setLatestBlock(
payload: string payload: string

View File

@ -1,5 +1,5 @@
import { TypeKeys } from './constants'; import { TypeKeys } from './constants';
import { CustomNodeConfig, NodeConfig } from 'config/data'; import { NodeConfig, CustomNodeConfig, CustomNetworkConfig } from 'config/data';
/*** Toggle Offline ***/ /*** Toggle Offline ***/
export interface ToggleOfflineAction { export interface ToggleOfflineAction {
@ -56,6 +56,18 @@ export interface RemoveCustomNodeAction {
payload: CustomNodeConfig; payload: CustomNodeConfig;
} }
/*** Add Custom Network ***/
export interface AddCustomNetworkAction {
type: TypeKeys.CONFIG_ADD_CUSTOM_NETWORK;
payload: CustomNetworkConfig;
}
/*** Remove Custom Network ***/
export interface RemoveCustomNetworkAction {
type: TypeKeys.CONFIG_REMOVE_CUSTOM_NETWORK;
payload: CustomNetworkConfig;
}
/*** Set Latest Block ***/ /*** Set Latest Block ***/
export interface SetLatestBlockAction { export interface SetLatestBlockAction {
type: TypeKeys.CONFIG_SET_LATEST_BLOCK; type: TypeKeys.CONFIG_SET_LATEST_BLOCK;
@ -78,5 +90,7 @@ export type ConfigAction =
| ChangeNodeIntentAction | ChangeNodeIntentAction
| AddCustomNodeAction | AddCustomNodeAction
| RemoveCustomNodeAction | RemoveCustomNodeAction
| AddCustomNetworkAction
| RemoveCustomNetworkAction
| SetLatestBlockAction | SetLatestBlockAction
| Web3UnsetNodeAction; | Web3UnsetNodeAction;

View File

@ -8,6 +8,8 @@ export enum TypeKeys {
CONFIG_POLL_OFFLINE_STATUS = 'CONFIG_POLL_OFFLINE_STATUS', CONFIG_POLL_OFFLINE_STATUS = 'CONFIG_POLL_OFFLINE_STATUS',
CONFIG_ADD_CUSTOM_NODE = 'CONFIG_ADD_CUSTOM_NODE', CONFIG_ADD_CUSTOM_NODE = 'CONFIG_ADD_CUSTOM_NODE',
CONFIG_REMOVE_CUSTOM_NODE = 'CONFIG_REMOVE_CUSTOM_NODE', CONFIG_REMOVE_CUSTOM_NODE = 'CONFIG_REMOVE_CUSTOM_NODE',
CONFIG_ADD_CUSTOM_NETWORK = 'CONFIG_ADD_CUSTOM_NETWORK',
CONFIG_REMOVE_CUSTOM_NETWORK = 'CONFIG_REMOVE_CUSTOM_NETWORK',
CONFIG_SET_LATEST_BLOCK = 'CONFIG_SET_LATEST_BLOCK', CONFIG_SET_LATEST_BLOCK = 'CONFIG_SET_LATEST_BLOCK',
CONFIG_NODE_WEB3_UNSET = 'CONFIG_NODE_WEB3_UNSET' CONFIG_NODE_WEB3_UNSET = 'CONFIG_NODE_WEB3_UNSET'
} }

View File

@ -2,9 +2,12 @@ import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import Modal, { IButton } from 'components/ui/Modal'; import Modal, { IButton } from 'components/ui/Modal';
import translate from 'translations'; import translate from 'translations';
import { NETWORKS, CustomNodeConfig } from 'config/data'; import { NETWORKS, CustomNodeConfig, CustomNetworkConfig } from 'config/data';
import { makeCustomNodeId } from 'utils/node';
import { makeCustomNetworkId } from 'utils/network';
const NETWORK_KEYS = Object.keys(NETWORKS); const NETWORK_KEYS = Object.keys(NETWORKS);
const CUSTOM = 'custom';
interface Input { interface Input {
name: string; name: string;
@ -13,7 +16,10 @@ interface Input {
} }
interface Props { interface Props {
customNodes: CustomNodeConfig[];
customNetworks: CustomNetworkConfig[];
handleAddCustomNode(node: CustomNodeConfig): void; handleAddCustomNode(node: CustomNodeConfig): void;
handleAddCustomNetwork(node: CustomNetworkConfig): void;
handleClose(): void; handleClose(): void;
} }
@ -22,6 +28,9 @@ interface State {
url: string; url: string;
port: string; port: string;
network: string; network: string;
customNetworkName: string;
customNetworkUnit: string;
customNetworkChainId: string;
hasAuth: boolean; hasAuth: boolean;
username: string; username: string;
password: string; password: string;
@ -33,13 +42,17 @@ export default class CustomNodeModal extends React.Component<Props, State> {
url: '', url: '',
port: '', port: '',
network: NETWORK_KEYS[0], network: NETWORK_KEYS[0],
customNetworkName: '',
customNetworkUnit: '',
customNetworkChainId: '',
hasAuth: false, hasAuth: false,
username: '', username: '',
password: '' password: ''
}; };
public render() { public render() {
const { handleClose } = this.props; const { customNetworks, handleClose } = this.props;
const { network } = this.state;
const isHttps = window.location.protocol.includes('https'); const isHttps = window.location.protocol.includes('https');
const invalids = this.getInvalids(); const invalids = this.getInvalids();
@ -56,6 +69,8 @@ export default class CustomNodeModal extends React.Component<Props, State> {
} }
]; ];
const conflictedNode = this.getConflictedNode();
return ( return (
<Modal <Modal
title={translate('NODE_Title')} title={translate('NODE_Title')}
@ -65,11 +80,18 @@ export default class CustomNodeModal extends React.Component<Props, State> {
> >
<div> <div>
{isHttps && ( {isHttps && (
<div className="alert alert-danger small"> <div className="alert alert-warning small">
{translate('NODE_Warning')} {translate('NODE_Warning')}
</div> </div>
)} )}
{conflictedNode && (
<div className="alert alert-warning small">
You already have a node called '{conflictedNode.name}' that
matches this one, saving this will overwrite it
</div>
)}
<form> <form>
<div className="row"> <div className="row">
<div className="col-sm-7"> <div className="col-sm-7">
@ -87,7 +109,7 @@ export default class CustomNodeModal extends React.Component<Props, State> {
<select <select
className="form-control" className="form-control"
name="network" name="network"
value={this.state.network} value={network}
onChange={this.handleChange} onChange={this.handleChange}
> >
{NETWORK_KEYS.map(net => ( {NETWORK_KEYS.map(net => (
@ -95,10 +117,56 @@ export default class CustomNodeModal extends React.Component<Props, State> {
{net} {net}
</option> </option>
))} ))}
{customNetworks.map(net => {
const id = makeCustomNetworkId(net);
return (
<option key={id} value={id}>
{net.name} (Custom)
</option>
);
})}
<option value={CUSTOM}>Custom...</option>
</select> </select>
</div> </div>
</div> </div>
{network === CUSTOM && (
<div className="row">
<div className="col-sm-6">
<label className="is-required">Network Name</label>
{this.renderInput(
{
name: 'customNetworkName',
placeholder: 'My Custom Network'
},
invalids
)}
</div>
<div className="col-sm-3">
<label className="is-required">Currency</label>
{this.renderInput(
{
name: 'customNetworkUnit',
placeholder: 'ETH'
},
invalids
)}
</div>
<div className="col-sm-3">
<label>Chain ID</label>
{this.renderInput(
{
name: 'customNetworkChainId',
placeholder: 'e.g. 1'
},
invalids
)}
</div>
</div>
)}
<hr />
<div className="row"> <div className="row">
<div className="col-sm-9"> <div className="col-sm-9">
<label>URL</label> <label>URL</label>
@ -123,6 +191,7 @@ export default class CustomNodeModal extends React.Component<Props, State> {
)} )}
</div> </div>
</div> </div>
<div className="row"> <div className="row">
<div className="col-sm-12"> <div className="col-sm-12">
<label> <label>
@ -139,11 +208,11 @@ export default class CustomNodeModal extends React.Component<Props, State> {
{this.state.hasAuth && ( {this.state.hasAuth && (
<div className="row"> <div className="row">
<div className="col-sm-6"> <div className="col-sm-6">
<label>Username</label> <label className="is-required">Username</label>
{this.renderInput({ name: 'username' }, invalids)} {this.renderInput({ name: 'username' }, invalids)}
</div> </div>
<div className="col-sm-6"> <div className="col-sm-6">
<label>Password</label> <label className="is-required">Password</label>
{this.renderInput( {this.renderInput(
{ {
name: 'password', name: 'password',
@ -175,7 +244,17 @@ export default class CustomNodeModal extends React.Component<Props, State> {
} }
private getInvalids(): { [key: string]: boolean } { private getInvalids(): { [key: string]: boolean } {
const { url, port, hasAuth, username, password } = this.state; const {
url,
port,
hasAuth,
username,
password,
network,
customNetworkName,
customNetworkUnit,
customNetworkChainId
} = this.state;
const required = ['name', 'url', 'port', 'network']; const required = ['name', 'url', 'port', 'network'];
const invalids: { [key: string]: boolean } = {}; const invalids: { [key: string]: boolean } = {};
@ -207,9 +286,64 @@ export default class CustomNodeModal extends React.Component<Props, State> {
} }
} }
// If they have a custom network, make sure info is provided
if (network === CUSTOM) {
if (!customNetworkName) {
invalids.customNetworkName = true;
}
if (!customNetworkUnit) {
invalids.customNetworkUnit = true;
}
// Numeric chain ID (if provided)
const iChainId = parseInt(customNetworkChainId, 10);
if (!iChainId || iChainId < 0) {
invalids.customNetworkChainId = true;
}
}
return invalids; return invalids;
} }
private makeCustomNetworkConfigFromState(): CustomNetworkConfig {
return {
name: this.state.customNetworkName,
unit: this.state.customNetworkUnit,
chainId: this.state.customNetworkChainId
? parseInt(this.state.customNetworkChainId, 10)
: 0
};
}
private makeCustomNodeConfigFromState(): CustomNodeConfig {
const { network } = this.state;
const node: CustomNodeConfig = {
name: this.state.name.trim(),
url: this.state.url.trim(),
port: parseInt(this.state.port, 10),
network:
network === CUSTOM
? makeCustomNetworkId(this.makeCustomNetworkConfigFromState())
: network
};
if (this.state.hasAuth) {
node.auth = {
username: this.state.username,
password: this.state.password
};
}
return node;
}
private getConflictedNode(): CustomNodeConfig | undefined {
const { customNodes } = this.props;
const config = this.makeCustomNodeConfigFromState();
const thisId = makeCustomNodeId(config);
return customNodes.find(conf => makeCustomNodeId(conf) === thisId);
}
private handleChange = ( private handleChange = (
ev: React.FormEvent<HTMLInputElement | HTMLSelectElement> ev: React.FormEvent<HTMLInputElement | HTMLSelectElement>
) => { ) => {
@ -223,18 +357,11 @@ export default class CustomNodeModal extends React.Component<Props, State> {
}; };
private saveAndAdd = () => { private saveAndAdd = () => {
const node: CustomNodeConfig = { const node = this.makeCustomNodeConfigFromState();
name: this.state.name.trim(),
url: this.state.url.trim(),
port: parseInt(this.state.port, 10),
network: this.state.network
};
if (this.state.hasAuth) { if (this.state.network === CUSTOM) {
node.auth = { const network = this.makeCustomNetworkConfigFromState();
username: this.state.username, this.props.handleAddCustomNetwork(network);
password: this.state.password
};
} }
this.props.handleAddCustomNode(node); this.props.handleAddCustomNode(node);

View File

@ -3,7 +3,8 @@ import {
TChangeLanguage, TChangeLanguage,
TChangeNodeIntent, TChangeNodeIntent,
TAddCustomNode, TAddCustomNode,
TRemoveCustomNode TRemoveCustomNode,
TAddCustomNetwork
} from 'actions/config'; } from 'actions/config';
import logo from 'assets/images/logo-myetherwallet.svg'; import logo from 'assets/images/logo-myetherwallet.svg';
import { Dropdown, ColorDropdown } from 'components/ui'; import { Dropdown, ColorDropdown } from 'components/ui';
@ -14,17 +15,18 @@ import {
ANNOUNCEMENT_MESSAGE, ANNOUNCEMENT_MESSAGE,
ANNOUNCEMENT_TYPE, ANNOUNCEMENT_TYPE,
languages, languages,
NETWORKS,
NODES, NODES,
VERSION, VERSION,
NodeConfig, NodeConfig,
CustomNodeConfig CustomNodeConfig,
} from '../../config/data'; CustomNetworkConfig
} from 'config/data';
import GasPriceDropdown from './components/GasPriceDropdown'; import GasPriceDropdown from './components/GasPriceDropdown';
import Navigation from './components/Navigation'; import Navigation from './components/Navigation';
import CustomNodeModal from './components/CustomNodeModal'; import CustomNodeModal from './components/CustomNodeModal';
import { getKeyByValue } from 'utils/helpers'; import { getKeyByValue } from 'utils/helpers';
import { makeCustomNodeId } from 'utils/node'; import { makeCustomNodeId } from 'utils/node';
import { getNetworkConfigFromId } from 'utils/network';
import './index.scss'; import './index.scss';
interface Props { interface Props {
@ -34,12 +36,14 @@ interface Props {
isChangingNode: boolean; isChangingNode: boolean;
gasPriceGwei: number; gasPriceGwei: number;
customNodes: CustomNodeConfig[]; customNodes: CustomNodeConfig[];
customNetworks: CustomNetworkConfig[];
changeLanguage: TChangeLanguage; changeLanguage: TChangeLanguage;
changeNodeIntent: TChangeNodeIntent; changeNodeIntent: TChangeNodeIntent;
changeGasPrice: TChangeGasPrice; changeGasPrice: TChangeGasPrice;
addCustomNode: TAddCustomNode; addCustomNode: TAddCustomNode;
removeCustomNode: TRemoveCustomNode; removeCustomNode: TRemoveCustomNode;
addCustomNetwork: TAddCustomNetwork;
} }
interface State { interface State {
@ -58,40 +62,47 @@ export default class Header extends Component<Props, State> {
node, node,
nodeSelection, nodeSelection,
isChangingNode, isChangingNode,
customNodes customNodes,
customNetworks
} = this.props; } = this.props;
const { isAddingCustomNode } = this.state; const { isAddingCustomNode } = this.state;
const selectedLanguage = languageSelection; const selectedLanguage = languageSelection;
const selectedNetwork = NETWORKS[node.network]; const selectedNetwork = getNetworkConfigFromId(
node.network,
customNetworks
);
const LanguageDropDown = Dropdown as new () => Dropdown< const LanguageDropDown = Dropdown as new () => Dropdown<
typeof selectedLanguage typeof selectedLanguage
>; >;
const nodeOptions = Object.keys(NODES) const nodeOptions = Object.keys(NODES)
.map(key => { .map(key => {
const n = NODES[key];
const network = getNetworkConfigFromId(n.network, customNetworks);
return { return {
value: key, value: key,
name: ( name: (
<span> <span>
{NODES[key].network} <small>({NODES[key].service})</small> {network && network.name} <small>({n.service})</small>
</span> </span>
), ),
color: NETWORKS[NODES[key].network].color, color: network && network.color,
hidden: NODES[key].hidden hidden: n.hidden
}; };
}) })
.concat( .concat(
customNodes.map(customNode => { customNodes.map(cn => {
const network = getNetworkConfigFromId(cn.network, customNetworks);
return { return {
value: makeCustomNodeId(customNode), value: makeCustomNodeId(cn),
name: ( name: (
<span> <span>
{customNode.network} - {customNode.name} <small>(custom)</small> {network && network.name} - {cn.name} <small>(custom)</small>
</span> </span>
), ),
color: '#000', color: network && network.color,
hidden: false, hidden: false,
onRemove: () => this.props.removeCustomNode(customNode) onRemove: () => this.props.removeCustomNode(cn)
}; };
}) })
); );
@ -161,8 +172,8 @@ export default class Header extends Component<Props, State> {
> >
<ColorDropdown <ColorDropdown
ariaLabel={` ariaLabel={`
change node. current node ${node.network} change node. current node is on the ${node.network} network
node by ${node.service} provided by ${node.service}
`} `}
options={nodeOptions} options={nodeOptions}
value={nodeSelection} value={nodeSelection}
@ -182,11 +193,14 @@ export default class Header extends Component<Props, State> {
</section> </section>
</section> </section>
<Navigation color={selectedNetwork.color} /> <Navigation color={selectedNetwork && selectedNetwork.color} />
{isAddingCustomNode && ( {isAddingCustomNode && (
<CustomNodeModal <CustomNodeModal
customNodes={customNodes}
customNetworks={customNetworks}
handleAddCustomNode={this.addCustomNode} handleAddCustomNode={this.addCustomNode}
handleAddCustomNetwork={this.props.addCustomNetwork}
handleClose={this.closeCustomNodeModal} handleClose={this.closeCustomNodeModal}
/> />
)} )}

View File

@ -79,6 +79,12 @@ export interface NetworkConfig {
contracts: NetworkContract[] | null; contracts: NetworkContract[] | null;
} }
export interface CustomNetworkConfig {
name: string;
unit: string;
chainId: number;
}
export interface NodeConfig { export interface NodeConfig {
network: string; network: string;
lib: RPCNode | Web3Node; lib: RPCNode | Web3Node;

View File

@ -1,40 +1,49 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { import {
changeGasPrice as dChangeGasPrice, changeGasPrice as dChangeGasPrice,
changeLanguage as dChangeLanguage, changeLanguage as dChangeLanguage,
changeNodeIntent as dChangeNodeIntent, changeNodeIntent as dChangeNodeIntent,
addCustomNode as dAddCustomNode, addCustomNode as dAddCustomNode,
removeCustomNode as dRemoveCustomNode, removeCustomNode as dRemoveCustomNode,
addCustomNetwork as dAddCustomNetwork,
TChangeGasPrice, TChangeGasPrice,
TChangeLanguage, TChangeLanguage,
TChangeNodeIntent, TChangeNodeIntent,
TAddCustomNode, TAddCustomNode,
TRemoveCustomNode TRemoveCustomNode,
TAddCustomNetwork
} from 'actions/config'; } from 'actions/config';
import { AlphaAgreement, Footer, Header } from 'components'; import { AlphaAgreement, Footer, Header } from 'components';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import Notifications from './Notifications'; import Notifications from './Notifications';
import { NodeConfig, CustomNodeConfig } from 'config/data';
interface Props { interface ReduxProps {
// FIXME languageSelection: AppState['config']['languageSelection'];
children: any; node: AppState['config']['node'];
nodeSelection: AppState['config']['nodeSelection'];
languageSelection: string; isChangingNode: AppState['config']['isChangingNode'];
node: NodeConfig; gasPriceGwei: AppState['config']['gasPriceGwei'];
nodeSelection: string; customNodes: AppState['config']['customNodes'];
isChangingNode: boolean; customNetworks: AppState['config']['customNetworks'];
gasPriceGwei: number; latestBlock: AppState['config']['latestBlock'];
customNodes: CustomNodeConfig[]; }
latestBlock: string;
interface ActionProps {
changeLanguage: TChangeLanguage; changeLanguage: TChangeLanguage;
changeNodeIntent: TChangeNodeIntent; changeNodeIntent: TChangeNodeIntent;
changeGasPrice: TChangeGasPrice; changeGasPrice: TChangeGasPrice;
addCustomNode: TAddCustomNode; addCustomNode: TAddCustomNode;
removeCustomNode: TRemoveCustomNode; removeCustomNode: TRemoveCustomNode;
addCustomNetwork: TAddCustomNetwork;
} }
type Props = {
// FIXME
children: any;
} & ReduxProps &
ActionProps;
class TabSection extends Component<Props, {}> { class TabSection extends Component<Props, {}> {
public render() { public render() {
const { const {
@ -46,13 +55,15 @@ class TabSection extends Component<Props, {}> {
languageSelection, languageSelection,
gasPriceGwei, gasPriceGwei,
customNodes, customNodes,
customNetworks,
latestBlock, latestBlock,
changeLanguage, changeLanguage,
changeNodeIntent, changeNodeIntent,
changeGasPrice, changeGasPrice,
addCustomNode, addCustomNode,
removeCustomNode removeCustomNode,
addCustomNetwork
} = this.props; } = this.props;
const headerProps = { const headerProps = {
@ -62,12 +73,14 @@ class TabSection extends Component<Props, {}> {
isChangingNode, isChangingNode,
gasPriceGwei, gasPriceGwei,
customNodes, customNodes,
customNetworks,
changeLanguage, changeLanguage,
changeNodeIntent, changeNodeIntent,
changeGasPrice, changeGasPrice,
addCustomNode, addCustomNode,
removeCustomNode removeCustomNode,
addCustomNetwork
}; };
return ( return (
@ -84,7 +97,7 @@ class TabSection extends Component<Props, {}> {
} }
} }
function mapStateToProps(state: AppState) { function mapStateToProps(state: AppState): ReduxProps {
return { return {
node: state.config.node, node: state.config.node,
nodeSelection: state.config.nodeSelection, nodeSelection: state.config.nodeSelection,
@ -92,6 +105,7 @@ function mapStateToProps(state: AppState) {
languageSelection: state.config.languageSelection, languageSelection: state.config.languageSelection,
gasPriceGwei: state.config.gasPriceGwei, gasPriceGwei: state.config.gasPriceGwei,
customNodes: state.config.customNodes, customNodes: state.config.customNodes,
customNetworks: state.config.customNetworks,
latestBlock: state.config.latestBlock latestBlock: state.config.latestBlock
}; };
} }
@ -101,5 +115,6 @@ export default connect(mapStateToProps, {
changeLanguage: dChangeLanguage, changeLanguage: dChangeLanguage,
changeNodeIntent: dChangeNodeIntent, changeNodeIntent: dChangeNodeIntent,
addCustomNode: dAddCustomNode, addCustomNode: dAddCustomNode,
removeCustomNode: dRemoveCustomNode removeCustomNode: dRemoveCustomNode,
addCustomNetwork: dAddCustomNetwork
})(TabSection); })(TabSection);

View File

@ -20,18 +20,21 @@ export interface IWithTx {
showNotification: TShowNotification; showNotification: TShowNotification;
} }
const mapStateToProps = (state: AppState) => ({ const mapStateToProps = (state: AppState) => {
wallet: state.wallet.inst, const network = configSelectors.getNetworkConfig(state);
balance: state.wallet.balance, return {
node: configSelectors.getNodeConfig(state), wallet: state.wallet.inst,
nodeLib: configSelectors.getNodeLib(state), balance: state.wallet.balance,
chainId: configSelectors.getNetworkConfig(state).chainId, node: configSelectors.getNodeConfig(state),
networkName: configSelectors.getNetworkConfig(state).name, nodeLib: configSelectors.getNodeLib(state),
gasPrice: toWei( chainId: network ? network.chainId : 0,
`${configSelectors.getGasPriceGwei(state)}`, networkName: network ? network.name : 'Unknown network',
getDecimal('gwei') gasPrice: toWei(
) `${configSelectors.getGasPriceGwei(state)}`,
}); getDecimal('gwei')
)
};
};
export const withTx = passedComponent => export const withTx = passedComponent =>
connect(mapStateToProps, { connect(mapStateToProps, {

View File

@ -198,7 +198,7 @@ export async function generateCompleteTransactionFromRawTransaction(
to: toChecksumAddress(cleanHex(to)), to: toChecksumAddress(cleanHex(to)),
value: token ? '0x00' : cleanHex(value.toString(16)), value: token ? '0x00' : cleanHex(value.toString(16)),
data: data ? cleanHex(data) : '', data: data ? cleanHex(data) : '',
chainId: chainId || 1 chainId: chainId || 0
}; };
// Sign the transaction // Sign the transaction

View File

@ -4,23 +4,35 @@ import {
ChangeNodeAction, ChangeNodeAction,
AddCustomNodeAction, AddCustomNodeAction,
RemoveCustomNodeAction, RemoveCustomNodeAction,
AddCustomNetworkAction,
RemoveCustomNetworkAction,
SetLatestBlockAction, SetLatestBlockAction,
ConfigAction ConfigAction
} from 'actions/config'; } from 'actions/config';
import { TypeKeys } from 'actions/config/constants'; import { TypeKeys } from 'actions/config/constants';
import { NODES, NodeConfig, CustomNodeConfig } from '../config/data'; import {
NODES,
NETWORKS,
NodeConfig,
CustomNodeConfig,
NetworkConfig,
CustomNetworkConfig
} from '../config/data';
import { makeCustomNodeId } from 'utils/node'; import { makeCustomNodeId } from 'utils/node';
import { makeCustomNetworkId } from 'utils/network';
export interface State { export interface State {
// FIXME // FIXME
languageSelection: string; languageSelection: string;
nodeSelection: string; nodeSelection: string;
node: NodeConfig; node: NodeConfig;
network: NetworkConfig;
isChangingNode: boolean; isChangingNode: boolean;
gasPriceGwei: number; gasPriceGwei: number;
offline: boolean; offline: boolean;
forceOffline: boolean; forceOffline: boolean;
customNodes: CustomNodeConfig[]; customNodes: CustomNodeConfig[];
customNetworks: CustomNetworkConfig[];
latestBlock: string; latestBlock: string;
} }
@ -29,11 +41,13 @@ export const INITIAL_STATE: State = {
languageSelection: 'en', languageSelection: 'en',
nodeSelection: defaultNode, nodeSelection: defaultNode,
node: NODES[defaultNode], node: NODES[defaultNode],
network: NETWORKS[NODES[defaultNode].network],
isChangingNode: false, isChangingNode: false,
gasPriceGwei: 21, gasPriceGwei: 21,
offline: false, offline: false,
forceOffline: false, forceOffline: false,
customNodes: [], customNodes: [],
customNetworks: [],
latestBlock: '???' latestBlock: '???'
}; };
@ -82,9 +96,13 @@ function forceOffline(state: State): State {
} }
function addCustomNode(state: State, action: AddCustomNodeAction): State { function addCustomNode(state: State, action: AddCustomNodeAction): State {
const newId = makeCustomNodeId(action.payload);
return { return {
...state, ...state,
customNodes: [...state.customNodes, action.payload] customNodes: [
...state.customNodes.filter(node => makeCustomNodeId(node) !== newId),
action.payload
]
}; };
} }
@ -98,6 +116,29 @@ function removeCustomNode(state: State, action: RemoveCustomNodeAction): State {
}; };
} }
function addCustomNetwork(state: State, action: AddCustomNetworkAction): State {
const newId = makeCustomNetworkId(action.payload);
return {
...state,
customNetworks: [
...state.customNetworks.filter(
node => makeCustomNetworkId(node) !== newId
),
action.payload
]
};
}
function removeCustomNetwork(
state: State,
action: RemoveCustomNetworkAction
): State {
return {
...state,
customNetworks: state.customNetworks.filter(cn => cn !== action.payload)
};
}
function setLatestBlock(state: State, action: SetLatestBlockAction): State { function setLatestBlock(state: State, action: SetLatestBlockAction): State {
return { return {
...state, ...state,
@ -126,6 +167,10 @@ export function config(
return addCustomNode(state, action); return addCustomNode(state, action);
case TypeKeys.CONFIG_REMOVE_CUSTOM_NODE: case TypeKeys.CONFIG_REMOVE_CUSTOM_NODE:
return removeCustomNode(state, action); return removeCustomNode(state, action);
case TypeKeys.CONFIG_ADD_CUSTOM_NETWORK:
return addCustomNetwork(state, action);
case TypeKeys.CONFIG_REMOVE_CUSTOM_NETWORK:
return removeCustomNetwork(state, action);
case TypeKeys.CONFIG_SET_LATEST_BLOCK: case TypeKeys.CONFIG_SET_LATEST_BLOCK:
return setLatestBlock(state, action); return setLatestBlock(state, action);
default: default:

View File

@ -16,10 +16,12 @@ import {
getCustomNodeConfigFromId, getCustomNodeConfigFromId,
makeNodeConfigFromCustomConfig makeNodeConfigFromCustomConfig
} from 'utils/node'; } from 'utils/node';
import { makeCustomNetworkId } from 'utils/network';
import { import {
getNode, getNode,
getNodeConfig, getNodeConfig,
getCustomNodeConfigs, getCustomNodeConfigs,
getCustomNetworkConfigs,
getOffline, getOffline,
getForceOffline getForceOffline
} from 'selectors/config'; } from 'selectors/config';
@ -30,6 +32,7 @@ import {
changeNode, changeNode,
changeNodeIntent, changeNodeIntent,
setLatestBlock, setLatestBlock,
removeCustomNetwork,
AddCustomNodeAction, AddCustomNodeAction,
ChangeNodeIntentAction ChangeNodeIntentAction
} from 'actions/config'; } from 'actions/config';
@ -188,6 +191,22 @@ export function* switchToNewNode(action: AddCustomNodeAction): SagaIterator {
yield put(changeNodeIntent(nodeId)); yield put(changeNodeIntent(nodeId));
} }
// If there are any orphaned custom networks, purge them
export function* cleanCustomNetworks(): SagaIterator {
const customNodes = yield select(getCustomNodeConfigs);
const customNetworks = yield select(getCustomNetworkConfigs);
const networksInUse = customNodes.reduce((prev, conf) => {
prev[conf.network] = true;
return prev;
}, {});
for (const net of customNetworks) {
if (!networksInUse[makeCustomNetworkId(net)]) {
yield put(removeCustomNetwork(net));
}
}
}
// unset web3 as the selected node if a non-web3 wallet has been selected // unset web3 as the selected node if a non-web3 wallet has been selected
export function* unsetWeb3Node(action): SagaIterator { export function* unsetWeb3Node(action): SagaIterator {
const node = yield select(getNode); const node = yield select(getNode);
@ -230,6 +249,7 @@ export default function* configSaga(): SagaIterator {
yield takeEvery(TypeKeys.CONFIG_NODE_CHANGE_INTENT, handleNodeChangeIntent); yield takeEvery(TypeKeys.CONFIG_NODE_CHANGE_INTENT, handleNodeChangeIntent);
yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload); yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload);
yield takeEvery(TypeKeys.CONFIG_ADD_CUSTOM_NODE, switchToNewNode); yield takeEvery(TypeKeys.CONFIG_ADD_CUSTOM_NODE, switchToNewNode);
yield takeEvery(TypeKeys.CONFIG_REMOVE_CUSTOM_NODE, cleanCustomNetworks);
yield takeEvery(WalletTypeKeys.WALLET_SET, unsetWeb3Node); yield takeEvery(WalletTypeKeys.WALLET_SET, unsetWeb3Node);
yield takeEvery(WalletTypeKeys.WALLET_RESET, unsetWeb3Node); yield takeEvery(WalletTypeKeys.WALLET_RESET, unsetWeb3Node);
} }

View File

@ -3,6 +3,19 @@
@import "common/sass/mixins"; @import "common/sass/mixins";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/alerts"; @import "~bootstrap-sass/assets/stylesheets/bootstrap/alerts";
.alert {
margin-bottom: 1rem;
a {
color: #FFF;
&:hover {
color: #FFF;
opacity: 0.8;
}
}
}
// Alert icons // Alert icons
.alert:after { .alert:after {
content: ''; content: '';
@ -23,15 +36,6 @@
@media screen and (max-width: $screen-xs) { @media screen and (max-width: $screen-xs) {
left: 1%; left: 1%;
} }
a {
color: #FFF;
&:hover {
color: #FFF;
opacity: 0.8;
}
}
} }
.alert, .alert,

View File

@ -4,6 +4,12 @@
label { label {
margin-bottom: $space-xs; margin-bottom: $space-xs;
font-size: $font-size-bump-more; font-size: $font-size-bump-more;
&.is-required:after {
content: '*';
padding-left: 2px;
color: $brand-warning;
}
} }
label + .form-control, label + .form-control,

View File

@ -1,12 +1,13 @@
import { import {
NetworkConfig, NetworkConfig,
NetworkContract, NetworkContract,
NETWORKS,
NodeConfig, NodeConfig,
CustomNodeConfig CustomNodeConfig,
CustomNetworkConfig
} from 'config/data'; } from 'config/data';
import { INode } from 'libs/nodes/INode'; import { INode } from 'libs/nodes/INode';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import { getNetworkConfigFromId } from 'utils/network';
export function getNode(state: AppState): string { export function getNode(state: AppState): string {
return state.config.nodeSelection; return state.config.nodeSelection;
@ -20,12 +21,16 @@ export function getNodeLib(state: AppState): INode {
return getNodeConfig(state).lib; return getNodeConfig(state).lib;
} }
export function getNetworkConfig(state: AppState): NetworkConfig { export function getNetworkConfig(state: AppState): NetworkConfig | undefined {
return NETWORKS[getNodeConfig(state).network]; return getNetworkConfigFromId(
getNodeConfig(state).network,
getCustomNetworkConfigs(state)
);
} }
export function getNetworkContracts(state: AppState): NetworkContract[] | null { export function getNetworkContracts(state: AppState): NetworkContract[] | null {
return getNetworkConfig(state).contracts; const network = getNetworkConfig(state);
return network ? network.contracts : [];
} }
export function getGasPriceGwei(state: AppState): number { export function getGasPriceGwei(state: AppState): number {
@ -40,6 +45,12 @@ export function getCustomNodeConfigs(state: AppState): CustomNodeConfig[] {
return state.config.customNodes; return state.config.customNodes;
} }
export function getCustomNetworkConfigs(
state: AppState
): CustomNetworkConfig[] {
return state.config.customNetworks;
}
export function getOffline(state: AppState): boolean { export function getOffline(state: AppState): boolean {
return state.config.offline; return state.config.offline;
} }

View File

@ -22,7 +22,8 @@ export type MergedToken = Token & {
}; };
export function getTokens(state: AppState): MergedToken[] { export function getTokens(state: AppState): MergedToken[] {
const tokens: Token[] = getNetworkConfig(state).tokens; const network = getNetworkConfig(state);
const tokens: Token[] = network ? network.tokens : [];
return tokens.concat( return tokens.concat(
state.customTokens.map((token: Token) => { state.customTokens.map((token: Token) => {
const mergedToken = { ...token, custom: true }; const mergedToken = { ...token, custom: true };

View File

@ -109,7 +109,8 @@ const configureStore = () => {
gasPriceGwei: state.config.gasPriceGwei, gasPriceGwei: state.config.gasPriceGwei,
nodeSelection: state.config.nodeSelection, nodeSelection: state.config.nodeSelection,
languageSelection: state.config.languageSelection, languageSelection: state.config.languageSelection,
customNodes: state.config.customNodes customNodes: state.config.customNodes,
customNetworks: state.config.customNetworks
}, },
swap: { ...state.swap, bityRates: {} }, swap: { ...state.swap, bityRates: {} },
customTokens: state.customTokens customTokens: state.customTokens

30
common/utils/network.ts Normal file
View File

@ -0,0 +1,30 @@
import { NETWORKS, NetworkConfig, CustomNetworkConfig } from 'config/data';
export function makeCustomNetworkId(config: CustomNetworkConfig): string {
return config.chainId ? `${config.chainId}` : `${config.name}:${config.unit}`;
}
export function makeNetworkConfigFromCustomConfig(
config: CustomNetworkConfig
): NetworkConfig {
return {
...config,
color: '#000',
tokens: [],
contracts: []
};
}
export function getNetworkConfigFromId(
id: string,
configs: CustomNetworkConfig[]
): NetworkConfig | undefined {
if (NETWORKS[id]) {
return NETWORKS[id];
}
const customConfig = configs.find(conf => makeCustomNetworkId(conf) === id);
if (customConfig) {
return makeNetworkConfigFromCustomConfig(customConfig);
}
}