From 3de60ef0c753ef35d59109c9fb77063ef6c4dd04 Mon Sep 17 00:00:00 2001 From: Richard Ramos Date: Mon, 16 Apr 2018 14:05:57 -0400 Subject: [PATCH] Components for Contract UI --- .../components/contracts-section.css | 102 ++++++++ .../components/contracts-section.html | 18 ++ lib/dashboard/components/contracts-section.js | 22 ++ .../components/contracts/account-list.js | 46 ++++ .../components/contracts/contract-context.js | 9 + .../components/contracts/contract-ui.js | 92 ++++++++ .../components/contracts/function-area.js | 24 ++ .../components/contracts/function-form.js | 88 +++++++ .../components/contracts/function.js | 219 ++++++++++++++++++ .../components/contracts/instance-selector.js | 105 +++++++++ .../components/contracts/source-area.js | 27 +++ lib/dashboard/components/contracts/tab.js | 10 + 12 files changed, 762 insertions(+) create mode 100644 lib/dashboard/components/contracts-section.css create mode 100644 lib/dashboard/components/contracts-section.html create mode 100644 lib/dashboard/components/contracts-section.js create mode 100644 lib/dashboard/components/contracts/account-list.js create mode 100644 lib/dashboard/components/contracts/contract-context.js create mode 100644 lib/dashboard/components/contracts/contract-ui.js create mode 100644 lib/dashboard/components/contracts/function-area.js create mode 100644 lib/dashboard/components/contracts/function-form.js create mode 100644 lib/dashboard/components/contracts/function.js create mode 100644 lib/dashboard/components/contracts/instance-selector.js create mode 100644 lib/dashboard/components/contracts/source-area.js create mode 100644 lib/dashboard/components/contracts/tab.js diff --git a/lib/dashboard/components/contracts-section.css b/lib/dashboard/components/contracts-section.css new file mode 100644 index 00000000..8c46c586 --- /dev/null +++ b/lib/dashboard/components/contracts-section.css @@ -0,0 +1,102 @@ +h2 { + font-size: 16px; + font-weight: bold; +} + +.scenario p { + font-size: 12px; +} + +.scenario p.note { + font-family: monospace; +} + +.scenario p.error { + color: #ff0000; + font-size: 12px; +} + +.code { + background: #dedeff; + padding: 0px 15px; + border: 1px dashed #a0a0ff; + font-family: monospace; + font-size: 12px; +} + +.code button { + background: none; + border: none; + font-weight: bold; + padding: 0 10px 3px 10px; + font-size: 20px; + position: relative; +} + +.code button:hover, +.code select:hover, +.code input:hover { + background: #a0a0ff; +} + +.code select { + background: none; + border: 1px dashed #a0a0ff; + padding: 0 10px; +} + +.code input { + background: none; + border: 1px dashed #a0a0ff; + padding: 0 10px; +} + +.scenario { + margin-bottom: 30px; +} + + + + + + + +code .Highlight-boolean { + color: #0086b3; +} + +code .Highlight-class { + color: #0086b3; +} + +code .Highlight-comment { + color: #969896; +} + +code .Highlight-constant { + color: #a71d5d; +} + +code .Highlight-function { + color: #795da3; +} + +code .Highlight-keyword { + color: #a71d5d; +} + +code .Highlight-number { + color: #0086b3; +} + +code .Highlight-operator { + color: #a71d5d; +} + +code .Highlight-punctuation { + color: #333; +} + +code .Highlight-string { + color: #df5000; +} \ No newline at end of file diff --git a/lib/dashboard/components/contracts-section.html b/lib/dashboard/components/contracts-section.html new file mode 100644 index 00000000..3068fa1d --- /dev/null +++ b/lib/dashboard/components/contracts-section.html @@ -0,0 +1,18 @@ + + + + + Contract UI + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/lib/dashboard/components/contracts-section.js b/lib/dashboard/components/contracts-section.js new file mode 100644 index 00000000..a939f82c --- /dev/null +++ b/lib/dashboard/components/contracts-section.js @@ -0,0 +1,22 @@ +import 'bootstrap'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import './contracts-section.css'; + +import EmbarkJS from 'Embark/EmbarkJS'; +import IdentityFactory from 'Embark/contracts/IdentityFactory'; // Import all contracts + +import ContractUI from './contracts/contract-ui'; + + +__embarkContext.execWhenReady(function(){ + + // Each contract should be available on window + window["IdentityFactory"] = IdentityFactory; + + ReactDOM.render( + , + document.getElementById('root') + ); + + +}); diff --git a/lib/dashboard/components/contracts/account-list.js b/lib/dashboard/components/contracts/account-list.js new file mode 100644 index 00000000..1fabd4be --- /dev/null +++ b/lib/dashboard/components/contracts/account-list.js @@ -0,0 +1,46 @@ +import ContractContext from './contract-context'; + +class AccountList extends React.Component { + constructor(props) { + super(props); + this.state = { + error: false, + errorMessage: "", + accounts: [] + }; + } + + + async handleClick(e, updateAccountsCallback){ + e.preventDefault(); + + try { + updateAccountsCallback(); + } catch(err) { + this.setState({ + error: true, + errorMessage: e.name + ': ' + e.message + }) + } + + } + + render(){ + return + {(context) => ( +
+

Get Accounts

+
+
+ await web3.eth.getAccounts(); +
+

accounts variable is available in the console

+ {this.state.error ? '

' + this.state.errorMessage + '

' : ''} +
+
+ )} +
; + } +} + +export default AccountList; diff --git a/lib/dashboard/components/contracts/contract-context.js b/lib/dashboard/components/contracts/contract-context.js new file mode 100644 index 00000000..87b2990f --- /dev/null +++ b/lib/dashboard/components/contracts/contract-context.js @@ -0,0 +1,9 @@ +const ContractContext = React.createContext({ + accounts: [], + instances: [], + updateAccounts: () => {}, + updateInstances: (_instance) => {} + +}); + +export default ContractContext; \ No newline at end of file diff --git a/lib/dashboard/components/contracts/contract-ui.js b/lib/dashboard/components/contracts/contract-ui.js new file mode 100644 index 00000000..8a3c49c5 --- /dev/null +++ b/lib/dashboard/components/contracts/contract-ui.js @@ -0,0 +1,92 @@ +import Tab from './tab'; +import AccountList from './account-list'; +import SourceArea from './source-area'; +import InstanceSelector from './instance-selector'; +import FunctionArea from './function-area'; +import ContractContext from './contract-context'; + + +class ContractUI extends React.Component { + constructor(props) { + super(props); + + this.updateInstances = this.updateInstances.bind(this); + this.updateAccounts = this.updateAccounts.bind(this); + this.handleInstanceSelection = this.handleInstanceSelection.bind(this); + + this.state = { + accounts: [], + instances: [], + selectedInstance: null, + updateAccounts: this.updateAccounts, + updateInstances: this.updateInstances + }; + + if(props.contract.options.address != null){ + this.state.instances = [props.contract.options.address]; + this.state.selectedInstance = props.contract.options.address; + } + } + + componentDidMount(){ + this.updateAccounts(); + } + + async updateAccounts(){ + let accounts = await web3.eth.getAccounts(); + window.accounts = accounts; + + console.log("%cawait web3.eth.getAccounts()", 'font-weight: bold'); + console.log(accounts); + + this.setState({accounts: accounts}); + } + + updateInstances(_instance){ + this.state.instances.push(_instance); + this.setState({ + instances: this.state.instances + }); + } + + handleInstanceSelection(_instance){ + this.props.contract.options.address = _instance; + this.setState({ + selectedInstance: _instance + }) + } + + render() { + return ( + +
+

{this.props.name} contract

+

Open your browser's console: Tools > Developer Tools

+

Remix: http://remix.ethereum.org

+ +
+ + +

Deploy

+ +
+ + + + + + + + +
+
+
+ ) + } +} + +export default ContractUI; \ No newline at end of file diff --git a/lib/dashboard/components/contracts/function-area.js b/lib/dashboard/components/contracts/function-area.js new file mode 100644 index 00000000..8123382a --- /dev/null +++ b/lib/dashboard/components/contracts/function-area.js @@ -0,0 +1,24 @@ +import FunctionForm from './function-form'; + +class FunctionArea extends React.Component { + constructor(props) { + super(props); + this.state = { }; + } + + render(){ + const type = this.props.type; + const contract = this.props.contract; + const contractName = this.props.contractName; + + return + { + this.props.contract.options.jsonInterface + .filter(item => item.type == type) + .map((item, i) => ) + } + ; + } +} + +export default FunctionArea; \ No newline at end of file diff --git a/lib/dashboard/components/contracts/function-form.js b/lib/dashboard/components/contracts/function-form.js new file mode 100644 index 00000000..1a2b549b --- /dev/null +++ b/lib/dashboard/components/contracts/function-form.js @@ -0,0 +1,88 @@ +import Function from './function'; + +class FunctionForm extends React.Component { + constructor(props) { + super(props); + this.state = { + fields: {}, + error: false, + message: null, + receipt: null + }; + + this.showResults = this.showResults.bind(this); + } + + _getFunctionParamFields(elem){ + if(this.props.abi.type == 'fallback') return ''; + + return '(' + this.props.abi.inputs + .map((input, i) => ) + .join(', ') + ')'; + } + + _getMethodType(elem){ + return (this.props.abi.constant == true || this.props.abi.stateMutability == 'view' || this.props.abi.stateMutability == 'pure') ? 'call' : 'send'; + } + + render(){ + const functionName = this.props.abi.name; + const isDuplicated = this.props.contract.options.jsonInterface.filter(x => x.name == functionName).length > 1; + const contract = this.props.contract; + const receipt = this.state.receipt; + + return
+

{this.props.abi.type == 'function' ? this.props.abi.name : (this.props.abi.type == 'fallback' ? '(fallback)' : this.props.abi.name)}

+
+
+ +
+ { receipt != null ? +
    +
  • Status: {receipt.status}
  • +
  • Transaction Hash: {receipt.transactionHash}
  • + { + receipt.events != null ? +
  • Events: +
      + { + Object.keys(receipt.events).map(function(ev, index) { + if(!isNaN(ev)) return null; + const eventAbi = contract.options.jsonInterface.filter(x => x.name == ev)[0]; + let props = []; + for(let prop in receipt.events[ev].returnValues){ + if(isNaN(prop)){ + let input = eventAbi.inputs.filter(x => x.name == prop)[0]; + props.push(prop + ': ' + + (input.type.indexOf('int') == -1 ? '"' : '') + + receipt.events[ev].returnValues[prop] + + (input.type.indexOf('int') == -1 ? '"' : '')); + } + } + return
    • {ev}({props.join(', ')})
    • ; + }) + } +
    +
  • + : '' + } +
+ : "" + } + {this.state.error ?

{this.state.message}

: '' } + {!this.state.error && this.state.message != null ?

{this.state.message}

: '' } + +
+
; + } + + showResults(_error, _message, _receipt){ + this.setState({ + error: _error, + message: _message, + receipt: _receipt + }) + } +} + +export default FunctionForm; \ No newline at end of file diff --git a/lib/dashboard/components/contracts/function.js b/lib/dashboard/components/contracts/function.js new file mode 100644 index 00000000..96b5522d --- /dev/null +++ b/lib/dashboard/components/contracts/function.js @@ -0,0 +1,219 @@ +import ContractContext from './contract-context'; + +class Function extends React.Component { + + constructor(props) { + super(props); + this.state = { + onRequest: false, + fields: {}, + methodFields: { + from: '', + to: '', + value: 0, + data: '', + gasLimit: '7000000' + }, + receipt: null + }; + + this.handleParameterChange = this.handleParameterChange.bind(this); + this.handleMethodFieldChange = this.handleMethodFieldChange.bind(this); + this.handleClick = this.handleClick.bind(this); + } + + async handleClick(e, instanceUpdateCallback){ + e.preventDefault(); + + this.setState({onRequest: true, receipt: null}); + + this.props.resultHandler(false, null, null); + + let executionParams = { + from: this.state.methodFields.from, + gasLimit: this.state.methodFields.gasLimit + } + + if(this.props.abi.payable) + executionParams.value = this.state.methodFields.value; + + if(this.props.abi.type == 'fallback'){ + executionParams.data = this.state.methodFields.data; + executionParams.to = this.props.contract.options.address; + } + + let fields = this.state.fields; + + let functionLabel = this._getFunctionLabel(); + let functionParams = this._getFunctionParamString(); + let methodParams = this._getMethodString(); + if(this.props.abi.type == "constructor") + functionParams = `{arguments: [${functionParams}]}`; + + console.log(`%cawait ${functionLabel}(${functionParams})${this.props.abi.type != 'fallback' ? '.' + this._getMethodType() : ''}${methodParams}`, 'font-weight: bold'); + + let _receipt; + + try { + if(this.props.abi.type == 'constructor'){ + let contractInstance = await this.props.contract.deploy({arguments: Object.keys(fields).map(val => fields[val])}).send(executionParams); + instanceUpdateCallback(contractInstance.options.address); + this.setState({onRequest: false}); + console.log(contractInstance.options.address); + this.props.resultHandler(false, 'New instance: ' + contractInstance.options.address); + } else { + + if(this.props.abi.type == 'fallback') + _receipt = await this.web3.eth.sendTransaction(executionParams); + else + _receipt = await this.props.contract + .methods[this.props.abi.name + '(' + this.props.abi.inputs.map(input => input.type).join(',') + ')'] + .apply(null, Object.keys(fields).map(val => fields[val])) + [this._getMethodType()](executionParams) + + if(this._getMethodType() == 'call'){ + this.props.resultHandler(false, _receipt, null); + } else { + this.props.resultHandler(false, null, _receipt); + } + + this.setState({onRequest: false, receipt: _receipt}); + console.log(_receipt); + } + + + } catch (e) { + console.error('%s: %s', e.name, e.message); + this.setState({onRequest: false}); + this.props.resultHandler(true, e.name + ": " + e.message, _receipt); + } + } + + handleParameterChange(e){ + let newState = this.state; + newState.fields[e.target.getAttribute('data-name')] = e.target.value; + this.setState(newState); + } + + handleMethodFieldChange(e){ + let newState = this.state; + newState.methodFields[e.target.getAttribute('data-param')] = e.target.value; + + if(e.target.getAttribute('data-param') == 'from'){ + newState.selectedAccount = e.target.options[e.target.selectedIndex].text; + } + this.setState(newState); + } + + _getFunctionLabel(){ + if(this.props.abi.type == 'function') + if(!this.props.duplicated) + return `${this.props.contractName}.methods.${this.props.abi.name}`; + else { + return `${this.props.contractName}.methods['${this.props.abi.name + '(' + (this.props.abi.inputs != null ? this.props.abi.inputs.map(input => input.type).join(',') : '') + ')'}']`; + } + else if(this.props.abi.type == 'fallback'){ + return `web3.eth.sendTransaction`; + } + else + return `${this.props.contractName}.deploy`; + } + + _getMethodType(){ + return (this.props.abi.constant == true || this.props.abi.stateMutability == 'view' || this.props.abi.stateMutability == 'pure') ? 'call' : 'send'; + } + + _getMethodFields(accounts){ + let methodParams; + return + from: + { + this.props.abi.payable ? + , value: + + + : '' + } + { + this._getMethodType() == 'send' ? + , gasLimit: + + + : '' + } + { + this._getMethodType() == 'send' && this.props.abi.type == 'fallback' ? + , data: + + + : '' + } + ; + } + + + _getFunctionParamFields(){ + return + { + this.props.abi.inputs + .map((input, i) => ) + .reduce((accu, elem) => { + return accu === null ? [elem] : [...accu, ', ', elem] + }, null) + } + ; + } + + _getFunctionParamString(){ + if(this.props.abi.type == 'fallback') return ''; + return this.props.abi.inputs + .map((input, i) => (input.type.indexOf('int') == -1 ? '"' : '') + this.state.fields[input.name] + (input.type.indexOf('int') == -1 ? '"' : '')) + .join(', '); + } + + _getMethodString(elem){ + let methodParams = "({"; + + methodParams += `from: ` + this.state.selectedAccount; + if(this._getMethodType() == 'send'){ + methodParams += ', gasLimit: ' + this.state.methodFields.gasLimit + if(this.props.abi.payable){ + methodParams += ', value: ' + this.state.methodFields.value + } + if(this.props.abi.type == 'fallback'){ + methodParams += ', data: "' + this.state.methodFields.data + '", to: "' + this.state.methodFields.to + '"' + } + } + return methodParams + "})"; + } + + render(){ + return + { (context) => ( + + await {this._getFunctionLabel()} + { this.props.abi.type != 'fallback' ? '(' : '' } + { this.props.abi.type != 'fallback' ? this._getFunctionParamFields() : '' } + { this.props.abi.type != 'fallback' ? ')' : '' } + { this.props.abi.type != 'fallback' ? '.' + this._getMethodType() : '' } + ({ this._getMethodFields(context.accounts) }) + + { this.state.onRequest ? + + : '' + } + + )} + ; + } + +} + +export default Function; \ No newline at end of file diff --git a/lib/dashboard/components/contracts/instance-selector.js b/lib/dashboard/components/contracts/instance-selector.js new file mode 100644 index 00000000..1624b8f8 --- /dev/null +++ b/lib/dashboard/components/contracts/instance-selector.js @@ -0,0 +1,105 @@ +import ContractContext from './contract-context'; + +class InstanceSelector extends React.Component { + + constructor(props) { + super(props); + this.state = { + showInstances: false, + showCustomAddressField: false, + selectedInstance: props.selectedInstance, + customInstance: "", + error: false, + errorMessage: "" + }; + + this.handleShowInstances = this.handleShowInstances.bind(this); + this.handleChange = this.handleChange.bind(this); + this.handleClick = this.handleClick.bind(this); + this.handleTextChange = this.handleTextChange.bind(this); + } + + handleTextChange(e){ + this.setState({customInstance: e.target.value}); + } + + handleShowInstances(e){ + e.preventDefault(); + this.setState({ + showInstances: !this.state.showInstances + }); + } + + handleClick(e){ + e.preventDefault(); + + let instance; + if(this.state.selectedInstance == "custom"){ + instance = this.state.customInstance; + } else { + instance = this.state.selectedInstance; + } + + if(!/^0x[0-9a-f]{40}$/i.test(instance)){ + this.setState({error: true, errorMessage: 'Not a valid Ethereum address.'}); + console.log(this.state.errorMessage); + return; + } else { + this.setState({error: false}); + } + + this.props.instanceUpdate(instance); + + this.setState({ + showInstances: false, + showCustomAddressField: false, + selectedInstance: instance, + customInstance: this.state.selectedInstance == "custom" ? this.state.customInstance : "" + }) + } + + handleChange(e){ + this.setState({ + showCustomAddressField: e.target.value == "custom", + selectedInstance: e.target.value + }); + } + + render(){ + + return + { (context) => (
+
+ Instance Selected: {this.props.selectedInstance != null ? this.props.selectedInstance : 'none'} + {!this.state.showInstances ? Change : Cancel } +
+ {this.state.showInstances ? +
+ + { + this.state.showCustomAddressField ? + + : '' + } + + { + this.state.error ? +

{this.state.errorMessage}

+ : "" + } +
: "" } +
+ )} +
; + } + +} + +export default InstanceSelector; \ No newline at end of file diff --git a/lib/dashboard/components/contracts/source-area.js b/lib/dashboard/components/contracts/source-area.js new file mode 100644 index 00000000..b3ab9a6c --- /dev/null +++ b/lib/dashboard/components/contracts/source-area.js @@ -0,0 +1,27 @@ +class SourceArea extends React.Component { + constructor(props) { + super(props); + this.state = { + sourceCode: "" + }; + } + + componentDidMount(){ + fetch(this.props.sourceURL) + .then(response => response.text()) + .then(text => { + let colorCodedText = hljs.highlight('javascript', text, true).value; + this.setState({sourceCode: colorCodedText}); + }); + } + + render(){ + return +

{this.props.sourceURL.split('\\').pop().split('/').pop()}

+ {this.props.sourceURL} +

+        
; + } +} + +export default SourceArea; \ No newline at end of file diff --git a/lib/dashboard/components/contracts/tab.js b/lib/dashboard/components/contracts/tab.js new file mode 100644 index 00000000..80006f02 --- /dev/null +++ b/lib/dashboard/components/contracts/tab.js @@ -0,0 +1,10 @@ +class Tab extends React.Component { + render(){ + return
+

{this.props.name}

+ { this.props.children } +
; + } +} + +export default Tab; \ No newline at end of file