[web] options: make help and err permanently visible, improve perf

This commit is contained in:
Maximilian Hils 2017-07-17 20:46:28 +02:00
parent 21b3f9c029
commit babd967eb8
10 changed files with 260 additions and 338 deletions

View file

@ -0,0 +1,121 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
import { connect } from "react-redux"
import { update as updateOptions } from "../../ducks/options"
import { Key } from "../../utils"
const stopPropagation = e => {
if (e.keyCode !== Key.ESC) {
e.stopPropagation()
}
}
BooleanOption.PropTypes = {
value: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
}
function BooleanOption({ value, onChange, ...props }) {
return (
<input type="checkbox"
checked={value}
onChange={e => onChange(e.target.checked)}
{...props}
/>
)
}
StringOption.PropTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
}
function StringOption({ value, onChange, ...props }) {
return (
<input type="text"
value={value || ""}
onChange={e => onChange(e.target.value)}
{...props}
/>
)
}
NumberOption.PropTypes = {
value: PropTypes.number.isRequired,
onChange: PropTypes.func.isRequired,
}
function NumberOption({ value, onChange, ...props }) {
return (
<input type="number"
value={value}
onChange={(e) => onChange(parseInt(e.target.value))}
{...props}
/>
)
}
ChoicesOption.PropTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
}
function ChoicesOption({ value, onChange, choices, ...props }) {
return (
<select
onChange={(e) => onChange(e.target.value)}
selected={value}
{...props}
>
{ choices.map(
choice => (
<option key={choice} value={choice}>{choice}</option>
)
)}
</select>
)
}
StringSequenceOption.PropTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
}
function StringSequenceOption({ value, onChange, ...props }) {
const height = Math.max(value.length, 1)
return <textarea
rows={height}
value={value.join("\n")}
onChange={e => onChange(e.target.value.split("\n"))}
{...props}
/>
}
const Options = {
"bool": BooleanOption,
"str": StringOption,
"int": NumberOption,
"optional str": StringOption,
"sequence of str": StringSequenceOption,
}
function PureOption({ choices, type, value, onChange }) {
if (choices) {
return <ChoicesOption
value={value}
onChange={onChange}
choices={choices}
onKeyDown={stopPropagation}
/>
}
const Opt = Options[type]
return <Opt
value={value}
onChange={onChange}
onKeyDown={stopPropagation}
/>
}
export default connect(
(state, { name }) => ({
...state.options[name],
...state.ui.optionsEditor[name]
}),
(dispatch, { name }) => ({
onChange: value => dispatch(updateOptions(name, value))
})
)(PureOption)

View file

@ -1,257 +0,0 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import classnames from 'classnames'
import { update as updateOptions } from '../../ducks/options'
PureBooleanOption.PropTypes = {
value: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
}
function PureBooleanOption({ value, onChange, ...props}) {
return (
<input type="checkbox"
checked={value}
onChange={onChange}
onMouseOver={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
/>
)
}
PureStringOption.PropTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
}
function PureStringOption( { value, onChange, ...props }) {
let onKeyDown = (e) => {e.stopPropagation()}
return (
<div className={classnames('input-group', {'has-error': props.error})}>
<input type="text"
value={value}
className='form-control'
onChange={onChange}
onKeyDown={onKeyDown}
onFocus={props.onFocus}
onBlur={props.onBlur}
onMouseOver={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
/>
</div>
)
}
PureNumberOption.PropTypes = {
value: PropTypes.number.isRequired,
onChange: PropTypes.func.isRequired,
}
function PureNumberOption( {value, onChange, ...props }) {
let onKeyDown = (e) => {e.stopPropagation()}
return (
<input type="number"
className="form-control"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onFocus={props.onFocus}
onBlur={props.onBlur}
onMouseOver={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
/>
)
}
PureChoicesOption.PropTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
}
function PureChoicesOption( { value, onChange, name, choices, ...props}) {
return (
<select
name={name}
className="form-control"
onChange={onChange}
selected={value}
onFocus={props.onFocus}
onBlur={props.onBlur}
onMouseOver={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
>
{ choices.map((choice, index) => (
<option key={index} value={choice}> {choice} </option>
))}
</select>
)
}
class PureStringSequenceOption extends Component {
constructor(props, context) {
super(props, context)
this.state = { height: 1, focus: false, value: this.props.value}
this.onFocus = this.onFocus.bind(this)
this.onBlur = this.onBlur.bind(this)
this.onKeyDown = this.onKeyDown.bind(this)
this.onChange = this.onChange.bind(this)
}
onFocus() {
this.setState( {focus: true, height: 3 })
this.props.onFocus()
}
onBlur() {
this.setState( {focus: false, height: 1})
this.props.onBlur()
}
onKeyDown(e) {
e.stopPropagation()
}
onChange(e) {
const value = e.target.value.split("\n")
this.props.onChange(e)
this.setState({ value })
}
render() {
const {height, value} = this.state
const {error, onMouseEnter, onMouseLeave} = this.props
return (
<div className={classnames('input-group', {'has-error': error})}>
<textarea
rows={height}
value={value}
className="form-control"
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onFocus={this.onFocus}
onBlur={this.onBlur}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
</div>
)
}
}
const OptionTypes = {
bool: PureBooleanOption,
str: PureStringOption,
int: PureNumberOption,
"optional str": PureStringOption,
"sequence of str": PureStringSequenceOption,
}
class OptionMaster extends Component {
constructor(props, context) {
super(props, context)
this.state = {
option: this.props.option,
name: this.props.name,
mousefocus: false,
focus: false,
error: false,
}
if (props.option.choices) {
this.WrappedComponent = PureChoicesOption
} else {
this.WrappedComponent = OptionTypes[props.option.type]
}
this.onChange = this.onChange.bind(this)
this.onMouseEnter = this.onMouseEnter.bind(this)
this.onMouseLeave = this.onMouseLeave.bind(this)
this.onFocus = this.onFocus.bind(this)
this.onBlur = this.onBlur.bind(this)
}
componentWillReceiveProps(nextProps) {
this.setState({ option: nextProps.option })
}
onChange(e) {
const { option, name } = this.state
const { updateOptions } = this.props
switch (option.type) {
case 'bool' :
updateOptions({[name]: !option.value})
break
case 'int':
updateOptions({[name]: parseInt(e.target.value)})
break
case 'sequence of str':
const value = e.target.value.split('\n')
updateOptions({[name]: value})
break
default:
updateOptions({[name]: e.target.value})
}
}
onMouseEnter() {
this.setState({ mousefocus: true })
}
onMouseLeave() {
this.setState({ mousefocus: false })
}
onFocus() {
this.setState({ focus: true })
}
onBlur() {
this.setState({ focus: false })
}
render() {
const { name, children, client_options } = this.props
const { option, focus, mousefocus } = this.state
const WrappedComponent = this.WrappedComponent
let error = (name in client_options) ? client_options[name].error : false,
value = (name in client_options) ? client_options[name].value : option.value
return (
<div className="row">
<div className="col-sm-8">
{name}
</div>
<div className="col-sm-4">
<WrappedComponent
children={children}
value={value}
onChange={this.onChange}
name={name}
choices={option.choices}
onFocus={this.onFocus}
onBlur={this.onBlur}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
error={error}
/>
{(focus || mousefocus) && (
<div className="tooltip tooltip-bottom" role="tooltip" style={{opacity: 1}}>
<div className="tooltip-inner">
{option.help}
</div>
</div>)}
</div>
</div>
)
}
}
export default connect(
state => ({
client_options: state.ui.option
}),
{
updateOptions
}
)(OptionMaster)

View file

@ -1,7 +1,22 @@
import React, { Component } from 'react'
import { connect } from 'react-redux'
import * as modalAction from '../../ducks/ui/modal'
import Option from './OptionMaster'
import React, { Component } from "react"
import { connect } from "react-redux"
import * as modalAction from "../../ducks/ui/modal"
import Option from "./Option"
function PureOptionHelp({help}){
return <div className="small text-muted">{help}</div>;
}
const OptionHelp = connect((state, {name}) => ({
help: state.options[name].help,
}))(PureOptionHelp);
function PureOptionError({error}){
if(!error) return null;
return <div className="small text-danger">{error}</div>;
}
const OptionError = connect((state, {name}) => ({
error: state.ui.optionsEditor[name] && state.ui.optionsEditor[name].error
}))(PureOptionError);
class PureOptionModal extends Component {
@ -28,18 +43,20 @@ class PureOptionModal extends Component {
<div className="modal-body">
<div className="container-fluid">
{
Object.keys(options).sort()
.map((key, index) => {
let option = options[key];
return (
<Option
key={index}
name={key}
option={option}
/>)
})
}
{
options.map(name =>
<div key={name} className="row">
<div className="col-xs-6">
{name}
<OptionHelp name={name}/>
</div>
<div className="col-xs-6">
<Option name={name}/>
<OptionError name={name}/>
</div>
</div>
)
}
</div>
</div>
@ -52,7 +69,7 @@ class PureOptionModal extends Component {
export default connect(
state => ({
options: state.options
options: Object.keys(state.options)
}),
{
hideModal: modalAction.hideModal,

View file

@ -1,6 +1,6 @@
export const ConnectionState = {
INIT: Symbol("init"),
FETCHING: Symbol("fetching"), // WebSocket is established, but still startFetching resources.
FETCHING: Symbol("fetching"), // WebSocket is established, but still fetching resources.
ESTABLISHED: Symbol("established"),
ERROR: Symbol("error"),
OFFLINE: Symbol("offline"), // indicates that there is no live (websocket) backend.

View file

@ -1,14 +1,12 @@
import { fetchApi } from '../utils'
import * as optionActions from './ui/option'
import { fetchApi } from "../utils"
import * as optionsEditorActions from "./ui/optionsEditor"
import _ from "lodash"
export const RECEIVE = 'OPTIONS_RECEIVE'
export const UPDATE = 'OPTIONS_UPDATE'
export const RECEIVE = 'OPTIONS_RECEIVE'
export const UPDATE = 'OPTIONS_UPDATE'
export const REQUEST_UPDATE = 'REQUEST_UPDATE'
export const UNKNOWN_CMD = 'OPTIONS_UNKNOWN_CMD'
const defaultState = {
}
const defaultState = {}
export default function reducer(state = defaultState, action) {
switch (action.type) {
@ -27,18 +25,23 @@ export default function reducer(state = defaultState, action) {
}
}
export function update(options) {
let sendUpdate = (option, value, dispatch) => {
fetchApi.put('/options', { [option]: value }).then(response => {
if (response.status === 200) {
dispatch(optionsEditorActions.updateSuccess(option))
} else {
response.text().then(error => {
dispatch(optionsEditorActions.updateError(option, error))
})
}
})
}
sendUpdate = _.throttle(sendUpdate, 700, { leading: true, trailing: true })
export function update(option, value) {
return dispatch => {
let option = Object.keys(options)[0]
dispatch({ type: optionActions.OPTION_UPDATE_START, option, value: options[option] })
fetchApi.put('/options', options).then(response => {
if (response.status === 200) {
dispatch({ type: optionActions.OPTION_UPDATE_SUCCESS, option})
} else {
response.text().then( text => {
dispatch({type: optionActions.OPTION_UPDATE_ERROR, error: text, option})
})
}
})
dispatch(optionsEditorActions.startUpdate(option, value))
sendUpdate(option, value, dispatch);
}
}

View file

@ -3,7 +3,6 @@ import { fetchApi } from '../utils'
export const RECEIVE = 'SETTINGS_RECEIVE'
export const UPDATE = 'SETTINGS_UPDATE'
export const REQUEST_UPDATE = 'REQUEST_UPDATE'
export const UNKNOWN_CMD = 'SETTINGS_UNKNOWN_CMD'
const defaultState = {

View file

@ -2,12 +2,12 @@ import { combineReducers } from 'redux'
import flow from './flow'
import header from './header'
import modal from './modal'
import option from './option'
import optionsEditor from './optionsEditor'
// TODO: Just move ducks/ui/* into ducks/?
export default combineReducers({
flow,
header,
modal,
option,
optionsEditor,
})

View file

@ -1,6 +1,7 @@
import { Key } from "../../utils"
import { selectTab } from "./flow"
import * as flowsActions from "../flows"
import * as modalActions from "./modal"
export function onKeyDown(e) {
@ -46,7 +47,11 @@ export function onKeyDown(e) {
break
case Key.ESC:
dispatch(flowsActions.select(null))
if(getState().ui.modal.activeModal){
dispatch(modalActions.hideModal())
} else {
dispatch(flowsActions.select(null))
}
break
case Key.LEFT: {

View file

@ -1,39 +0,0 @@
export const OPTION_UPDATE_START = 'UI_OPTION_UPDATE_START'
export const OPTION_UPDATE_SUCCESS = 'UI_OPTION_UPDATE_SUCCESS'
export const OPTION_UPDATE_ERROR = 'UI_OPTION_UPDATE_ERROR'
const defaultState = {
/* optionName -> {isUpdating, value (client-side), error} */
}
export default function reducer(state = defaultState, action) {
switch (action.type) {
case OPTION_UPDATE_START:
return {
...state,
[action.option]: {
isUpdate: true,
value: action.value,
error: false,
}
}
case OPTION_UPDATE_SUCCESS:
let s = {...state}
delete s[action.option]
return s
case OPTION_UPDATE_ERROR:
return {
...state,
[action.option]: {
...state[action.option],
isUpdating: false,
error: action.error
}
}
default:
return state
}
}

View file

@ -0,0 +1,73 @@
import { HIDE_MODAL } from "./modal"
export const OPTION_UPDATE_START = 'UI_OPTION_UPDATE_START'
export const OPTION_UPDATE_SUCCESS = 'UI_OPTION_UPDATE_SUCCESS'
export const OPTION_UPDATE_ERROR = 'UI_OPTION_UPDATE_ERROR'
const defaultState = {
/* optionName -> {isUpdating, value (client-side), error} */
}
export default function reducer(state = defaultState, action) {
switch (action.type) {
case OPTION_UPDATE_START:
return {
...state,
[action.option]: {
isUpdate: true,
value: action.value,
error: false,
}
}
case OPTION_UPDATE_SUCCESS:
return {
...state,
[action.option]: undefined
}
case OPTION_UPDATE_ERROR:
let val = state[action.option].value;
if (typeof(val) === "boolean") {
// If a boolean option errs, reset it to its previous state to be less confusing.
// Example: Start mitmweb, check "add_upstream_certs_to_client_chain".
val = !val;
}
return {
...state,
[action.option]: {
value: val,
isUpdating: false,
error: action.error
}
}
case HIDE_MODAL:
return {}
default:
return state
}
}
export function startUpdate(option, value) {
return {
type: OPTION_UPDATE_START,
option,
value,
}
}
export function updateSuccess(option) {
return {
type: OPTION_UPDATE_SUCCESS,
option,
}
}
export function updateError(option, error) {
return {
type: OPTION_UPDATE_ERROR,
option,
error,
}
}