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 { TypeKeys } from './constants';
|
||||
import { NodeConfig, CustomNodeConfig } from 'config/data';
|
||||
|
||||
export type TForceOfflineConfig = typeof forceOfflineConfig;
|
||||
export function forceOfflineConfig(): interfaces.ForceOfflineAction {
|
||||
|
@ -24,10 +25,13 @@ export function changeLanguage(sign: string): interfaces.ChangeLanguageAction {
|
|||
}
|
||||
|
||||
export type TChangeNode = typeof changeNode;
|
||||
export function changeNode(value: string): interfaces.ChangeNodeAction {
|
||||
export function changeNode(
|
||||
nodeSelection: string,
|
||||
node: NodeConfig
|
||||
): interfaces.ChangeNodeAction {
|
||||
return {
|
||||
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 function web3UnsetNode(): interfaces.Web3UnsetNodeAction {
|
||||
return {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { TypeKeys } from './constants';
|
||||
import { CustomNodeConfig, NodeConfig } from 'config/data';
|
||||
|
||||
/*** Toggle Offline ***/
|
||||
export interface ToggleOfflineAction {
|
||||
|
@ -20,7 +21,10 @@ export interface ChangeLanguageAction {
|
|||
export interface ChangeNodeAction {
|
||||
type: TypeKeys.CONFIG_NODE_CHANGE;
|
||||
// FIXME $keyof?
|
||||
payload: string;
|
||||
payload: {
|
||||
nodeSelection: string;
|
||||
node: NodeConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/*** Change gas price ***/
|
||||
|
@ -40,6 +44,24 @@ export interface ChangeNodeIntentAction {
|
|||
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 ***/
|
||||
export interface Web3UnsetNodeAction {
|
||||
type: TypeKeys.CONFIG_NODE_WEB3_UNSET;
|
||||
|
@ -54,4 +76,7 @@ export type ConfigAction =
|
|||
| PollOfflineStatus
|
||||
| ForceOfflineAction
|
||||
| ChangeNodeIntentAction
|
||||
| AddCustomNodeAction
|
||||
| RemoveCustomNodeAction
|
||||
| SetLatestBlockAction
|
||||
| Web3UnsetNodeAction;
|
||||
|
|
|
@ -6,5 +6,8 @@ export enum TypeKeys {
|
|||
CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE',
|
||||
CONFIG_FORCE_OFFLINE = 'CONFIG_FORCE_OFFLINE',
|
||||
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'
|
||||
}
|
||||
|
|
|
@ -92,11 +92,15 @@ const LINKS_SOCIAL = [
|
|||
}
|
||||
];
|
||||
|
||||
interface ComponentState {
|
||||
interface Props {
|
||||
latestBlock: string;
|
||||
};
|
||||
|
||||
interface State {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default class Footer extends React.Component<{}, ComponentState> {
|
||||
export default class Footer extends React.Component<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { isOpen: false };
|
||||
|
@ -276,9 +280,7 @@ export default class Footer extends React.Component<{}, ComponentState> {
|
|||
);
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* TODO: Fix me */}
|
||||
<p>Latest Block#: ?????</p>
|
||||
<p>Latest Block#: {this.props.latestBlock}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</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);
|
||||
};
|
||||
}
|
|
@ -15,6 +15,15 @@ $small-size: 900px;
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes dropdown-is-flashing {
|
||||
0%, 100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
.Header {
|
||||
margin-bottom: 2rem;
|
||||
|
@ -124,6 +133,11 @@ $small-size: 900px;
|
|||
padding-top: $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 {
|
||||
TChangeGasPrice,
|
||||
TChangeLanguage,
|
||||
TChangeNodeIntent
|
||||
TChangeNodeIntent,
|
||||
TAddCustomNode,
|
||||
TRemoveCustomNode
|
||||
} from 'actions/config';
|
||||
import logo from 'assets/images/logo-myetherwallet.svg';
|
||||
import { Dropdown, ColorDropdown } from 'components/ui';
|
||||
import React, { Component } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ANNOUNCEMENT_MESSAGE,
|
||||
|
@ -13,33 +16,59 @@ import {
|
|||
languages,
|
||||
NETWORKS,
|
||||
NODES,
|
||||
VERSION
|
||||
VERSION,
|
||||
NodeConfig,
|
||||
CustomNodeConfig
|
||||
} from '../../config/data';
|
||||
import GasPriceDropdown from './components/GasPriceDropdown';
|
||||
import Navigation from './components/Navigation';
|
||||
import CustomNodeModal from './components/CustomNodeModal';
|
||||
import { getKeyByValue } from 'utils/helpers';
|
||||
import { makeCustomNodeId } from 'utils/node';
|
||||
import './index.scss';
|
||||
|
||||
interface Props {
|
||||
languageSelection: string;
|
||||
node: NodeConfig;
|
||||
nodeSelection: string;
|
||||
isChangingNode: boolean;
|
||||
gasPriceGwei: number;
|
||||
customNodes: CustomNodeConfig[];
|
||||
|
||||
changeLanguage: TChangeLanguage;
|
||||
changeNodeIntent: TChangeNodeIntent;
|
||||
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() {
|
||||
const { languageSelection, changeNodeIntent, nodeSelection } = this.props;
|
||||
const {
|
||||
languageSelection,
|
||||
changeNodeIntent,
|
||||
node,
|
||||
nodeSelection,
|
||||
isChangingNode,
|
||||
customNodes
|
||||
} = this.props;
|
||||
const { isAddingCustomNode } = this.state;
|
||||
const selectedLanguage = languageSelection;
|
||||
const selectedNode = NODES[nodeSelection];
|
||||
const selectedNetwork = NETWORKS[selectedNode.network];
|
||||
const selectedNetwork = NETWORKS[node.network];
|
||||
const LanguageDropDown = Dropdown as new () => Dropdown<
|
||||
typeof selectedLanguage
|
||||
>;
|
||||
const nodeOptions = Object.keys(NODES).map(key => {
|
||||
|
||||
const nodeOptions = Object.keys(NODES)
|
||||
.map(key => {
|
||||
return {
|
||||
value: key,
|
||||
name: (
|
||||
|
@ -50,7 +79,22 @@ export default class Header extends Component<Props, {}> {
|
|||
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 (
|
||||
<div className="Header">
|
||||
|
@ -66,7 +110,7 @@ export default class Header extends Component<Props, {}> {
|
|||
<section className="Header-branding">
|
||||
<section className="Header-branding-inner container">
|
||||
<Link
|
||||
to={'/'}
|
||||
to="/"
|
||||
className="Header-branding-title"
|
||||
aria-label="Go to homepage"
|
||||
>
|
||||
|
@ -109,22 +153,29 @@ export default class Header extends Component<Props, {}> {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="Header-branding-right-dropdown">
|
||||
<div
|
||||
className={classnames({
|
||||
'Header-branding-right-dropdown': true,
|
||||
'is-flashing': isChangingNode
|
||||
})}
|
||||
>
|
||||
<ColorDropdown
|
||||
ariaLabel={`change node. current node ${
|
||||
selectedNode.network
|
||||
} node by ${selectedNode.service}`}
|
||||
ariaLabel={`
|
||||
change node. current node ${node.network}
|
||||
node by ${node.service}
|
||||
`}
|
||||
options={nodeOptions}
|
||||
value={nodeSelection}
|
||||
extra={
|
||||
<li>
|
||||
<a>Add Custom Node</a>
|
||||
<a onClick={this.openCustomNodeModal}>Add Custom Node</a>
|
||||
</li>
|
||||
}
|
||||
disabled={nodeSelection === 'web3'}
|
||||
onChange={changeNodeIntent}
|
||||
size="smr"
|
||||
color="white"
|
||||
menuAlign="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -132,6 +183,13 @@ export default class Header extends Component<Props, {}> {
|
|||
</section>
|
||||
|
||||
<Navigation color={selectedNetwork.color} />
|
||||
|
||||
{isAddingCustomNode && (
|
||||
<CustomNodeModal
|
||||
handleAddCustomNode={this.addCustomNode}
|
||||
handleClose={this.closeCustomNodeModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -142,4 +200,17 @@ export default class Header extends Component<Props, {}> {
|
|||
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 classnames from 'classnames';
|
||||
import DropdownShell from './DropdownShell';
|
||||
import removeIcon from 'assets/images/icon-remove.svg';
|
||||
import './ColorDropdown.scss';
|
||||
|
||||
interface Option<T> {
|
||||
name: any;
|
||||
value: T;
|
||||
color?: string;
|
||||
hidden: boolean | undefined;
|
||||
onRemove?(): void;
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
|
@ -67,6 +70,7 @@ export default class ColorDropdown<T> extends Component<Props<T>, {}> {
|
|||
}, []);
|
||||
|
||||
const menuClass = classnames({
|
||||
ColorDropdown: true,
|
||||
'dropdown-menu': true,
|
||||
[`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" />;
|
||||
} else {
|
||||
return (
|
||||
<li key={i} style={{ borderLeft: `2px solid ${option.color}` }}>
|
||||
<li
|
||||
key={i}
|
||||
className="ColorDropdown-item"
|
||||
style={{ borderColor: option.color }}
|
||||
>
|
||||
<a
|
||||
className={option.value === value ? 'active' : ''}
|
||||
onClick={this.onChange.bind(null, option.value)}
|
||||
>
|
||||
{option.name}
|
||||
|
||||
{option.onRemove && (
|
||||
<img
|
||||
className="ColorDropdown-item-remove"
|
||||
onClick={this.onRemove.bind(null, option.onRemove)}
|
||||
src={removeIcon}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
</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() {
|
||||
return this.props.options.find(opt => opt.value === this.props.value);
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import React, { Component } from 'react';
|
|||
import './Modal.scss';
|
||||
|
||||
export interface IButton {
|
||||
text: string;
|
||||
text: string | React.ReactElement<string>;
|
||||
type?:
|
||||
| 'default'
|
||||
| 'primary'
|
||||
|
@ -17,7 +17,7 @@ export interface IButton {
|
|||
}
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
title: string;
|
||||
title: string | React.ReactElement<any>;
|
||||
disableButtons?: boolean;
|
||||
children: any;
|
||||
buttons: IButton[];
|
||||
|
|
|
@ -81,6 +81,17 @@ export interface NodeConfig {
|
|||
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
|
||||
// address/[address] to generate the correct functions.
|
||||
function makeExplorer(url): BlockExplorerConfig {
|
||||
|
|
|
@ -2,50 +2,72 @@ import {
|
|||
changeGasPrice as dChangeGasPrice,
|
||||
changeLanguage as dChangeLanguage,
|
||||
changeNodeIntent as dChangeNodeIntent,
|
||||
addCustomNode as dAddCustomNode,
|
||||
removeCustomNode as dRemoveCustomNode,
|
||||
TChangeGasPrice,
|
||||
TChangeLanguage,
|
||||
TChangeNodeIntent
|
||||
TChangeNodeIntent,
|
||||
TAddCustomNode,
|
||||
TRemoveCustomNode,
|
||||
} from 'actions/config';
|
||||
import { AlphaAgreement, Footer, Header } from 'components';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from 'reducers';
|
||||
import Notifications from './Notifications';
|
||||
import { NodeConfig, CustomNodeConfig } from 'config/data';
|
||||
|
||||
interface Props {
|
||||
// FIXME
|
||||
children: any;
|
||||
|
||||
languageSelection: string;
|
||||
node: NodeConfig;
|
||||
nodeSelection: string;
|
||||
|
||||
isChangingNode: boolean;
|
||||
gasPriceGwei: number;
|
||||
customNodes: CustomNodeConfig[];
|
||||
latestBlock: string;
|
||||
|
||||
changeLanguage: TChangeLanguage;
|
||||
changeNodeIntent: TChangeNodeIntent;
|
||||
changeGasPrice: TChangeGasPrice;
|
||||
addCustomNode: TAddCustomNode;
|
||||
removeCustomNode: TRemoveCustomNode;
|
||||
}
|
||||
class TabSection extends Component<Props, {}> {
|
||||
public render() {
|
||||
const {
|
||||
children,
|
||||
// APP
|
||||
node,
|
||||
nodeSelection,
|
||||
isChangingNode,
|
||||
languageSelection,
|
||||
gasPriceGwei,
|
||||
customNodes,
|
||||
latestBlock,
|
||||
|
||||
changeLanguage,
|
||||
changeNodeIntent,
|
||||
changeGasPrice
|
||||
changeGasPrice,
|
||||
addCustomNode,
|
||||
removeCustomNode,
|
||||
} = this.props;
|
||||
|
||||
const headerProps = {
|
||||
languageSelection,
|
||||
node,
|
||||
nodeSelection,
|
||||
isChangingNode,
|
||||
gasPriceGwei,
|
||||
customNodes,
|
||||
|
||||
changeLanguage,
|
||||
changeNodeIntent,
|
||||
changeGasPrice
|
||||
changeGasPrice,
|
||||
addCustomNode,
|
||||
removeCustomNode,
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -53,7 +75,7 @@ class TabSection extends Component<Props, {}> {
|
|||
<main>
|
||||
<Header {...headerProps} />
|
||||
<div className="Tab container">{children}</div>
|
||||
<Footer />
|
||||
<Footer latestBlock={latestBlock} />
|
||||
</main>
|
||||
<Notifications />
|
||||
<AlphaAgreement />
|
||||
|
@ -64,14 +86,20 @@ class TabSection extends Component<Props, {}> {
|
|||
|
||||
function mapStateToProps(state: AppState) {
|
||||
return {
|
||||
node: state.config.node,
|
||||
nodeSelection: state.config.nodeSelection,
|
||||
isChangingNode: state.config.isChangingNode,
|
||||
languageSelection: state.config.languageSelection,
|
||||
gasPriceGwei: state.config.gasPriceGwei
|
||||
gasPriceGwei: state.config.gasPriceGwei,
|
||||
customNodes: state.config.customNodes,
|
||||
latestBlock: state.config.latestBlock,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
changeGasPrice: dChangeGasPrice,
|
||||
changeLanguage: dChangeLanguage,
|
||||
changeNodeIntent: dChangeNodeIntent
|
||||
changeNodeIntent: dChangeNodeIntent,
|
||||
addCustomNode: dAddCustomNode,
|
||||
removeCustomNode: dRemoveCustomNode,
|
||||
})(TabSection);
|
||||
|
|
|
@ -14,4 +14,5 @@ export interface INode {
|
|||
getTransactionCount(address: string): Promise<string>;
|
||||
sendRawTx(tx: string): 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,
|
||||
GetTokenBalanceRequest,
|
||||
GetTransactionCountRequest,
|
||||
SendRawTxRequest
|
||||
SendRawTxRequest,
|
||||
GetCurrentBlockRequest
|
||||
} from './types';
|
||||
|
||||
export default class EtherscanRequests extends RPCRequests {
|
||||
|
@ -66,4 +67,11 @@ export default class EtherscanRequests extends RPCRequests {
|
|||
data: ERC20.balanceOf(address)
|
||||
});
|
||||
}
|
||||
|
||||
public getCurrentBlock(): GetCurrentBlockRequest {
|
||||
return {
|
||||
module: 'proxy',
|
||||
action: 'eth_blockNumber',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,10 +41,16 @@ export interface GetTransactionCountRequest extends EtherscanReqBase {
|
|||
tag: 'latest';
|
||||
}
|
||||
|
||||
export interface GetCurrentBlockRequest extends EtherscanReqBase {
|
||||
module: 'proxy';
|
||||
action: 'eth_blockNumber';
|
||||
}
|
||||
|
||||
export type EtherscanRequest =
|
||||
| SendRawTxRequest
|
||||
| GetBalanceRequest
|
||||
| CallRequest
|
||||
| GetTokenBalanceRequest
|
||||
| EstimateGasRequest
|
||||
| GetTransactionCountRequest;
|
||||
| GetTransactionCountRequest
|
||||
| GetCurrentBlockRequest;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export { default as RPCNode } from './rpc';
|
||||
export { default as InfuraNode } from './infura';
|
||||
export { default as EtherscanNode } from './etherscan';
|
||||
export { default as CustomNode } from './custom';
|
||||
export { default as Web3Node } from './web3';
|
||||
|
|
|
@ -3,8 +3,10 @@ import { JsonRpcResponse, RPCRequest } from './types';
|
|||
|
||||
export default class RPCClient {
|
||||
public endpoint: string;
|
||||
constructor(endpoint: string) {
|
||||
public headers: object;
|
||||
constructor(endpoint: string, headers: object = {}) {
|
||||
this.endpoint = endpoint;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
public id(): string {
|
||||
|
@ -21,7 +23,8 @@ export default class RPCClient {
|
|||
return fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
...this.headers,
|
||||
},
|
||||
body: JSON.stringify(this.decorateRequest(request))
|
||||
}).then(r => r.json());
|
||||
|
@ -31,7 +34,8 @@ export default class RPCClient {
|
|||
return fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
...this.headers,
|
||||
},
|
||||
body: JSON.stringify(requests.map(this.decorateRequest))
|
||||
}).then(r => r.json());
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import BN from 'bn.js';
|
||||
import { Token } from 'config/data';
|
||||
import { TransactionWithoutGas } from 'libs/messages';
|
||||
import { Wei, TokenValue } from 'libs/units';
|
||||
import { stripHexPrefix } from 'libs/values';
|
||||
import { INode, TxObj } from '../INode';
|
||||
import RPCClient from './client';
|
||||
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 {
|
||||
public client: RPCClient;
|
||||
public requests: RPCRequests;
|
||||
|
@ -25,23 +34,15 @@ export default class RpcNode implements INode {
|
|||
public getBalance(address: string): Promise<Wei> {
|
||||
return this.client
|
||||
.call(this.requests.getBalance(address))
|
||||
.then(response => {
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return Wei(response.result);
|
||||
});
|
||||
.then(errorOrResult)
|
||||
.then(result => Wei(result));
|
||||
}
|
||||
|
||||
public estimateGas(transaction: TransactionWithoutGas): Promise<Wei> {
|
||||
return this.client
|
||||
.call(this.requests.estimateGas(transaction))
|
||||
.then(response => {
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return Wei(response.result);
|
||||
});
|
||||
.then(errorOrResult)
|
||||
.then(result => Wei(result));
|
||||
}
|
||||
|
||||
public getTokenBalance(address: string, token: Token): Promise<TokenValue> {
|
||||
|
@ -77,12 +78,14 @@ export default class RpcNode implements INode {
|
|||
public getTransactionCount(address: string): Promise<string> {
|
||||
return this.client
|
||||
.call(this.requests.getTransactionCount(address))
|
||||
.then(response => {
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
.then(errorOrResult);
|
||||
}
|
||||
return response.result;
|
||||
});
|
||||
|
||||
public getCurrentBlock(): Promise<string> {
|
||||
return this.client
|
||||
.call(this.requests.getCurrentBlock())
|
||||
.then(errorOrResult)
|
||||
.then(result => new BN(stripHexPrefix(result)).toString());
|
||||
}
|
||||
|
||||
public sendRawTx(signedTx: string): Promise<string> {
|
||||
|
|
|
@ -6,7 +6,8 @@ import {
|
|||
GetBalanceRequest,
|
||||
GetTokenBalanceRequest,
|
||||
GetTransactionCountRequest,
|
||||
SendRawTxRequest
|
||||
SendRawTxRequest,
|
||||
GetCurrentBlockRequest,
|
||||
} from './types';
|
||||
import { hexEncodeData } from './utils';
|
||||
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];
|
||||
}
|
||||
|
||||
export interface GetCurrentBlockRequest extends RPCRequestBase {
|
||||
method: 'eth_blockNumber'
|
||||
}
|
||||
|
||||
export type RPCRequest =
|
||||
| GetBalanceRequest
|
||||
| GetTokenBalanceRequest
|
||||
| CallRequest
|
||||
| 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> {
|
||||
return new Promise((resolve, reject) =>
|
||||
this.web3.eth.sendRawTransaction(signedTx, (err, txHash) => {
|
||||
|
|
|
@ -2,26 +2,43 @@ import {
|
|||
ChangeGasPriceAction,
|
||||
ChangeLanguageAction,
|
||||
ChangeNodeAction,
|
||||
ConfigAction
|
||||
AddCustomNodeAction,
|
||||
RemoveCustomNodeAction,
|
||||
SetLatestBlockAction,
|
||||
ConfigAction,
|
||||
} from 'actions/config';
|
||||
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 {
|
||||
// FIXME
|
||||
languageSelection: string;
|
||||
nodeSelection: string;
|
||||
node: NodeConfig;
|
||||
isChangingNode: boolean;
|
||||
gasPriceGwei: number;
|
||||
offline: boolean;
|
||||
forceOffline: boolean;
|
||||
customNodes: CustomNodeConfig[];
|
||||
latestBlock: string;
|
||||
}
|
||||
|
||||
const defaultNode = 'eth_mew';
|
||||
export const INITIAL_STATE: State = {
|
||||
languageSelection: 'en',
|
||||
nodeSelection: Object.keys(NODES)[0],
|
||||
nodeSelection: defaultNode,
|
||||
node: NODES[defaultNode],
|
||||
isChangingNode: false,
|
||||
gasPriceGwei: 21,
|
||||
offline: false,
|
||||
forceOffline: false
|
||||
forceOffline: false,
|
||||
customNodes: [],
|
||||
latestBlock: "???",
|
||||
};
|
||||
|
||||
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 {
|
||||
return {
|
||||
...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(
|
||||
state: State = INITIAL_STATE,
|
||||
action: ConfigAction
|
||||
|
@ -68,12 +121,20 @@ export function config(
|
|||
return changeLanguage(state, action);
|
||||
case TypeKeys.CONFIG_NODE_CHANGE:
|
||||
return changeNode(state, action);
|
||||
case TypeKeys.CONFIG_NODE_CHANGE_INTENT:
|
||||
return changeNodeIntent(state);
|
||||
case TypeKeys.CONFIG_GAS_PRICE:
|
||||
return changeGasPrice(state, action);
|
||||
case TypeKeys.CONFIG_TOGGLE_OFFLINE:
|
||||
return toggleOffline(state);
|
||||
case TypeKeys.CONFIG_FORCE_OFFLINE:
|
||||
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:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -7,20 +7,30 @@ import {
|
|||
take,
|
||||
takeLatest,
|
||||
takeEvery,
|
||||
select
|
||||
select,
|
||||
race
|
||||
} from 'redux-saga/effects';
|
||||
import { NODES } from 'config/data';
|
||||
import { Web3Wallet } from 'libs/wallet';
|
||||
import { getNode, getNodeConfig } from 'selectors/config';
|
||||
import { getWalletInst } from 'selectors/wallet';
|
||||
import {
|
||||
makeCustomNodeId,
|
||||
getCustomNodeConfigFromId,
|
||||
makeNodeConfigFromCustomConfig
|
||||
} from 'utils/node';
|
||||
import { getNode, getNodeConfig, getCustomNodeConfigs } from 'selectors/config';
|
||||
import { AppState } from 'reducers';
|
||||
import { TypeKeys } from 'actions/config/constants';
|
||||
import { TypeKeys as WalletTypeKeys } from 'actions/wallet/constants';
|
||||
import {
|
||||
toggleOfflineConfig,
|
||||
changeNode,
|
||||
changeNodeIntent
|
||||
changeNodeIntent,
|
||||
setLatestBlock,
|
||||
AddCustomNodeAction
|
||||
} 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 {
|
||||
State as ConfigState,
|
||||
INITIAL_STATE as configInitialState
|
||||
|
@ -54,19 +64,68 @@ function* reload(): SagaIterator {
|
|||
}
|
||||
|
||||
function* handleNodeChangeIntent(action): SagaIterator {
|
||||
const nodeConfig = yield select(getNodeConfig);
|
||||
const currentNetwork = nodeConfig.network;
|
||||
const actionNetwork = NODES[action.payload].network;
|
||||
const currentNode = yield select(getNode);
|
||||
const currentConfig = yield select(getNodeConfig);
|
||||
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 (currentWallet && currentNetwork !== actionNetwork) {
|
||||
if (currentWallet && currentNetwork !== actionConfig.network) {
|
||||
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
|
||||
function* unsetWeb3Node(action): SagaIterator {
|
||||
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_LANGUAGE_CHANGE, reload);
|
||||
yield takeEvery(TypeKeys.CONFIG_ADD_CUSTOM_NODE, switchToNewNode);
|
||||
yield takeEvery(WalletTypeKeys.WALLET_SET, unsetWeb3Node);
|
||||
yield takeEvery(WalletTypeKeys.WALLET_RESET, unsetWeb3Node);
|
||||
}
|
||||
|
|
|
@ -23,6 +23,15 @@
|
|||
@media screen and (max-width: $screen-xs) {
|
||||
left: 1%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #FFF;
|
||||
|
||||
&:hover {
|
||||
color: #FFF;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert,
|
||||
|
|
|
@ -3,7 +3,7 @@ import {
|
|||
NetworkContract,
|
||||
NETWORKS,
|
||||
NodeConfig,
|
||||
NODES
|
||||
CustomNodeConfig
|
||||
} from 'config/data';
|
||||
import { INode } from 'libs/nodes/INode';
|
||||
import { AppState } from 'reducers';
|
||||
|
@ -13,15 +13,15 @@ export function getNode(state: AppState): string {
|
|||
}
|
||||
|
||||
export function getNodeConfig(state: AppState): NodeConfig {
|
||||
return NODES[state.config.nodeSelection];
|
||||
return state.config.node;
|
||||
}
|
||||
|
||||
export function getNodeLib(state: AppState): INode {
|
||||
return NODES[state.config.nodeSelection].lib;
|
||||
return getNodeConfig(state).lib;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -35,3 +35,7 @@ export function getGasPriceGwei(state: AppState): number {
|
|||
export function getLanguageSelection(state: AppState): string {
|
||||
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 { loadStatePropertyOrEmptyObject, saveState } from 'utils/localStorage';
|
||||
import RootReducer from './reducers';
|
||||
import { State as ConfigState } from './reducers/config';
|
||||
import { State as CustomTokenState } from './reducers/customTokens';
|
||||
import { State as SwapState } from './reducers/swap';
|
||||
import promiseMiddleware from 'redux-promise-middleware';
|
||||
import { getNodeConfigFromId } from 'utils/node';
|
||||
|
||||
import sagas from './sagas';
|
||||
|
||||
|
@ -56,10 +58,29 @@ const configureStore = () => {
|
|||
'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 = {
|
||||
config: {
|
||||
...configInitialState,
|
||||
...loadStatePropertyOrEmptyObject('config')
|
||||
...savedConfigState
|
||||
},
|
||||
customTokens: localCustomTokens || customTokensInitialState,
|
||||
// ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3
|
||||
|
@ -82,14 +103,16 @@ const configureStore = () => {
|
|||
|
||||
store.subscribe(
|
||||
throttle(() => {
|
||||
const state = store.getState();
|
||||
saveState({
|
||||
config: {
|
||||
gasPriceGwei: store.getState().config.gasPriceGwei,
|
||||
nodeSelection: store.getState().config.nodeSelection,
|
||||
languageSelection: store.getState().config.languageSelection
|
||||
gasPriceGwei: state.config.gasPriceGwei,
|
||||
nodeSelection: state.config.nodeSelection,
|
||||
languageSelection: state.config.languageSelection,
|
||||
customNodes: state.config.customNodes
|
||||
},
|
||||
swap: store.getState().swap,
|
||||
customTokens: store.getState().customTokens
|
||||
swap: state.swap,
|
||||
customTokens: state.customTokens
|
||||
});
|
||||
}),
|
||||
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 shallowWithStore from '../utils/shallowWithStore';
|
||||
import { createMockStore } from 'redux-test-utils';
|
||||
import { NODES } from 'config/data';
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
it('render snapshot', () => {
|
||||
const testNode = 'rop_mew';
|
||||
const testStateConfig = {
|
||||
languageSelection: 'en',
|
||||
nodeSelection: 'rop_mew',
|
||||
nodeSelection: testNode,
|
||||
node: NODES[testNode],
|
||||
gasPriceGwei: 21,
|
||||
offline: false,
|
||||
forceOffline: false
|
||||
|
|
|
@ -58,6 +58,7 @@ exports[`render snapshot 1`] = `
|
|||
"call": [Function],
|
||||
"decorateRequest": [Function],
|
||||
"endpoint": "https://api.myetherapi.com/rop",
|
||||
"headers": Object {},
|
||||
},
|
||||
"requests": RPCRequests {},
|
||||
}
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
import { config, INITIAL_STATE } from 'reducers/config';
|
||||
import * as configActions from 'actions/config';
|
||||
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', () => {
|
||||
it('should handle CONFIG_LANGUAGE_CHANGE', () => {
|
||||
|
@ -12,11 +20,14 @@ describe('config reducer', () => {
|
|||
});
|
||||
|
||||
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,
|
||||
nodeSelection: node
|
||||
node: NODES[key],
|
||||
nodeSelection: key
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -76,4 +87,42 @@ describe('config reducer', () => {
|
|||
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