Custom Nodes (#322)
* Layed out components for custom nodes. * Outline of custom nodes. Still missing various features and error handling. * Persist custom nodes to local storage. * Make custom nodes removable. * Add latest block functions, call it when switching nodes. * Initialize correct node, move node utils into utils file. * Fix names * Send headers along with rpc requests. * Remove custom network options for now. * PR feedback. * One last log. * Fix tests. * Headers in batch too. * Switch to node when you add it. * Reduce hackery. * Clean up linter and tsc. * Fix latest block hex conversion. * Unit tests. * Fix missing property. * Fix Modal title typing.
This commit is contained in:
parent
1510533ec7
commit
c0cd668c64
|
@ -1,5 +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';
|
||||||
|
|
||||||
export type TForceOfflineConfig = typeof forceOfflineConfig;
|
export type TForceOfflineConfig = typeof forceOfflineConfig;
|
||||||
export function forceOfflineConfig(): interfaces.ForceOfflineAction {
|
export function forceOfflineConfig(): interfaces.ForceOfflineAction {
|
||||||
|
@ -24,10 +25,13 @@ export function changeLanguage(sign: string): interfaces.ChangeLanguageAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TChangeNode = typeof changeNode;
|
export type TChangeNode = typeof changeNode;
|
||||||
export function changeNode(value: string): interfaces.ChangeNodeAction {
|
export function changeNode(
|
||||||
|
nodeSelection: string,
|
||||||
|
node: NodeConfig
|
||||||
|
): interfaces.ChangeNodeAction {
|
||||||
return {
|
return {
|
||||||
type: TypeKeys.CONFIG_NODE_CHANGE,
|
type: TypeKeys.CONFIG_NODE_CHANGE,
|
||||||
payload: value
|
payload: { nodeSelection, node }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,6 +60,36 @@ export function changeNodeIntent(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TAddCustomNode = typeof addCustomNode;
|
||||||
|
export function addCustomNode(
|
||||||
|
payload: CustomNodeConfig
|
||||||
|
): interfaces.AddCustomNodeAction {
|
||||||
|
return {
|
||||||
|
type: TypeKeys.CONFIG_ADD_CUSTOM_NODE,
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TRemoveCustomNode = typeof removeCustomNode;
|
||||||
|
export function removeCustomNode(
|
||||||
|
payload: CustomNodeConfig
|
||||||
|
): interfaces.RemoveCustomNodeAction {
|
||||||
|
return {
|
||||||
|
type: TypeKeys.CONFIG_REMOVE_CUSTOM_NODE,
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TSetLatestBlock = typeof setLatestBlock;
|
||||||
|
export function setLatestBlock(
|
||||||
|
payload: string
|
||||||
|
): interfaces.SetLatestBlockAction {
|
||||||
|
return {
|
||||||
|
type: TypeKeys.CONFIG_SET_LATEST_BLOCK,
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type TWeb3UnsetNode = typeof web3UnsetNode;
|
export type TWeb3UnsetNode = typeof web3UnsetNode;
|
||||||
export function web3UnsetNode(): interfaces.Web3UnsetNodeAction {
|
export function web3UnsetNode(): interfaces.Web3UnsetNodeAction {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { TypeKeys } from './constants';
|
import { TypeKeys } from './constants';
|
||||||
|
import { CustomNodeConfig, NodeConfig } from 'config/data';
|
||||||
|
|
||||||
/*** Toggle Offline ***/
|
/*** Toggle Offline ***/
|
||||||
export interface ToggleOfflineAction {
|
export interface ToggleOfflineAction {
|
||||||
|
@ -20,7 +21,10 @@ export interface ChangeLanguageAction {
|
||||||
export interface ChangeNodeAction {
|
export interface ChangeNodeAction {
|
||||||
type: TypeKeys.CONFIG_NODE_CHANGE;
|
type: TypeKeys.CONFIG_NODE_CHANGE;
|
||||||
// FIXME $keyof?
|
// FIXME $keyof?
|
||||||
payload: string;
|
payload: {
|
||||||
|
nodeSelection: string;
|
||||||
|
node: NodeConfig;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/*** Change gas price ***/
|
/*** Change gas price ***/
|
||||||
|
@ -40,6 +44,24 @@ export interface ChangeNodeIntentAction {
|
||||||
payload: string;
|
payload: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*** Add Custom Node ***/
|
||||||
|
export interface AddCustomNodeAction {
|
||||||
|
type: TypeKeys.CONFIG_ADD_CUSTOM_NODE;
|
||||||
|
payload: CustomNodeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** Remove Custom Node ***/
|
||||||
|
export interface RemoveCustomNodeAction {
|
||||||
|
type: TypeKeys.CONFIG_REMOVE_CUSTOM_NODE;
|
||||||
|
payload: CustomNodeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** Set Latest Block ***/
|
||||||
|
export interface SetLatestBlockAction {
|
||||||
|
type: TypeKeys.CONFIG_SET_LATEST_BLOCK;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
/*** Unset Web3 as a Node ***/
|
/*** Unset Web3 as a Node ***/
|
||||||
export interface Web3UnsetNodeAction {
|
export interface Web3UnsetNodeAction {
|
||||||
type: TypeKeys.CONFIG_NODE_WEB3_UNSET;
|
type: TypeKeys.CONFIG_NODE_WEB3_UNSET;
|
||||||
|
@ -54,4 +76,7 @@ export type ConfigAction =
|
||||||
| PollOfflineStatus
|
| PollOfflineStatus
|
||||||
| ForceOfflineAction
|
| ForceOfflineAction
|
||||||
| ChangeNodeIntentAction
|
| ChangeNodeIntentAction
|
||||||
|
| AddCustomNodeAction
|
||||||
|
| RemoveCustomNodeAction
|
||||||
|
| SetLatestBlockAction
|
||||||
| Web3UnsetNodeAction;
|
| Web3UnsetNodeAction;
|
||||||
|
|
|
@ -6,5 +6,8 @@ export enum TypeKeys {
|
||||||
CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE',
|
CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE',
|
||||||
CONFIG_FORCE_OFFLINE = 'CONFIG_FORCE_OFFLINE',
|
CONFIG_FORCE_OFFLINE = 'CONFIG_FORCE_OFFLINE',
|
||||||
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_REMOVE_CUSTOM_NODE = 'CONFIG_REMOVE_CUSTOM_NODE',
|
||||||
|
CONFIG_SET_LATEST_BLOCK = 'CONFIG_SET_LATEST_BLOCK',
|
||||||
CONFIG_NODE_WEB3_UNSET = 'CONFIG_NODE_WEB3_UNSET'
|
CONFIG_NODE_WEB3_UNSET = 'CONFIG_NODE_WEB3_UNSET'
|
||||||
}
|
}
|
||||||
|
|
|
@ -92,11 +92,15 @@ const LINKS_SOCIAL = [
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ComponentState {
|
interface Props {
|
||||||
|
latestBlock: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Footer extends React.Component<{}, ComponentState> {
|
export default class Footer extends React.Component<Props, State> {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { isOpen: false };
|
this.state = { isOpen: false };
|
||||||
|
@ -276,9 +280,7 @@ export default class Footer extends React.Component<{}, ComponentState> {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
<p>Latest Block#: {this.props.latestBlock}</p>
|
||||||
{/* TODO: Fix me */}
|
|
||||||
<p>Latest Block#: ?????</p>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,230 @@
|
||||||
|
import React from 'react';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import Modal, { IButton } from 'components/ui/Modal';
|
||||||
|
import translate from 'translations';
|
||||||
|
import { NETWORKS, CustomNodeConfig } from 'config/data';
|
||||||
|
|
||||||
|
const NETWORK_KEYS = Object.keys(NETWORKS);
|
||||||
|
|
||||||
|
interface Input {
|
||||||
|
name: string;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handleAddCustomNode(node: CustomNodeConfig): void;
|
||||||
|
handleClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
port: string;
|
||||||
|
network: string;
|
||||||
|
hasAuth: boolean;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class CustomNodeModal extends React.Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
port: '',
|
||||||
|
network: NETWORK_KEYS[0],
|
||||||
|
hasAuth: false,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const { handleClose } = this.props;
|
||||||
|
const isHttps = window.location.protocol.includes('https');
|
||||||
|
const invalids = this.getInvalids();
|
||||||
|
|
||||||
|
const buttons: IButton[] = [{
|
||||||
|
type: 'primary',
|
||||||
|
text: translate('NODE_CTA'),
|
||||||
|
onClick: this.saveAndAdd,
|
||||||
|
disabled: !!Object.keys(invalids).length,
|
||||||
|
}, {
|
||||||
|
text: translate('x_Cancel'),
|
||||||
|
onClick: handleClose
|
||||||
|
}];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={translate('NODE_Title')}
|
||||||
|
isOpen={true}
|
||||||
|
buttons={buttons}
|
||||||
|
handleClose={handleClose}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{isHttps &&
|
||||||
|
<div className="alert alert-danger small">
|
||||||
|
{translate('NODE_Warning')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-7">
|
||||||
|
<label>{translate('NODE_Name')}</label>
|
||||||
|
{this.renderInput({
|
||||||
|
name: 'name',
|
||||||
|
placeholder: 'My Node',
|
||||||
|
}, invalids)}
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-5">
|
||||||
|
<label>Network</label>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
name="network"
|
||||||
|
value={this.state.network}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
>
|
||||||
|
{NETWORK_KEYS.map((net) =>
|
||||||
|
<option key={net} value={net}>{net}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-9">
|
||||||
|
<label>URL</label>
|
||||||
|
{this.renderInput({
|
||||||
|
name: 'url',
|
||||||
|
placeholder: 'http://127.0.0.1/',
|
||||||
|
}, invalids)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<label>{translate('NODE_Port')}</label>
|
||||||
|
{this.renderInput({
|
||||||
|
name: 'port',
|
||||||
|
placeholder: '8545',
|
||||||
|
type: 'number',
|
||||||
|
}, invalids)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="hasAuth"
|
||||||
|
checked={this.state.hasAuth}
|
||||||
|
onChange={this.handleCheckbox}
|
||||||
|
/>
|
||||||
|
{' '}
|
||||||
|
<span>HTTP Basic Authentication</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{this.state.hasAuth &&
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-6">
|
||||||
|
<label>Username</label>
|
||||||
|
{this.renderInput({ name: 'username' }, invalids)}
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6">
|
||||||
|
<label>Password</label>
|
||||||
|
{this.renderInput({
|
||||||
|
name: 'password',
|
||||||
|
type: 'password',
|
||||||
|
}, invalids)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderInput(input: Input, invalids: { [key: string]: boolean }) {
|
||||||
|
return <input
|
||||||
|
className={classnames({
|
||||||
|
'form-control': true,
|
||||||
|
'is-invalid': this.state[input.name] && invalids[input.name],
|
||||||
|
})}
|
||||||
|
value={this.state[name]}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
{...input}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInvalids(): { [key: string]: boolean } {
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
port,
|
||||||
|
hasAuth,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
} = this.state;
|
||||||
|
const required = ["name", "url", "port", "network"];
|
||||||
|
const invalids: { [key: string]: boolean } = {};
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
required.forEach((field) => {
|
||||||
|
if (!this.state[field]) {
|
||||||
|
invalids[field] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Somewhat valid URL, not 100% fool-proof
|
||||||
|
if (!/https?\:\/\/\w+/i.test(url)) {
|
||||||
|
invalids.url = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric port within range
|
||||||
|
const iport = parseInt(port, 10);
|
||||||
|
if (!iport || iport < 1 || iport > 65535) {
|
||||||
|
invalids.port = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If they have auth, make sure it's provided
|
||||||
|
if (hasAuth) {
|
||||||
|
if (!username) {
|
||||||
|
invalids.username = true;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
invalids.password = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return invalids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleChange = (ev: React.FormEvent<
|
||||||
|
HTMLInputElement | HTMLSelectElement
|
||||||
|
>) => {
|
||||||
|
const { name, value } = ev.currentTarget;
|
||||||
|
this.setState({ [name as any]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleCheckbox = (ev: React.FormEvent<HTMLInputElement>) => {
|
||||||
|
const { name } = ev.currentTarget;
|
||||||
|
this.setState({ [name as any]: !this.state[name] });
|
||||||
|
};
|
||||||
|
|
||||||
|
private saveAndAdd = () => {
|
||||||
|
const node: CustomNodeConfig = {
|
||||||
|
name: this.state.name.trim(),
|
||||||
|
url: this.state.url.trim(),
|
||||||
|
port: parseInt(this.state.port, 10),
|
||||||
|
network: this.state.network,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.state.hasAuth) {
|
||||||
|
node.auth = {
|
||||||
|
username: this.state.username,
|
||||||
|
password: this.state.password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.handleAddCustomNode(node);
|
||||||
|
};
|
||||||
|
}
|
|
@ -65,7 +65,7 @@ export default class Navigation extends Component<Props, State> {
|
||||||
/*
|
/*
|
||||||
* public scrollLeft() {}
|
* public scrollLeft() {}
|
||||||
public scrollRight() {}
|
public scrollRight() {}
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
|
|
|
@ -15,6 +15,15 @@ $small-size: 900px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes dropdown-is-flashing {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
.Header {
|
.Header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
@ -124,6 +133,11 @@ $small-size: 900px;
|
||||||
padding-top: $space-sm !important;
|
padding-top: $space-sm !important;
|
||||||
padding-bottom: $space-sm !important;
|
padding-bottom: $space-sm !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-flashing {
|
||||||
|
pointer-events: none;
|
||||||
|
animation: dropdown-is-flashing 800ms ease infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
import {
|
import {
|
||||||
TChangeGasPrice,
|
TChangeGasPrice,
|
||||||
TChangeLanguage,
|
TChangeLanguage,
|
||||||
TChangeNodeIntent
|
TChangeNodeIntent,
|
||||||
|
TAddCustomNode,
|
||||||
|
TRemoveCustomNode
|
||||||
} 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';
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
import classnames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ANNOUNCEMENT_MESSAGE,
|
ANNOUNCEMENT_MESSAGE,
|
||||||
|
@ -13,44 +16,85 @@ import {
|
||||||
languages,
|
languages,
|
||||||
NETWORKS,
|
NETWORKS,
|
||||||
NODES,
|
NODES,
|
||||||
VERSION
|
VERSION,
|
||||||
|
NodeConfig,
|
||||||
|
CustomNodeConfig
|
||||||
} from '../../config/data';
|
} 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 { getKeyByValue } from 'utils/helpers';
|
import { getKeyByValue } from 'utils/helpers';
|
||||||
|
import { makeCustomNodeId } from 'utils/node';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
languageSelection: string;
|
languageSelection: string;
|
||||||
|
node: NodeConfig;
|
||||||
nodeSelection: string;
|
nodeSelection: string;
|
||||||
|
isChangingNode: boolean;
|
||||||
gasPriceGwei: number;
|
gasPriceGwei: number;
|
||||||
|
customNodes: CustomNodeConfig[];
|
||||||
|
|
||||||
changeLanguage: TChangeLanguage;
|
changeLanguage: TChangeLanguage;
|
||||||
changeNodeIntent: TChangeNodeIntent;
|
changeNodeIntent: TChangeNodeIntent;
|
||||||
changeGasPrice: TChangeGasPrice;
|
changeGasPrice: TChangeGasPrice;
|
||||||
|
addCustomNode: TAddCustomNode;
|
||||||
|
removeCustomNode: TRemoveCustomNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Header extends Component<Props, {}> {
|
interface State {
|
||||||
|
isAddingCustomNode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Header extends Component<Props, State> {
|
||||||
|
public state = {
|
||||||
|
isAddingCustomNode: false
|
||||||
|
};
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const { languageSelection, changeNodeIntent, nodeSelection } = this.props;
|
const {
|
||||||
|
languageSelection,
|
||||||
|
changeNodeIntent,
|
||||||
|
node,
|
||||||
|
nodeSelection,
|
||||||
|
isChangingNode,
|
||||||
|
customNodes
|
||||||
|
} = this.props;
|
||||||
|
const { isAddingCustomNode } = this.state;
|
||||||
const selectedLanguage = languageSelection;
|
const selectedLanguage = languageSelection;
|
||||||
const selectedNode = NODES[nodeSelection];
|
const selectedNetwork = NETWORKS[node.network];
|
||||||
const selectedNetwork = NETWORKS[selectedNode.network];
|
|
||||||
const LanguageDropDown = Dropdown as new () => Dropdown<
|
const LanguageDropDown = Dropdown as new () => Dropdown<
|
||||||
typeof selectedLanguage
|
typeof selectedLanguage
|
||||||
>;
|
>;
|
||||||
const nodeOptions = Object.keys(NODES).map(key => {
|
|
||||||
return {
|
const nodeOptions = Object.keys(NODES)
|
||||||
value: key,
|
.map(key => {
|
||||||
name: (
|
return {
|
||||||
<span>
|
value: key,
|
||||||
{NODES[key].network} <small>({NODES[key].service})</small>
|
name: (
|
||||||
</span>
|
<span>
|
||||||
),
|
{NODES[key].network} <small>({NODES[key].service})</small>
|
||||||
color: NETWORKS[NODES[key].network].color,
|
</span>
|
||||||
hidden: NODES[key].hidden
|
),
|
||||||
};
|
color: NETWORKS[NODES[key].network].color,
|
||||||
});
|
hidden: NODES[key].hidden
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.concat(
|
||||||
|
customNodes.map(customNode => {
|
||||||
|
return {
|
||||||
|
value: makeCustomNodeId(customNode),
|
||||||
|
name: (
|
||||||
|
<span>
|
||||||
|
{customNode.network} - {customNode.name} <small>(custom)</small>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
color: '#000',
|
||||||
|
hidden: false,
|
||||||
|
onRemove: () => this.props.removeCustomNode(customNode)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Header">
|
<div className="Header">
|
||||||
|
@ -66,7 +110,7 @@ export default class Header extends Component<Props, {}> {
|
||||||
<section className="Header-branding">
|
<section className="Header-branding">
|
||||||
<section className="Header-branding-inner container">
|
<section className="Header-branding-inner container">
|
||||||
<Link
|
<Link
|
||||||
to={'/'}
|
to="/"
|
||||||
className="Header-branding-title"
|
className="Header-branding-title"
|
||||||
aria-label="Go to homepage"
|
aria-label="Go to homepage"
|
||||||
>
|
>
|
||||||
|
@ -109,22 +153,29 @@ export default class Header extends Component<Props, {}> {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="Header-branding-right-dropdown">
|
<div
|
||||||
|
className={classnames({
|
||||||
|
'Header-branding-right-dropdown': true,
|
||||||
|
'is-flashing': isChangingNode
|
||||||
|
})}
|
||||||
|
>
|
||||||
<ColorDropdown
|
<ColorDropdown
|
||||||
ariaLabel={`change node. current node ${
|
ariaLabel={`
|
||||||
selectedNode.network
|
change node. current node ${node.network}
|
||||||
} node by ${selectedNode.service}`}
|
node by ${node.service}
|
||||||
|
`}
|
||||||
options={nodeOptions}
|
options={nodeOptions}
|
||||||
value={nodeSelection}
|
value={nodeSelection}
|
||||||
extra={
|
extra={
|
||||||
<li>
|
<li>
|
||||||
<a>Add Custom Node</a>
|
<a onClick={this.openCustomNodeModal}>Add Custom Node</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
disabled={nodeSelection === 'web3'}
|
disabled={nodeSelection === 'web3'}
|
||||||
onChange={changeNodeIntent}
|
onChange={changeNodeIntent}
|
||||||
size="smr"
|
size="smr"
|
||||||
color="white"
|
color="white"
|
||||||
|
menuAlign="right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -132,6 +183,13 @@ export default class Header extends Component<Props, {}> {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Navigation color={selectedNetwork.color} />
|
<Navigation color={selectedNetwork.color} />
|
||||||
|
|
||||||
|
{isAddingCustomNode && (
|
||||||
|
<CustomNodeModal
|
||||||
|
handleAddCustomNode={this.addCustomNode}
|
||||||
|
handleClose={this.closeCustomNodeModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -142,4 +200,17 @@ export default class Header extends Component<Props, {}> {
|
||||||
this.props.changeLanguage(key);
|
this.props.changeLanguage(key);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private openCustomNodeModal = () => {
|
||||||
|
this.setState({ isAddingCustomNode: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
private closeCustomNodeModal = () => {
|
||||||
|
this.setState({ isAddingCustomNode: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
private addCustomNode = (node: CustomNodeConfig) => {
|
||||||
|
this.setState({ isAddingCustomNode: false });
|
||||||
|
this.props.addCustomNode(node);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
.ColorDropdown {
|
||||||
|
&-item {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 10px;
|
||||||
|
border-left: 2px solid;
|
||||||
|
|
||||||
|
&-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 5px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
// Z fixes clipping issue
|
||||||
|
transform: translateY(-50%) translateZ(0);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,15 @@
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import DropdownShell from './DropdownShell';
|
import DropdownShell from './DropdownShell';
|
||||||
|
import removeIcon from 'assets/images/icon-remove.svg';
|
||||||
|
import './ColorDropdown.scss';
|
||||||
|
|
||||||
interface Option<T> {
|
interface Option<T> {
|
||||||
name: any;
|
name: any;
|
||||||
value: T;
|
value: T;
|
||||||
color?: string;
|
color?: string;
|
||||||
hidden: boolean | undefined;
|
hidden: boolean | undefined;
|
||||||
|
onRemove?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
|
@ -67,6 +70,7 @@ export default class ColorDropdown<T> extends Component<Props<T>, {}> {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const menuClass = classnames({
|
const menuClass = classnames({
|
||||||
|
ColorDropdown: true,
|
||||||
'dropdown-menu': true,
|
'dropdown-menu': true,
|
||||||
[`dropdown-menu-${menuAlign || ''}`]: !!menuAlign
|
[`dropdown-menu-${menuAlign || ''}`]: !!menuAlign
|
||||||
});
|
});
|
||||||
|
@ -78,12 +82,24 @@ export default class ColorDropdown<T> extends Component<Props<T>, {}> {
|
||||||
return <li key={i} role="separator" className="divider" />;
|
return <li key={i} role="separator" className="divider" />;
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<li key={i} style={{ borderLeft: `2px solid ${option.color}` }}>
|
<li
|
||||||
|
key={i}
|
||||||
|
className="ColorDropdown-item"
|
||||||
|
style={{ borderColor: option.color }}
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
className={option.value === value ? 'active' : ''}
|
className={option.value === value ? 'active' : ''}
|
||||||
onClick={this.onChange.bind(null, option.value)}
|
onClick={this.onChange.bind(null, option.value)}
|
||||||
>
|
>
|
||||||
{option.name}
|
{option.name}
|
||||||
|
|
||||||
|
{option.onRemove && (
|
||||||
|
<img
|
||||||
|
className="ColorDropdown-item-remove"
|
||||||
|
onClick={this.onRemove.bind(null, option.onRemove)}
|
||||||
|
src={removeIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
@ -102,6 +118,17 @@ export default class ColorDropdown<T> extends Component<Props<T>, {}> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onRemove(
|
||||||
|
onRemove: () => void,
|
||||||
|
ev?: React.SyntheticEvent<HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
if (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
onRemove();
|
||||||
|
}
|
||||||
|
|
||||||
private getActiveOption() {
|
private getActiveOption() {
|
||||||
return this.props.options.find(opt => opt.value === this.props.value);
|
return this.props.options.find(opt => opt.value === this.props.value);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import React, { Component } from 'react';
|
||||||
import './Modal.scss';
|
import './Modal.scss';
|
||||||
|
|
||||||
export interface IButton {
|
export interface IButton {
|
||||||
text: string;
|
text: string | React.ReactElement<string>;
|
||||||
type?:
|
type?:
|
||||||
| 'default'
|
| 'default'
|
||||||
| 'primary'
|
| 'primary'
|
||||||
|
@ -17,7 +17,7 @@ export interface IButton {
|
||||||
}
|
}
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
title: string;
|
title: string | React.ReactElement<any>;
|
||||||
disableButtons?: boolean;
|
disableButtons?: boolean;
|
||||||
children: any;
|
children: any;
|
||||||
buttons: IButton[];
|
buttons: IButton[];
|
||||||
|
|
|
@ -81,6 +81,17 @@ export interface NodeConfig {
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomNodeConfig {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
port: number;
|
||||||
|
network: string;
|
||||||
|
auth?: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Must be a website that follows the ethplorer convention of /tx/[hash] and
|
// Must be a website that follows the ethplorer convention of /tx/[hash] and
|
||||||
// address/[address] to generate the correct functions.
|
// address/[address] to generate the correct functions.
|
||||||
function makeExplorer(url): BlockExplorerConfig {
|
function makeExplorer(url): BlockExplorerConfig {
|
||||||
|
|
|
@ -2,50 +2,72 @@ import {
|
||||||
changeGasPrice as dChangeGasPrice,
|
changeGasPrice as dChangeGasPrice,
|
||||||
changeLanguage as dChangeLanguage,
|
changeLanguage as dChangeLanguage,
|
||||||
changeNodeIntent as dChangeNodeIntent,
|
changeNodeIntent as dChangeNodeIntent,
|
||||||
|
addCustomNode as dAddCustomNode,
|
||||||
|
removeCustomNode as dRemoveCustomNode,
|
||||||
TChangeGasPrice,
|
TChangeGasPrice,
|
||||||
TChangeLanguage,
|
TChangeLanguage,
|
||||||
TChangeNodeIntent
|
TChangeNodeIntent,
|
||||||
|
TAddCustomNode,
|
||||||
|
TRemoveCustomNode,
|
||||||
} from 'actions/config';
|
} from 'actions/config';
|
||||||
import { AlphaAgreement, Footer, Header } from 'components';
|
import { AlphaAgreement, Footer, Header } from 'components';
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { connect } from 'react-redux';
|
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 Props {
|
||||||
// FIXME
|
// FIXME
|
||||||
children: any;
|
children: any;
|
||||||
|
|
||||||
languageSelection: string;
|
languageSelection: string;
|
||||||
|
node: NodeConfig;
|
||||||
nodeSelection: string;
|
nodeSelection: string;
|
||||||
|
isChangingNode: boolean;
|
||||||
gasPriceGwei: number;
|
gasPriceGwei: number;
|
||||||
|
customNodes: CustomNodeConfig[];
|
||||||
|
latestBlock: string;
|
||||||
|
|
||||||
changeLanguage: TChangeLanguage;
|
changeLanguage: TChangeLanguage;
|
||||||
changeNodeIntent: TChangeNodeIntent;
|
changeNodeIntent: TChangeNodeIntent;
|
||||||
changeGasPrice: TChangeGasPrice;
|
changeGasPrice: TChangeGasPrice;
|
||||||
|
addCustomNode: TAddCustomNode;
|
||||||
|
removeCustomNode: TRemoveCustomNode;
|
||||||
}
|
}
|
||||||
class TabSection extends Component<Props, {}> {
|
class TabSection extends Component<Props, {}> {
|
||||||
public render() {
|
public render() {
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
// APP
|
// APP
|
||||||
|
node,
|
||||||
nodeSelection,
|
nodeSelection,
|
||||||
|
isChangingNode,
|
||||||
languageSelection,
|
languageSelection,
|
||||||
gasPriceGwei,
|
gasPriceGwei,
|
||||||
|
customNodes,
|
||||||
|
latestBlock,
|
||||||
|
|
||||||
changeLanguage,
|
changeLanguage,
|
||||||
changeNodeIntent,
|
changeNodeIntent,
|
||||||
changeGasPrice
|
changeGasPrice,
|
||||||
|
addCustomNode,
|
||||||
|
removeCustomNode,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const headerProps = {
|
const headerProps = {
|
||||||
languageSelection,
|
languageSelection,
|
||||||
|
node,
|
||||||
nodeSelection,
|
nodeSelection,
|
||||||
|
isChangingNode,
|
||||||
gasPriceGwei,
|
gasPriceGwei,
|
||||||
|
customNodes,
|
||||||
|
|
||||||
changeLanguage,
|
changeLanguage,
|
||||||
changeNodeIntent,
|
changeNodeIntent,
|
||||||
changeGasPrice
|
changeGasPrice,
|
||||||
|
addCustomNode,
|
||||||
|
removeCustomNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -53,7 +75,7 @@ class TabSection extends Component<Props, {}> {
|
||||||
<main>
|
<main>
|
||||||
<Header {...headerProps} />
|
<Header {...headerProps} />
|
||||||
<div className="Tab container">{children}</div>
|
<div className="Tab container">{children}</div>
|
||||||
<Footer />
|
<Footer latestBlock={latestBlock} />
|
||||||
</main>
|
</main>
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<AlphaAgreement />
|
<AlphaAgreement />
|
||||||
|
@ -64,14 +86,20 @@ class TabSection extends Component<Props, {}> {
|
||||||
|
|
||||||
function mapStateToProps(state: AppState) {
|
function mapStateToProps(state: AppState) {
|
||||||
return {
|
return {
|
||||||
|
node: state.config.node,
|
||||||
nodeSelection: state.config.nodeSelection,
|
nodeSelection: state.config.nodeSelection,
|
||||||
|
isChangingNode: state.config.isChangingNode,
|
||||||
languageSelection: state.config.languageSelection,
|
languageSelection: state.config.languageSelection,
|
||||||
gasPriceGwei: state.config.gasPriceGwei
|
gasPriceGwei: state.config.gasPriceGwei,
|
||||||
|
customNodes: state.config.customNodes,
|
||||||
|
latestBlock: state.config.latestBlock,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(mapStateToProps, {
|
export default connect(mapStateToProps, {
|
||||||
changeGasPrice: dChangeGasPrice,
|
changeGasPrice: dChangeGasPrice,
|
||||||
changeLanguage: dChangeLanguage,
|
changeLanguage: dChangeLanguage,
|
||||||
changeNodeIntent: dChangeNodeIntent
|
changeNodeIntent: dChangeNodeIntent,
|
||||||
|
addCustomNode: dAddCustomNode,
|
||||||
|
removeCustomNode: dRemoveCustomNode,
|
||||||
})(TabSection);
|
})(TabSection);
|
||||||
|
|
|
@ -14,4 +14,5 @@ export interface INode {
|
||||||
getTransactionCount(address: string): Promise<string>;
|
getTransactionCount(address: string): Promise<string>;
|
||||||
sendRawTx(tx: string): Promise<string>;
|
sendRawTx(tx: string): Promise<string>;
|
||||||
sendCallRequest(txObj: TxObj): Promise<string>;
|
sendCallRequest(txObj: TxObj): Promise<string>;
|
||||||
|
getCurrentBlock(): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import RPCNode from '../rpc';
|
||||||
|
import RPCClient from '../rpc/client';
|
||||||
|
import { CustomNodeConfig } from 'config/data';
|
||||||
|
|
||||||
|
export default class CustomNode extends RPCNode {
|
||||||
|
constructor(config: CustomNodeConfig) {
|
||||||
|
const endpoint = `${config.url}:${config.port}`;
|
||||||
|
super(endpoint);
|
||||||
|
|
||||||
|
const headers: { [key: string]: string } = {};
|
||||||
|
if (config.auth) {
|
||||||
|
const { username, password } = config.auth;
|
||||||
|
headers.Authorization = `Basic ${btoa(`${username}:${password}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new RPCClient(endpoint, headers);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,7 +7,8 @@ import {
|
||||||
GetBalanceRequest,
|
GetBalanceRequest,
|
||||||
GetTokenBalanceRequest,
|
GetTokenBalanceRequest,
|
||||||
GetTransactionCountRequest,
|
GetTransactionCountRequest,
|
||||||
SendRawTxRequest
|
SendRawTxRequest,
|
||||||
|
GetCurrentBlockRequest
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export default class EtherscanRequests extends RPCRequests {
|
export default class EtherscanRequests extends RPCRequests {
|
||||||
|
@ -66,4 +67,11 @@ export default class EtherscanRequests extends RPCRequests {
|
||||||
data: ERC20.balanceOf(address)
|
data: ERC20.balanceOf(address)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getCurrentBlock(): GetCurrentBlockRequest {
|
||||||
|
return {
|
||||||
|
module: 'proxy',
|
||||||
|
action: 'eth_blockNumber',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,10 +41,16 @@ export interface GetTransactionCountRequest extends EtherscanReqBase {
|
||||||
tag: 'latest';
|
tag: 'latest';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GetCurrentBlockRequest extends EtherscanReqBase {
|
||||||
|
module: 'proxy';
|
||||||
|
action: 'eth_blockNumber';
|
||||||
|
}
|
||||||
|
|
||||||
export type EtherscanRequest =
|
export type EtherscanRequest =
|
||||||
| SendRawTxRequest
|
| SendRawTxRequest
|
||||||
| GetBalanceRequest
|
| GetBalanceRequest
|
||||||
| CallRequest
|
| CallRequest
|
||||||
| GetTokenBalanceRequest
|
| GetTokenBalanceRequest
|
||||||
| EstimateGasRequest
|
| EstimateGasRequest
|
||||||
| GetTransactionCountRequest;
|
| GetTransactionCountRequest
|
||||||
|
| GetCurrentBlockRequest;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export { default as RPCNode } from './rpc';
|
export { default as RPCNode } from './rpc';
|
||||||
export { default as InfuraNode } from './infura';
|
export { default as InfuraNode } from './infura';
|
||||||
export { default as EtherscanNode } from './etherscan';
|
export { default as EtherscanNode } from './etherscan';
|
||||||
|
export { default as CustomNode } from './custom';
|
||||||
export { default as Web3Node } from './web3';
|
export { default as Web3Node } from './web3';
|
||||||
|
|
|
@ -3,8 +3,10 @@ import { JsonRpcResponse, RPCRequest } from './types';
|
||||||
|
|
||||||
export default class RPCClient {
|
export default class RPCClient {
|
||||||
public endpoint: string;
|
public endpoint: string;
|
||||||
constructor(endpoint: string) {
|
public headers: object;
|
||||||
|
constructor(endpoint: string, headers: object = {}) {
|
||||||
this.endpoint = endpoint;
|
this.endpoint = endpoint;
|
||||||
|
this.headers = headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
public id(): string {
|
public id(): string {
|
||||||
|
@ -21,7 +23,8 @@ export default class RPCClient {
|
||||||
return fetch(this.endpoint, {
|
return fetch(this.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
...this.headers,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(this.decorateRequest(request))
|
body: JSON.stringify(this.decorateRequest(request))
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
|
@ -31,7 +34,8 @@ export default class RPCClient {
|
||||||
return fetch(this.endpoint, {
|
return fetch(this.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
...this.headers,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requests.map(this.decorateRequest))
|
body: JSON.stringify(requests.map(this.decorateRequest))
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
|
|
|
@ -1,10 +1,19 @@
|
||||||
|
import BN from 'bn.js';
|
||||||
import { Token } from 'config/data';
|
import { Token } from 'config/data';
|
||||||
import { TransactionWithoutGas } from 'libs/messages';
|
import { TransactionWithoutGas } from 'libs/messages';
|
||||||
import { Wei, TokenValue } from 'libs/units';
|
import { Wei, TokenValue } from 'libs/units';
|
||||||
|
import { stripHexPrefix } from 'libs/values';
|
||||||
import { INode, TxObj } from '../INode';
|
import { INode, TxObj } from '../INode';
|
||||||
import RPCClient from './client';
|
import RPCClient from './client';
|
||||||
import RPCRequests from './requests';
|
import RPCRequests from './requests';
|
||||||
|
|
||||||
|
function errorOrResult(response) {
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
return response.result;
|
||||||
|
}
|
||||||
|
|
||||||
export default class RpcNode implements INode {
|
export default class RpcNode implements INode {
|
||||||
public client: RPCClient;
|
public client: RPCClient;
|
||||||
public requests: RPCRequests;
|
public requests: RPCRequests;
|
||||||
|
@ -25,23 +34,15 @@ export default class RpcNode implements INode {
|
||||||
public getBalance(address: string): Promise<Wei> {
|
public getBalance(address: string): Promise<Wei> {
|
||||||
return this.client
|
return this.client
|
||||||
.call(this.requests.getBalance(address))
|
.call(this.requests.getBalance(address))
|
||||||
.then(response => {
|
.then(errorOrResult)
|
||||||
if (response.error) {
|
.then(result => Wei(result));
|
||||||
throw new Error(response.error.message);
|
|
||||||
}
|
|
||||||
return Wei(response.result);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public estimateGas(transaction: TransactionWithoutGas): Promise<Wei> {
|
public estimateGas(transaction: TransactionWithoutGas): Promise<Wei> {
|
||||||
return this.client
|
return this.client
|
||||||
.call(this.requests.estimateGas(transaction))
|
.call(this.requests.estimateGas(transaction))
|
||||||
.then(response => {
|
.then(errorOrResult)
|
||||||
if (response.error) {
|
.then(result => Wei(result));
|
||||||
throw new Error(response.error.message);
|
|
||||||
}
|
|
||||||
return Wei(response.result);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTokenBalance(address: string, token: Token): Promise<TokenValue> {
|
public getTokenBalance(address: string, token: Token): Promise<TokenValue> {
|
||||||
|
@ -77,12 +78,14 @@ export default class RpcNode implements INode {
|
||||||
public getTransactionCount(address: string): Promise<string> {
|
public getTransactionCount(address: string): Promise<string> {
|
||||||
return this.client
|
return this.client
|
||||||
.call(this.requests.getTransactionCount(address))
|
.call(this.requests.getTransactionCount(address))
|
||||||
.then(response => {
|
.then(errorOrResult);
|
||||||
if (response.error) {
|
}
|
||||||
throw new Error(response.error.message);
|
|
||||||
}
|
public getCurrentBlock(): Promise<string> {
|
||||||
return response.result;
|
return this.client
|
||||||
});
|
.call(this.requests.getCurrentBlock())
|
||||||
|
.then(errorOrResult)
|
||||||
|
.then(result => new BN(stripHexPrefix(result)).toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendRawTx(signedTx: string): Promise<string> {
|
public sendRawTx(signedTx: string): Promise<string> {
|
||||||
|
|
|
@ -6,7 +6,8 @@ import {
|
||||||
GetBalanceRequest,
|
GetBalanceRequest,
|
||||||
GetTokenBalanceRequest,
|
GetTokenBalanceRequest,
|
||||||
GetTransactionCountRequest,
|
GetTransactionCountRequest,
|
||||||
SendRawTxRequest
|
SendRawTxRequest,
|
||||||
|
GetCurrentBlockRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { hexEncodeData } from './utils';
|
import { hexEncodeData } from './utils';
|
||||||
import { TxObj } from '../INode';
|
import { TxObj } from '../INode';
|
||||||
|
@ -63,4 +64,10 @@ export default class RPCRequests {
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getCurrentBlock(): GetCurrentBlockRequest | any {
|
||||||
|
return {
|
||||||
|
method: 'eth_blockNumber',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,9 +69,14 @@ export interface GetTransactionCountRequest extends RPCRequestBase {
|
||||||
params: [DATA, DEFAULT_BLOCK];
|
params: [DATA, DEFAULT_BLOCK];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GetCurrentBlockRequest extends RPCRequestBase {
|
||||||
|
method: 'eth_blockNumber'
|
||||||
|
}
|
||||||
|
|
||||||
export type RPCRequest =
|
export type RPCRequest =
|
||||||
| GetBalanceRequest
|
| GetBalanceRequest
|
||||||
| GetTokenBalanceRequest
|
| GetTokenBalanceRequest
|
||||||
| CallRequest
|
| CallRequest
|
||||||
| EstimateGasRequest
|
| EstimateGasRequest
|
||||||
| GetTransactionCountRequest;
|
| GetTransactionCountRequest
|
||||||
|
| GetCurrentBlockRequest;
|
||||||
|
|
|
@ -126,6 +126,17 @@ export default class Web3Node implements INode {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getCurrentBlock(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
this.web3.eth.getBlock('latest', false, (err, block) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
resolve(block.number);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public sendRawTx(signedTx: string): Promise<string> {
|
public sendRawTx(signedTx: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) =>
|
return new Promise((resolve, reject) =>
|
||||||
this.web3.eth.sendRawTransaction(signedTx, (err, txHash) => {
|
this.web3.eth.sendRawTransaction(signedTx, (err, txHash) => {
|
||||||
|
|
|
@ -2,26 +2,43 @@ import {
|
||||||
ChangeGasPriceAction,
|
ChangeGasPriceAction,
|
||||||
ChangeLanguageAction,
|
ChangeLanguageAction,
|
||||||
ChangeNodeAction,
|
ChangeNodeAction,
|
||||||
ConfigAction
|
AddCustomNodeAction,
|
||||||
|
RemoveCustomNodeAction,
|
||||||
|
SetLatestBlockAction,
|
||||||
|
ConfigAction,
|
||||||
} from 'actions/config';
|
} from 'actions/config';
|
||||||
import { TypeKeys } from 'actions/config/constants';
|
import { TypeKeys } from 'actions/config/constants';
|
||||||
import { NODES } from '../config/data';
|
import {
|
||||||
|
NODES,
|
||||||
|
NodeConfig,
|
||||||
|
CustomNodeConfig,
|
||||||
|
} from '../config/data';
|
||||||
|
import { makeCustomNodeId } from 'utils/node';
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
// FIXME
|
// FIXME
|
||||||
languageSelection: string;
|
languageSelection: string;
|
||||||
nodeSelection: string;
|
nodeSelection: string;
|
||||||
|
node: NodeConfig;
|
||||||
|
isChangingNode: boolean;
|
||||||
gasPriceGwei: number;
|
gasPriceGwei: number;
|
||||||
offline: boolean;
|
offline: boolean;
|
||||||
forceOffline: boolean;
|
forceOffline: boolean;
|
||||||
|
customNodes: CustomNodeConfig[];
|
||||||
|
latestBlock: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultNode = 'eth_mew';
|
||||||
export const INITIAL_STATE: State = {
|
export const INITIAL_STATE: State = {
|
||||||
languageSelection: 'en',
|
languageSelection: 'en',
|
||||||
nodeSelection: Object.keys(NODES)[0],
|
nodeSelection: defaultNode,
|
||||||
|
node: NODES[defaultNode],
|
||||||
|
isChangingNode: false,
|
||||||
gasPriceGwei: 21,
|
gasPriceGwei: 21,
|
||||||
offline: false,
|
offline: false,
|
||||||
forceOffline: false
|
forceOffline: false,
|
||||||
|
customNodes: [],
|
||||||
|
latestBlock: "???",
|
||||||
};
|
};
|
||||||
|
|
||||||
function changeLanguage(state: State, action: ChangeLanguageAction): State {
|
function changeLanguage(state: State, action: ChangeLanguageAction): State {
|
||||||
|
@ -34,7 +51,16 @@ function changeLanguage(state: State, action: ChangeLanguageAction): State {
|
||||||
function changeNode(state: State, action: ChangeNodeAction): State {
|
function changeNode(state: State, action: ChangeNodeAction): State {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
nodeSelection: action.payload
|
nodeSelection: action.payload.nodeSelection,
|
||||||
|
node: action.payload.node,
|
||||||
|
isChangingNode: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeNodeIntent(state: State): State {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isChangingNode: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,6 +85,33 @@ function forceOffline(state: State): State {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addCustomNode(state: State, action: AddCustomNodeAction): State {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
customNodes: [
|
||||||
|
...state.customNodes,
|
||||||
|
action.payload,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomNode(state: State, action: RemoveCustomNodeAction): State {
|
||||||
|
const id = makeCustomNodeId(action.payload);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
customNodes: state.customNodes.filter((cn) => cn !== action.payload),
|
||||||
|
nodeSelection: id === state.nodeSelection ?
|
||||||
|
defaultNode : state.nodeSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLatestBlock(state: State, action: SetLatestBlockAction): State {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
latestBlock: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function config(
|
export function config(
|
||||||
state: State = INITIAL_STATE,
|
state: State = INITIAL_STATE,
|
||||||
action: ConfigAction
|
action: ConfigAction
|
||||||
|
@ -68,12 +121,20 @@ export function config(
|
||||||
return changeLanguage(state, action);
|
return changeLanguage(state, action);
|
||||||
case TypeKeys.CONFIG_NODE_CHANGE:
|
case TypeKeys.CONFIG_NODE_CHANGE:
|
||||||
return changeNode(state, action);
|
return changeNode(state, action);
|
||||||
|
case TypeKeys.CONFIG_NODE_CHANGE_INTENT:
|
||||||
|
return changeNodeIntent(state);
|
||||||
case TypeKeys.CONFIG_GAS_PRICE:
|
case TypeKeys.CONFIG_GAS_PRICE:
|
||||||
return changeGasPrice(state, action);
|
return changeGasPrice(state, action);
|
||||||
case TypeKeys.CONFIG_TOGGLE_OFFLINE:
|
case TypeKeys.CONFIG_TOGGLE_OFFLINE:
|
||||||
return toggleOffline(state);
|
return toggleOffline(state);
|
||||||
case TypeKeys.CONFIG_FORCE_OFFLINE:
|
case TypeKeys.CONFIG_FORCE_OFFLINE:
|
||||||
return forceOffline(state);
|
return forceOffline(state);
|
||||||
|
case TypeKeys.CONFIG_ADD_CUSTOM_NODE:
|
||||||
|
return addCustomNode(state, action);
|
||||||
|
case TypeKeys.CONFIG_REMOVE_CUSTOM_NODE:
|
||||||
|
return removeCustomNode(state, action);
|
||||||
|
case TypeKeys.CONFIG_SET_LATEST_BLOCK:
|
||||||
|
return setLatestBlock(state, action);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,20 +7,30 @@ import {
|
||||||
take,
|
take,
|
||||||
takeLatest,
|
takeLatest,
|
||||||
takeEvery,
|
takeEvery,
|
||||||
select
|
select,
|
||||||
|
race
|
||||||
} from 'redux-saga/effects';
|
} from 'redux-saga/effects';
|
||||||
import { NODES } from 'config/data';
|
import { NODES } from 'config/data';
|
||||||
import { Web3Wallet } from 'libs/wallet';
|
import {
|
||||||
import { getNode, getNodeConfig } from 'selectors/config';
|
makeCustomNodeId,
|
||||||
import { getWalletInst } from 'selectors/wallet';
|
getCustomNodeConfigFromId,
|
||||||
|
makeNodeConfigFromCustomConfig
|
||||||
|
} from 'utils/node';
|
||||||
|
import { getNode, getNodeConfig, getCustomNodeConfigs } from 'selectors/config';
|
||||||
import { AppState } from 'reducers';
|
import { AppState } from 'reducers';
|
||||||
import { TypeKeys } from 'actions/config/constants';
|
import { TypeKeys } from 'actions/config/constants';
|
||||||
import { TypeKeys as WalletTypeKeys } from 'actions/wallet/constants';
|
|
||||||
import {
|
import {
|
||||||
toggleOfflineConfig,
|
toggleOfflineConfig,
|
||||||
changeNode,
|
changeNode,
|
||||||
changeNodeIntent
|
changeNodeIntent,
|
||||||
|
setLatestBlock,
|
||||||
|
AddCustomNodeAction
|
||||||
} from 'actions/config';
|
} from 'actions/config';
|
||||||
|
import { showNotification } from 'actions/notifications';
|
||||||
|
import translate from 'translations';
|
||||||
|
import { Web3Wallet } from 'libs/wallet';
|
||||||
|
import { getWalletInst } from 'selectors/wallet';
|
||||||
|
import { TypeKeys as WalletTypeKeys } from 'actions/wallet/constants';
|
||||||
import {
|
import {
|
||||||
State as ConfigState,
|
State as ConfigState,
|
||||||
INITIAL_STATE as configInitialState
|
INITIAL_STATE as configInitialState
|
||||||
|
@ -54,19 +64,68 @@ function* reload(): SagaIterator {
|
||||||
}
|
}
|
||||||
|
|
||||||
function* handleNodeChangeIntent(action): SagaIterator {
|
function* handleNodeChangeIntent(action): SagaIterator {
|
||||||
const nodeConfig = yield select(getNodeConfig);
|
const currentNode = yield select(getNode);
|
||||||
const currentNetwork = nodeConfig.network;
|
const currentConfig = yield select(getNodeConfig);
|
||||||
const actionNetwork = NODES[action.payload].network;
|
|
||||||
const currentWallet = yield select(getWalletInst);
|
const currentWallet = yield select(getWalletInst);
|
||||||
|
const currentNetwork = currentConfig.network;
|
||||||
|
|
||||||
yield put(changeNode(action.payload));
|
let actionConfig = NODES[action.payload];
|
||||||
|
if (!actionConfig) {
|
||||||
|
const customConfigs = yield select(getCustomNodeConfigs);
|
||||||
|
const config = getCustomNodeConfigFromId(action.payload, customConfigs);
|
||||||
|
if (config) {
|
||||||
|
actionConfig = makeNodeConfigFromCustomConfig(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actionConfig) {
|
||||||
|
yield put(
|
||||||
|
showNotification(
|
||||||
|
'danger',
|
||||||
|
`Attempted to switch to unknown node '${action.payload}'`,
|
||||||
|
5000
|
||||||
|
)
|
||||||
|
);
|
||||||
|
yield put(changeNode(currentNode, currentConfig));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab latest block from the node, before switching, to confirm it's online
|
||||||
|
// Give it 5 seconds before we call it offline
|
||||||
|
let latestBlock;
|
||||||
|
let timeout;
|
||||||
|
try {
|
||||||
|
const { lb, to } = yield race({
|
||||||
|
lb: call(actionConfig.lib.getCurrentBlock.bind(actionConfig.lib)),
|
||||||
|
to: call(delay, 5000)
|
||||||
|
});
|
||||||
|
latestBlock = lb;
|
||||||
|
timeout = to;
|
||||||
|
} catch (err) {
|
||||||
|
// Whether it times out or errors, same message
|
||||||
|
timeout = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeout) {
|
||||||
|
yield put(showNotification('danger', translate('ERROR_32'), 5000));
|
||||||
|
yield put(changeNode(currentNode, currentConfig));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield put(setLatestBlock(latestBlock));
|
||||||
|
yield put(changeNode(action.payload, actionConfig));
|
||||||
|
|
||||||
// if there's no wallet, do not reload as there's no component state to resync
|
// if there's no wallet, do not reload as there's no component state to resync
|
||||||
if (currentWallet && currentNetwork !== actionNetwork) {
|
if (currentWallet && currentNetwork !== actionConfig.network) {
|
||||||
yield call(reload);
|
yield call(reload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function* switchToNewNode(action: AddCustomNodeAction): SagaIterator {
|
||||||
|
const nodeId = makeCustomNodeId(action.payload);
|
||||||
|
yield put(changeNodeIntent(nodeId));
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
function* unsetWeb3Node(action): SagaIterator {
|
function* unsetWeb3Node(action): SagaIterator {
|
||||||
const node = yield select(getNode);
|
const node = yield select(getNode);
|
||||||
|
@ -107,6 +166,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(WalletTypeKeys.WALLET_SET, unsetWeb3Node);
|
yield takeEvery(WalletTypeKeys.WALLET_SET, unsetWeb3Node);
|
||||||
yield takeEvery(WalletTypeKeys.WALLET_RESET, unsetWeb3Node);
|
yield takeEvery(WalletTypeKeys.WALLET_RESET, unsetWeb3Node);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,15 @@
|
||||||
@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,
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {
|
||||||
NetworkContract,
|
NetworkContract,
|
||||||
NETWORKS,
|
NETWORKS,
|
||||||
NodeConfig,
|
NodeConfig,
|
||||||
NODES
|
CustomNodeConfig
|
||||||
} 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';
|
||||||
|
@ -13,15 +13,15 @@ export function getNode(state: AppState): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeConfig(state: AppState): NodeConfig {
|
export function getNodeConfig(state: AppState): NodeConfig {
|
||||||
return NODES[state.config.nodeSelection];
|
return state.config.node;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeLib(state: AppState): INode {
|
export function getNodeLib(state: AppState): INode {
|
||||||
return NODES[state.config.nodeSelection].lib;
|
return getNodeConfig(state).lib;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNetworkConfig(state: AppState): NetworkConfig {
|
export function getNetworkConfig(state: AppState): NetworkConfig {
|
||||||
return NETWORKS[NODES[state.config.nodeSelection].network];
|
return NETWORKS[getNodeConfig(state).network];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNetworkContracts(state: AppState): NetworkContract[] | null {
|
export function getNetworkContracts(state: AppState): NetworkContract[] | null {
|
||||||
|
@ -35,3 +35,7 @@ export function getGasPriceGwei(state: AppState): number {
|
||||||
export function getLanguageSelection(state: AppState): string {
|
export function getLanguageSelection(state: AppState): string {
|
||||||
return state.config.languageSelection;
|
return state.config.languageSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCustomNodeConfigs(state: AppState): CustomNodeConfig[] {
|
||||||
|
return state.config.customNodes;
|
||||||
|
}
|
||||||
|
|
|
@ -9,9 +9,11 @@ import { createLogger } from 'redux-logger';
|
||||||
import createSagaMiddleware from 'redux-saga';
|
import createSagaMiddleware from 'redux-saga';
|
||||||
import { loadStatePropertyOrEmptyObject, saveState } from 'utils/localStorage';
|
import { loadStatePropertyOrEmptyObject, saveState } from 'utils/localStorage';
|
||||||
import RootReducer from './reducers';
|
import RootReducer from './reducers';
|
||||||
|
import { State as ConfigState } from './reducers/config';
|
||||||
import { State as CustomTokenState } from './reducers/customTokens';
|
import { State as CustomTokenState } from './reducers/customTokens';
|
||||||
import { State as SwapState } from './reducers/swap';
|
import { State as SwapState } from './reducers/swap';
|
||||||
import promiseMiddleware from 'redux-promise-middleware';
|
import promiseMiddleware from 'redux-promise-middleware';
|
||||||
|
import { getNodeConfigFromId } from 'utils/node';
|
||||||
|
|
||||||
import sagas from './sagas';
|
import sagas from './sagas';
|
||||||
|
|
||||||
|
@ -56,10 +58,29 @@ const configureStore = () => {
|
||||||
'customTokens'
|
'customTokens'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const savedConfigState = loadStatePropertyOrEmptyObject<ConfigState>(
|
||||||
|
'config'
|
||||||
|
);
|
||||||
|
|
||||||
|
// If they have a saved node, make sure we assign that too. The node selected
|
||||||
|
// isn't serializable, so we have to assign it here.
|
||||||
|
if (savedConfigState && savedConfigState.nodeSelection) {
|
||||||
|
const savedNode = getNodeConfigFromId(
|
||||||
|
savedConfigState.nodeSelection,
|
||||||
|
savedConfigState.customNodes
|
||||||
|
);
|
||||||
|
// If we couldn't find it, revert to defaults
|
||||||
|
if (savedNode) {
|
||||||
|
savedConfigState.node = savedNode;
|
||||||
|
} else {
|
||||||
|
savedConfigState.nodeSelection = configInitialState.nodeSelection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const persistedInitialState = {
|
const persistedInitialState = {
|
||||||
config: {
|
config: {
|
||||||
...configInitialState,
|
...configInitialState,
|
||||||
...loadStatePropertyOrEmptyObject('config')
|
...savedConfigState
|
||||||
},
|
},
|
||||||
customTokens: localCustomTokens || customTokensInitialState,
|
customTokens: localCustomTokens || customTokensInitialState,
|
||||||
// ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3
|
// ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3
|
||||||
|
@ -82,14 +103,16 @@ const configureStore = () => {
|
||||||
|
|
||||||
store.subscribe(
|
store.subscribe(
|
||||||
throttle(() => {
|
throttle(() => {
|
||||||
|
const state = store.getState();
|
||||||
saveState({
|
saveState({
|
||||||
config: {
|
config: {
|
||||||
gasPriceGwei: store.getState().config.gasPriceGwei,
|
gasPriceGwei: state.config.gasPriceGwei,
|
||||||
nodeSelection: store.getState().config.nodeSelection,
|
nodeSelection: state.config.nodeSelection,
|
||||||
languageSelection: store.getState().config.languageSelection
|
languageSelection: state.config.languageSelection,
|
||||||
|
customNodes: state.config.customNodes
|
||||||
},
|
},
|
||||||
swap: store.getState().swap,
|
swap: state.swap,
|
||||||
customTokens: store.getState().customTokens
|
customTokens: state.customTokens
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
1000
|
1000
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { CustomNode } from 'libs/nodes';
|
||||||
|
import { NODES, NodeConfig, CustomNodeConfig } from 'config/data';
|
||||||
|
|
||||||
|
export function makeCustomNodeId(config: CustomNodeConfig): string {
|
||||||
|
return `${config.url}:${config.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomNodeConfigFromId(
|
||||||
|
id: string, configs: CustomNodeConfig[]
|
||||||
|
): CustomNodeConfig | undefined {
|
||||||
|
return configs.find((node) => makeCustomNodeId(node) === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeConfigFromId(
|
||||||
|
id: string, configs: CustomNodeConfig[]
|
||||||
|
): NodeConfig | undefined {
|
||||||
|
if (NODES[id]) {
|
||||||
|
return NODES[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getCustomNodeConfigFromId(id, configs);
|
||||||
|
if (config) {
|
||||||
|
return makeNodeConfigFromCustomConfig(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeNodeConfigFromCustomConfig(
|
||||||
|
config: CustomNodeConfig
|
||||||
|
): NodeConfig {
|
||||||
|
return {
|
||||||
|
network: config.network,
|
||||||
|
lib: new CustomNode(config),
|
||||||
|
service: "your custom node",
|
||||||
|
estimateGas: true,
|
||||||
|
};
|
||||||
|
}
|
|
@ -4,13 +4,16 @@ import Adapter from 'enzyme-adapter-react-16';
|
||||||
import SendTransaction from 'containers/Tabs/SendTransaction';
|
import SendTransaction from 'containers/Tabs/SendTransaction';
|
||||||
import shallowWithStore from '../utils/shallowWithStore';
|
import shallowWithStore from '../utils/shallowWithStore';
|
||||||
import { createMockStore } from 'redux-test-utils';
|
import { createMockStore } from 'redux-test-utils';
|
||||||
|
import { NODES } from 'config/data';
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
it('render snapshot', () => {
|
it('render snapshot', () => {
|
||||||
|
const testNode = 'rop_mew';
|
||||||
const testStateConfig = {
|
const testStateConfig = {
|
||||||
languageSelection: 'en',
|
languageSelection: 'en',
|
||||||
nodeSelection: 'rop_mew',
|
nodeSelection: testNode,
|
||||||
|
node: NODES[testNode],
|
||||||
gasPriceGwei: 21,
|
gasPriceGwei: 21,
|
||||||
offline: false,
|
offline: false,
|
||||||
forceOffline: false
|
forceOffline: false
|
||||||
|
|
|
@ -58,6 +58,7 @@ exports[`render snapshot 1`] = `
|
||||||
"call": [Function],
|
"call": [Function],
|
||||||
"decorateRequest": [Function],
|
"decorateRequest": [Function],
|
||||||
"endpoint": "https://api.myetherapi.com/rop",
|
"endpoint": "https://api.myetherapi.com/rop",
|
||||||
|
"headers": Object {},
|
||||||
},
|
},
|
||||||
"requests": RPCRequests {},
|
"requests": RPCRequests {},
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
import { config, INITIAL_STATE } from 'reducers/config';
|
import { config, INITIAL_STATE } from 'reducers/config';
|
||||||
import * as configActions from 'actions/config';
|
import * as configActions from 'actions/config';
|
||||||
import { NODES } from 'config/data';
|
import { NODES } from 'config/data';
|
||||||
|
import { makeCustomNodeId, makeNodeConfigFromCustomConfig } from 'utils/node';
|
||||||
|
|
||||||
|
const custNode = {
|
||||||
|
name: 'Test Config',
|
||||||
|
url: 'http://somecustomconfig.org/',
|
||||||
|
port: 443,
|
||||||
|
network: 'ETH'
|
||||||
|
};
|
||||||
|
|
||||||
describe('config reducer', () => {
|
describe('config reducer', () => {
|
||||||
it('should handle CONFIG_LANGUAGE_CHANGE', () => {
|
it('should handle CONFIG_LANGUAGE_CHANGE', () => {
|
||||||
|
@ -12,11 +20,14 @@ describe('config reducer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle CONFIG_NODE_CHANGE', () => {
|
it('should handle CONFIG_NODE_CHANGE', () => {
|
||||||
const node = Object.keys(NODES)[0];
|
const key = Object.keys(NODES)[0];
|
||||||
|
|
||||||
expect(config(undefined, configActions.changeNode(node))).toEqual({
|
expect(
|
||||||
|
config(undefined, configActions.changeNode(key, NODES[key]))
|
||||||
|
).toEqual({
|
||||||
...INITIAL_STATE,
|
...INITIAL_STATE,
|
||||||
nodeSelection: node
|
node: NODES[key],
|
||||||
|
nodeSelection: key
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -76,4 +87,42 @@ describe('config reducer', () => {
|
||||||
forceOffline: true
|
forceOffline: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle CONFIG_ADD_CUSTOM_NODE', () => {
|
||||||
|
expect(config(undefined, configActions.addCustomNode(custNode))).toEqual({
|
||||||
|
...INITIAL_STATE,
|
||||||
|
customNodes: [custNode]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should handle CONFIG_REMOVE_CUSTOM_NODE', () => {
|
||||||
|
const customNodeId = makeCustomNodeId(custNode);
|
||||||
|
const addedState = config(undefined, configActions.addCustomNode(custNode));
|
||||||
|
const addedAndActiveState = config(
|
||||||
|
addedState,
|
||||||
|
configActions.changeNode(
|
||||||
|
customNodeId,
|
||||||
|
makeNodeConfigFromCustomConfig(custNode)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const removedState = config(
|
||||||
|
addedAndActiveState,
|
||||||
|
configActions.removeCustomNode(custNode)
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should remove the custom node from `customNodes`', () => {
|
||||||
|
expect(removedState.customNodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change the active node, if the custom one was active', () => {
|
||||||
|
expect(removedState.nodeSelection === customNodeId).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle CONFIG_SET_LATEST_BLOCK', () => {
|
||||||
|
expect(config(undefined, configActions.setLatestBlock('12345'))).toEqual({
|
||||||
|
...INITIAL_STATE,
|
||||||
|
latestBlock: '12345'
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import {
|
||||||
|
makeCustomNodeId,
|
||||||
|
getCustomNodeConfigFromId,
|
||||||
|
getNodeConfigFromId,
|
||||||
|
makeNodeConfigFromCustomConfig
|
||||||
|
} from 'utils/node';
|
||||||
|
|
||||||
|
const custNode = {
|
||||||
|
name: 'Test Config',
|
||||||
|
url: 'http://somecustomconfig.org/',
|
||||||
|
port: 443,
|
||||||
|
network: 'ETH'
|
||||||
|
};
|
||||||
|
const custNodeId = 'http://somecustomconfig.org/:443';
|
||||||
|
|
||||||
|
describe('makeCustomNodeId', () => {
|
||||||
|
it('should construct an ID from url:port', () => {
|
||||||
|
expect(makeCustomNodeId(custNode) === custNodeId).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCustomNodeConfigFromId', () => {
|
||||||
|
it('should fetch the correct config, given its ID', () => {
|
||||||
|
expect(getCustomNodeConfigFromId(custNodeId, [custNode])).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNodeConfigFromId', () => {
|
||||||
|
it('should fetch the correct config, given its ID', () => {
|
||||||
|
expect(getNodeConfigFromId(custNodeId, [custNode])).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeNodeConfigFromCustomConfig', () => {
|
||||||
|
it('Should create a node config from a custom config', () => {
|
||||||
|
expect(makeNodeConfigFromCustomConfig(custNode)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue