diff options
| author | Maximilian Hils <git@maximilianhils.com> | 2016-07-25 17:51:38 -0700 | 
|---|---|---|
| committer | Maximilian Hils <git@maximilianhils.com> | 2016-07-25 17:51:38 -0700 | 
| commit | 817b675c5296d25a1fc41d9c3e68effbcee31100 (patch) | |
| tree | 5940b85167156d5a116046b04ef173591bd0ae6d | |
| parent | ffe6593361670a5963d2e07ed6ac2d5f022a66e7 (diff) | |
| parent | 3ebb58f641612a4c512c045187ffe40879720fa7 (diff) | |
| download | mitmproxy-817b675c5296d25a1fc41d9c3e68effbcee31100.tar.gz mitmproxy-817b675c5296d25a1fc41d9c3e68effbcee31100.tar.bz2 mitmproxy-817b675c5296d25a1fc41d9c3e68effbcee31100.zip  | |
Merge branch 'flow_editing_v2'
| -rw-r--r-- | mitmproxy/web/app.py | 13 | ||||
| -rw-r--r-- | web/package.json | 10 | ||||
| -rw-r--r-- | web/src/css/flowdetail.less | 14 | ||||
| -rw-r--r-- | web/src/js/components/ContentView.jsx | 67 | ||||
| -rw-r--r-- | web/src/js/components/ContentView/CodeEditor.jsx | 21 | ||||
| -rw-r--r-- | web/src/js/components/ContentView/ContentLoader.jsx | 104 | ||||
| -rw-r--r-- | web/src/js/components/ContentView/ContentViews.jsx | 45 | ||||
| -rw-r--r-- | web/src/js/components/ContentView/DownloadContentButton.jsx | 18 | ||||
| -rw-r--r-- | web/src/js/components/ContentView/MetaViews.jsx | 18 | ||||
| -rw-r--r-- | web/src/js/components/ContentView/UploadContentButton.jsx | 28 | ||||
| -rw-r--r-- | web/src/js/components/ContentView/ViewSelector.jsx | 51 | ||||
| -rw-r--r-- | web/src/js/components/FlowView/Headers.jsx | 3 | ||||
| -rw-r--r-- | web/src/js/components/FlowView/Messages.jsx | 21 | ||||
| -rw-r--r-- | web/src/js/components/FlowView/ToggleEdit.jsx | 7 | ||||
| -rw-r--r-- | web/src/js/components/ValueEditor/ValueEditor.jsx | 2 | ||||
| -rw-r--r-- | web/src/js/components/common/CodeEditor.jsx | 30 | ||||
| -rw-r--r-- | web/src/js/ducks/flows.js | 5 | ||||
| -rw-r--r-- | web/src/js/ducks/ui/flow.js | 37 | ||||
| -rw-r--r-- | web/src/js/utils.js | 13 | 
19 files changed, 305 insertions, 202 deletions
diff --git a/mitmproxy/web/app.py b/mitmproxy/web/app.py index e55df1f6..f8f85f3d 100644 --- a/mitmproxy/web/app.py +++ b/mitmproxy/web/app.py @@ -5,6 +5,8 @@ import json  import logging  import os.path  import re +import hashlib +  import six  import tornado.websocket @@ -45,7 +47,8 @@ def convert_flow_to_json_dict(flow):                  "path": flow.request.path,                  "http_version": flow.request.http_version,                  "headers": tuple(flow.request.headers.items(True)), -                "contentLength": len(flow.request.content) if flow.request.content is not None else None, +                "contentLength": len(flow.request.raw_content) if flow.request.raw_content is not None else None, +                "contentHash": hashlib.sha256(flow.request.raw_content).hexdigest() if flow.request.raw_content is not None else None,                  "timestamp_start": flow.request.timestamp_start,                  "timestamp_end": flow.request.timestamp_end,                  "is_replay": flow.request.is_replay, @@ -56,7 +59,8 @@ def convert_flow_to_json_dict(flow):                  "status_code": flow.response.status_code,                  "reason": flow.response.reason,                  "headers": tuple(flow.response.headers.items(True)), -                "contentLength": len(flow.response.content) if flow.response.content is not None else None, +                "contentLength": len(flow.response.raw_content) if flow.response.raw_content is not None else None, +                "contentHash": hashlib.sha256(flow.response.raw_content).hexdigest() if flow.response.raw_content is not None else None,                  "timestamp_start": flow.response.timestamp_start,                  "timestamp_end": flow.response.timestamp_end,                  "is_replay": flow.response.is_replay, @@ -248,11 +252,14 @@ class FlowHandler(RequestHandler):                          request.port = int(v)                      elif k == "headers":                          request.headers.set_state(v) +                    elif k == "content": +                        request.text = v                      else:                          print("Warning: Unknown update {}.{}: {}".format(a, k, v))              elif a == "response":                  response = flow.response +                  for k, v in six.iteritems(b):                      if k == "msg":                          response.msg = str(v) @@ -262,6 +269,8 @@ class FlowHandler(RequestHandler):                          response.http_version = str(v)                      elif k == "headers":                          response.headers.set_state(v) +                    elif k == "content": +                        response.text = v                      else:                          print("Warning: Unknown update {}.{}: {}".format(a, k, v))              else: diff --git a/web/package.json b/web/package.json index 81b96adc..302803f2 100644 --- a/web/package.json +++ b/web/package.json @@ -11,15 +11,13 @@        "<rootDir>/src/js"      ],      "unmockedModulePathPatterns": [ -      "react", -      "jquery" +      "react"      ]    },    "dependencies": {      "bootstrap": "^3.3.6",      "classnames": "^2.2.5",      "flux": "^2.1.1", -    "jquery": "^2.2.3",      "lodash": "^4.11.2",      "react": "^15.1.0",      "react-dom": "^15.1.0", @@ -29,7 +27,7 @@      "redux-logger": "^2.6.1",      "redux-thunk": "^2.1.0",      "shallowequal": "^0.2.2", -    "react-codemirror" : "^0.2.6" +    "react-codemirror": "^0.2.6"    },    "devDependencies": {      "babel-core": "^6.7.7", @@ -55,7 +53,9 @@      "gulp-sourcemaps": "^1.6.0",      "gulp-util": "^3.0.7",      "jest": "^12.1.1", -    "react-addons-test-utils": "^15.1.0", +    "react": "^15.2.1", +    "react-addons-test-utils": "^15.2.1", +    "react-dom": "^15.2.1",      "uglifyify": "^3.0.1",      "vinyl-buffer": "^1.0.0",      "vinyl-source-stream": "^1.1.0", diff --git a/web/src/css/flowdetail.less b/web/src/css/flowdetail.less index 35857729..d450bca5 100644 --- a/web/src/css/flowdetail.less +++ b/web/src/css/flowdetail.less @@ -102,11 +102,23 @@      }      .header-name {          width: 33%; -        padding-right: 1em;      }      .header-value {      } + +    // This exists so that you can copy +    // and paste headers out of mitmweb. +    .header-colon { +        position: absolute; +        opacity: 0; +    } + +    .inline-input { +        display: inline-block; +        width: 100%; +        height: 100%; +    }  }  .connection-table, .timing-table { diff --git a/web/src/js/components/ContentView.jsx b/web/src/js/components/ContentView.jsx index f7eafc89..75662509 100644 --- a/web/src/js/components/ContentView.jsx +++ b/web/src/js/components/ContentView.jsx @@ -1,12 +1,12 @@  import React, { Component, PropTypes } from 'react'  import { connect } from 'react-redux' -import { MessageUtils } from '../flow/utils.js'  import * as ContentViews from './ContentView/ContentViews'  import * as MetaViews from './ContentView/MetaViews' -import ContentLoader from './ContentView/ContentLoader'  import ViewSelector from './ContentView/ViewSelector' +import UploadContentButton from './ContentView/UploadContentButton' +import DownloadContentButton from './ContentView/DownloadContentButton' +  import { setContentView, displayLarge, updateEdit } from '../ducks/ui/flow' -import CodeEditor from './common/CodeEditor'  ContentView.propTypes = {      // It may seem a bit weird at the first glance: @@ -19,61 +19,32 @@ ContentView.propTypes = {  ContentView.isContentTooLarge = msg => msg.contentLength > 1024 * 1024 * (ContentViews.ViewImage.matches(msg) ? 10 : 0.2)  function ContentView(props) { -    const { flow, message, contentView, selectView, displayLarge, setDisplayLarge, onContentChange, isFlowEditorOpen, setModifiedFlowContent } = props +    const { flow, message, contentView, isDisplayLarge, displayLarge, uploadContent, onContentChange, readonly } = props -    if (message.contentLength === 0) { +    if (message.contentLength === 0 && readonly) {          return <MetaViews.ContentEmpty {...props}/>      } -    if (message.contentLength === null) { +    if (message.contentLength === null && readonly) {          return <MetaViews.ContentMissing {...props}/>      } -    if (!displayLarge && ContentView.isContentTooLarge(message)) { +    if (!isDisplayLarge && ContentView.isContentTooLarge(message)) {          return <MetaViews.ContentTooLarge {...props} onClick={displayLarge}/>      }      const View = ContentViews[contentView] -      return (          <div> -            {isFlowEditorOpen ? ( -                <ContentLoader flow={flow} message={message}> -                        <CodeEditor content="" onChange={content =>{setModifiedFlowContent(content)}}/> -                    </ContentLoader> -            ): ( -                <div> -                    {View.textView ? ( -                        <ContentLoader flow={flow} message={message}> -                            <View content="" /> -                        </ContentLoader> -                    ) : ( -                        <View flow={flow} message={message} /> -                    )} -                    <div className="view-options text-center"> -                        <ViewSelector onSelectView={selectView} active={View} message={message}/> -                          -                        <a className="btn btn-default btn-xs" -                           href={MessageUtils.getContentURL(flow, message)} -                           title="Download the content of the flow."> -                            <i className="fa fa-download"/> -                        </a> -                          -                        <a  className="btn btn-default btn-xs" -                            onClick={() => ContentView.fileInput.click()} -                            title="Upload a file to replace the content." -                        > -                            <i className="fa fa-upload"/> -                        </a> -                        <input -                            ref={ref => ContentView.fileInput = ref} -                            className="hidden" -                            type="file" -                            onChange={e => {if(e.target.files.length > 0) onContentChange(e.target.files[0])}} -                        /> -                    </div> -                </div> -            )} +            <View flow={flow} message={message} readonly={readonly} onChange={onContentChange}/> + +            <div className="view-options text-center"> +                <ViewSelector message={message}/> +                  +                <DownloadContentButton flow={flow} message={message}/> +                  +                <UploadContentButton uploadContent={uploadContent}/> +            </div>          </div>      )  } @@ -81,12 +52,10 @@ function ContentView(props) {  export default connect(      state => ({          contentView: state.ui.flow.contentView, -        displayLarge: state.ui.flow.displayLarge, -        isFlowEditorOpen : !!state.ui.flow.modifiedFlow // FIXME +        isDisplayLarge: state.ui.flow.displayLarge,      }),      { -        selectView: setContentView,          displayLarge, -        updateEdit, +        updateEdit      }  )(ContentView) diff --git a/web/src/js/components/ContentView/CodeEditor.jsx b/web/src/js/components/ContentView/CodeEditor.jsx new file mode 100644 index 00000000..95f1b98b --- /dev/null +++ b/web/src/js/components/ContentView/CodeEditor.jsx @@ -0,0 +1,21 @@ +import React, { Component, PropTypes } from 'react' +import { render } from 'react-dom'; +import Codemirror from 'react-codemirror'; + + +CodeEditor.propTypes = { +        content: PropTypes.string.isRequired, +        onChange: PropTypes.func.isRequired, +} + +export default function CodeEditor ( { content, onChange} ){ + +    let options = { +        lineNumbers: true +    }; +    return ( +        <div onKeyDown={e => e.stopPropagation()}> +            <Codemirror value={content} onChange={onChange} options={options}/> +        </div> +    ) +} diff --git a/web/src/js/components/ContentView/ContentLoader.jsx b/web/src/js/components/ContentView/ContentLoader.jsx index 1a23325c..ba6702ca 100644 --- a/web/src/js/components/ContentView/ContentLoader.jsx +++ b/web/src/js/components/ContentView/ContentLoader.jsx @@ -1,53 +1,36 @@  import React, { Component, PropTypes } from 'react'  import { MessageUtils } from '../../flow/utils.js' -// This is the only place where we use jQuery. -// Remove when possible. -import $ from "jquery" -export default class ContentLoader extends Component { +export default View => class extends React.Component { + +    static displayName = View.displayName || View.name +    static matches = View.matches      static propTypes = { +        ...View.propTypes, +        content: PropTypes.string,  // mark as non-required          flow: PropTypes.object.isRequired,          message: PropTypes.object.isRequired,      } -    constructor(props, context) { -        super(props, context) -        this.state = { content: null, request: null } -    } - -    requestContent(nextProps) { -        if (this.state.request) { -            this.state.request.abort() +    constructor(props) { +        super(props) +        this.state = { +            content: undefined, +            request: undefined,          } - -        const requestUrl = MessageUtils.getContentURL(nextProps.flow, nextProps.message) -        const request = $.get(requestUrl) - -        this.setState({ content: null, request }) - -        request -            .done(content => { -                this.setState({ content }) -            }) -            .fail((xhr, textStatus, errorThrown) => { -                if (textStatus === 'abort') { -                    return -                } -                this.setState({ content: `AJAX Error: ${textStatus}\r\n${errorThrown}` }) -            }) -            .always(() => { -                this.setState({ request: null }) -            })      }      componentWillMount() { -        this.requestContent(this.props) +        this.updateContent(this.props)      }      componentWillReceiveProps(nextProps) { -        if (nextProps.message !== this.props.message) { -            this.requestContent(nextProps) +        if ( +            nextProps.message.content !== this.props.message.content || +            nextProps.message.contentHash !== this.props.message.contentHash +        ) { +            this.updateContent(nextProps)          }      } @@ -57,15 +40,58 @@ export default class ContentLoader extends Component {          }      } +    updateContent(props) { +        if (this.state.request) { +            this.state.request.abort() +        } +        // We have a few special cases where we do not need to make an HTTP request. +        if(props.message.content !== undefined) { +            return this.setState({request: undefined, content: props.message.content}) +        } +        if(props.message.contentLength === 0 || props.message.contentLength === null){ +            return this.setState({request: undefined, content: ""}) +        } + +        let requestUrl = MessageUtils.getContentURL(props.flow, props.message) + +        // We use XMLHttpRequest instead of fetch() because fetch() is not (yet) abortable. +        let request = new XMLHttpRequest(); +        request.addEventListener("load", this.requestComplete.bind(this, request)); +        request.addEventListener("error", this.requestFailed.bind(this, request)); +        request.open("GET", requestUrl); +        request.send(); +        this.setState({ request, content: undefined }) +    } + +    requestComplete(request, e) { +        if (request !== this.state.request) { +            return // Stale request +        } +        this.setState({ +            content: request.responseText, +            request: undefined +        }) +    } + +    requestFailed(request, e) { +        if (request !== this.state.request) { +            return // Stale request +        } +        console.error(e) +        // FIXME: Better error handling +        this.setState({ +            content: "Error getting content.", +            request: undefined +        }) +    } +      render() { -        return this.state.content ? ( -            React.cloneElement(this.props.children, { -                content: this.state.content -            }) +        return this.state.content !== undefined ? ( +            <View content={this.state.content} {...this.props}/>          ) : (              <div className="text-center">                  <i className="fa fa-spinner fa-spin"></i>              </div>          )      } -} +}; diff --git a/web/src/js/components/ContentView/ContentViews.jsx b/web/src/js/components/ContentView/ContentViews.jsx index 82ee0adc..a1adebea 100644 --- a/web/src/js/components/ContentView/ContentViews.jsx +++ b/web/src/js/components/ContentView/ContentViews.jsx @@ -1,19 +1,16 @@  import React, { PropTypes } from 'react'  import ContentLoader from './ContentLoader' -import { MessageUtils } from '../../flow/utils.js' +import { MessageUtils } from '../../flow/utils' +import CodeEditor from './CodeEditor' -const views = [ViewAuto, ViewImage, ViewJSON, ViewRaw] - -ViewImage.regex = /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i -ViewImage.matches = msg => ViewImage.regex.test(MessageUtils.getContentType(msg)) - +const isImage = /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i +ViewImage.matches = msg => isImage.test(MessageUtils.getContentType(msg))  ViewImage.propTypes = {      flow: PropTypes.object.isRequired,      message: PropTypes.object.isRequired,  } - -export function ViewImage({ flow, message }) { +function ViewImage({ flow, message }) {      return (          <div className="flowview-image">              <img src={MessageUtils.getContentURL(flow, message)} alt="preview" className="img-thumbnail"/> @@ -21,26 +18,23 @@ export function ViewImage({ flow, message }) {      )  } -ViewRaw.textView = true -ViewRaw.matches = () => true +ViewRaw.matches = () => true  ViewRaw.propTypes = {      content: React.PropTypes.string.isRequired,  } - -export function ViewRaw({ content }) { -    return <pre>{content}</pre> +function ViewRaw({ content, readonly, onChange }) { +    return readonly ? <pre>{content}</pre> : <CodeEditor content={content} onChange={onChange}/>  } +ViewRaw = ContentLoader(ViewRaw) -ViewJSON.textView = true -ViewJSON.regex = /^application\/json$/i -ViewJSON.matches = msg => ViewJSON.regex.test(MessageUtils.getContentType(msg)) +const isJSON = /^application\/json$/i +ViewJSON.matches = msg => isJSON.test(MessageUtils.getContentType(msg))  ViewJSON.propTypes = {      content: React.PropTypes.string.isRequired,  } - -export function ViewJSON({ content }) { +function ViewJSON({ content }) {      let json = content      try {          json = JSON.stringify(JSON.parse(content), null, 2); @@ -49,23 +43,18 @@ export function ViewJSON({ content }) {      }      return <pre>{json}</pre>  } +ViewJSON = ContentLoader(ViewJSON)  ViewAuto.matches = () => false -ViewAuto.findView = msg => views.find(v => v.matches(msg)) || views[views.length - 1] - +ViewAuto.findView = msg => [ViewImage, ViewJSON, ViewRaw].find(v => v.matches(msg)) || ViewRaw  ViewAuto.propTypes = {      message: React.PropTypes.object.isRequired,      flow: React.PropTypes.object.isRequired,  } - -export function ViewAuto({ message, flow }) { +function ViewAuto({ message, flow, readonly, onChange }) {      const View = ViewAuto.findView(message) -    if (View.textView) { -        return <ContentLoader message={message} flow={flow}><View content="" /></ContentLoader> -    } else { -        return <View message={message} flow={flow} /> -    } +    return <View message={message} flow={flow} readonly={readonly} onChange={onChange}/>  } -export default views +export { ViewImage, ViewRaw, ViewAuto, ViewJSON } diff --git a/web/src/js/components/ContentView/DownloadContentButton.jsx b/web/src/js/components/ContentView/DownloadContentButton.jsx new file mode 100644 index 00000000..3f11f909 --- /dev/null +++ b/web/src/js/components/ContentView/DownloadContentButton.jsx @@ -0,0 +1,18 @@ +import { MessageUtils } from "../../flow/utils" +import { PropTypes } from 'react' + +DownloadContentButton.propTypes = { +    flow: PropTypes.object.isRequired, +    message: PropTypes.object.isRequired, +} + +export default function DownloadContentButton({ flow, message }) { + +    return ( +        <a className="btn btn-default btn-xs" +           href={MessageUtils.getContentURL(flow, message)} +           title="Download the content of the flow."> +            <i className="fa fa-download"/> +        </a> +    ) +} diff --git a/web/src/js/components/ContentView/MetaViews.jsx b/web/src/js/components/ContentView/MetaViews.jsx index 2d064b54..b926738e 100644 --- a/web/src/js/components/ContentView/MetaViews.jsx +++ b/web/src/js/components/ContentView/MetaViews.jsx @@ -1,5 +1,7 @@  import React from 'react'  import { formatSize } from '../../utils.js' +import UploadContentButton from './UploadContentButton' +import DownloadContentButton from './DownloadContentButton'  export function ContentEmpty({ flow, message }) {      return ( @@ -17,11 +19,19 @@ export function ContentMissing({ flow, message }) {      )  } -export function ContentTooLarge({ message, onClick }) { +export function ContentTooLarge({ message, onClick, uploadContent, flow }) {      return ( -        <div className="alert alert-warning"> -            <button onClick={onClick} className="btn btn-xs btn-warning pull-right">Display anyway</button> -            {formatSize(message.contentLength)} content size. +        <div> +            <div className="alert alert-warning"> + +                <button onClick={onClick} className="btn btn-xs btn-warning pull-right">Display anyway</button> +                {formatSize(message.contentLength)} content size. +            </div> +            <div className="view-options text-center"> +                <UploadContentButton uploadContent={uploadContent}/> +                  +                <DownloadContentButton flow={flow} message={message}/> +            </div>          </div>      )  } diff --git a/web/src/js/components/ContentView/UploadContentButton.jsx b/web/src/js/components/ContentView/UploadContentButton.jsx new file mode 100644 index 00000000..0652b584 --- /dev/null +++ b/web/src/js/components/ContentView/UploadContentButton.jsx @@ -0,0 +1,28 @@ +import { PropTypes } from 'react' + +UploadContentButton.propTypes = { +    uploadContent: PropTypes.func.isRequired, +} + +export default function UploadContentButton({ uploadContent }) { + +    let fileInput; + +    return ( +        <a className="btn btn-default btn-xs" +           onClick={() => fileInput.click()} +           title="Upload a file to replace the content."> +            <i className="fa fa-upload"/> +            <input +                ref={ref => fileInput = ref} +                className="hidden" +                type="file" +                onChange={e => { +                    if (e.target.files.length > 0) uploadContent(e.target.files[0]) +                }} +            /> +        </a> + +    ) +} + diff --git a/web/src/js/components/ContentView/ViewSelector.jsx b/web/src/js/components/ContentView/ViewSelector.jsx index 9b151a5b..89b36231 100644 --- a/web/src/js/components/ContentView/ViewSelector.jsx +++ b/web/src/js/components/ContentView/ViewSelector.jsx @@ -1,28 +1,47 @@  import React, { PropTypes } from 'react'  import classnames from 'classnames' -import views, { ViewAuto } from './ContentViews' +import { connect } from 'react-redux' +import * as ContentViews from './ContentViews' +import { setContentView } from "../../ducks/ui/flow"; + + +function ViewButton({ name, setContentView, children, activeView }) { +    return ( +        <button +            onClick={() => setContentView(name)} +            className={classnames('btn btn-default', { active: name === activeView })}> +            {children} +        </button> +    ) +} +ViewButton = connect(state => ({ +    activeView: state.ui.flow.contentView +}), { +    setContentView +})(ViewButton) +  ViewSelector.propTypes = { -    active: PropTypes.func.isRequired,      message: PropTypes.object.isRequired, -    onSelectView: PropTypes.func.isRequired,  } +export default function ViewSelector({ message }) { + +    let autoView = ContentViews.ViewAuto.findView(message) +    let autoViewName = (autoView.displayName || autoView.name) +        .toLowerCase() +        .replace('view', '') +        .replace(/ContentLoader\((.+)\)/,"$1") -export default function ViewSelector({ active, message, onSelectView }) {      return (          <div className="view-selector btn-group btn-group-xs"> -            {views.map(View => ( -                <button -                    key={View.name} -                    onClick={() => onSelectView(View.name)} -                    className={classnames('btn btn-default', { active: View === active })}> -                    {View === ViewAuto ? ( -                        `auto: ${ViewAuto.findView(message).name.toLowerCase().replace('view', '')}` -                    ) : ( -                        View.name.toLowerCase().replace('view', '') -                    )} -                </button> -            ))} + +            <ViewButton name="ViewAuto">auto: {autoViewName}</ViewButton> + +            {Object.keys(ContentViews).map(name => +                name !== "ViewAuto" && +                <ViewButton key={name} name={name}>{name.toLowerCase().replace('view', '')}</ViewButton> +            )} +          </div>      )  } diff --git a/web/src/js/components/FlowView/Headers.jsx b/web/src/js/components/FlowView/Headers.jsx index 706dd404..2e181383 100644 --- a/web/src/js/components/FlowView/Headers.jsx +++ b/web/src/js/components/FlowView/Headers.jsx @@ -126,7 +126,8 @@ export default class Headers extends Component {                                  onDone={val => this.onChange(i, 0, val)}                                  onRemove={event => this.onRemove(i, 0, event)}                                  onTab={event => this.onTab(i, 0, event)} -                            />: +                            /> +                            <span className="header-colon">:</span>                          </td>                          <td className="header-value">                              <HeaderEditor diff --git a/web/src/js/components/FlowView/Messages.jsx b/web/src/js/components/FlowView/Messages.jsx index 133b2883..9de25b5b 100644 --- a/web/src/js/components/FlowView/Messages.jsx +++ b/web/src/js/components/FlowView/Messages.jsx @@ -10,6 +10,7 @@ import ValueEditor from '../ValueEditor/ValueEditor'  import Headers from './Headers'  import { startEdit, updateEdit } from '../../ducks/ui/flow' +import * as FlowActions from '../../ducks/flows'  import ToggleEdit from './ToggleEdit'  function RequestLine({ flow, readonly, updateFlow }) { @@ -73,12 +74,13 @@ const Message = connect(      }),      {          updateFlow: updateEdit, +        uploadContent: FlowActions.uploadContent      }  )  export class Request extends Component {      render() { -        const { flow, isEdit, updateFlow } = this.props +        const { flow, isEdit, updateFlow, uploadContent } = this.props          return (              <section className="request"> @@ -94,7 +96,12 @@ export class Request extends Component {                  />                  <hr/> -                <ContentView flow={flow} message={flow.request}/> +                <ContentView +                    readonly={!isEdit} +                    flow={flow} +                    onContentChange={content => updateFlow({ request: {content}})} +                    uploadContent={content => uploadContent(flow, content, "request")} +                    message={flow.request}/>              </section>          )      } @@ -129,7 +136,7 @@ Request = Message(Request)  export class Response extends Component {      render() { -        const { flow, isEdit, updateFlow } = this.props +        const { flow, isEdit, updateFlow, uploadContent } = this.props          return (              <section className="response"> @@ -144,7 +151,13 @@ export class Response extends Component {                      onChange={headers => updateFlow({ response: { headers } })}                  />                  <hr/> -                <ContentView flow={flow} message={flow.response}/> +                <ContentView +                    readonly={!isEdit} +                    flow={flow} +                    onContentChange={content => updateFlow({ response: {content}})} +                    uploadContent={content => uploadContent(flow, content, "response")} +                    message={flow.response} +                />              </section>          )      } diff --git a/web/src/js/components/FlowView/ToggleEdit.jsx b/web/src/js/components/FlowView/ToggleEdit.jsx index 0c8cbbd8..9016348e 100644 --- a/web/src/js/components/FlowView/ToggleEdit.jsx +++ b/web/src/js/components/FlowView/ToggleEdit.jsx @@ -10,11 +10,11 @@ ToggleEdit.propTypes = {      stopEdit: PropTypes.func.isRequired,  } -function ToggleEdit({ isEdit, startEdit, stopEdit, flow }) { +function ToggleEdit({ isEdit, startEdit, stopEdit, flow, modifiedFlow }) {      return (          <div className="edit-flow-container">              {isEdit ? -                <a className="edit-flow" onClick={() => stopEdit(flow)}> +                <a className="edit-flow" onClick={() => stopEdit(flow, modifiedFlow)}>                      <i className="fa fa-check"/>                  </a>                  : @@ -29,7 +29,8 @@ function ToggleEdit({ isEdit, startEdit, stopEdit, flow }) {  export default connect(      state => ({          isEdit: !!state.ui.flow.modifiedFlow, -        flow: state.ui.flow.modifiedFlow || state.flows.byId[state.flows.selected[0]] +        modifiedFlow: state.ui.flow.modifiedFlow || state.flows.byId[state.flows.selected[0]], +        flow: state.flows.byId[state.flows.selected[0]]      }),      {          startEdit, diff --git a/web/src/js/components/ValueEditor/ValueEditor.jsx b/web/src/js/components/ValueEditor/ValueEditor.jsx index dd9c2cde..852f82c4 100644 --- a/web/src/js/components/ValueEditor/ValueEditor.jsx +++ b/web/src/js/components/ValueEditor/ValueEditor.jsx @@ -59,7 +59,7 @@ export default class ValueEditor extends Component {          return (              <div                  ref={input => this.input = input} -                tabIndex={!this.props.readonly && "0"} +                tabIndex={this.props.readonly ? undefined : 0}                  className={className}                  contentEditable={this.state.editable || undefined}                  onFocus={this.onFocus} diff --git a/web/src/js/components/common/CodeEditor.jsx b/web/src/js/components/common/CodeEditor.jsx deleted file mode 100644 index 5b2305a8..00000000 --- a/web/src/js/components/common/CodeEditor.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React, { Component, PropTypes } from 'react' -import { render } from 'react-dom'; -import Codemirror from 'react-codemirror'; - - -export default class CodeEditor extends Component{ -     static propTypes = { -        content: PropTypes.string.isRequired, -        onChange: PropTypes.func.isRequired, -    } - -    constructor(props){ -        super(props) -    } - -    componentWillMount(){ -        this.props.onChange(this.props.content) -    } - -    render() { -        let options = { -            lineNumbers: true -        }; -        return ( -            <div onKeyDown={e => e.stopPropagation()}> -                <Codemirror value={this.props.content} onChange={this.props.onChange} options={options}/> -            </div> -        ) -    } -} diff --git a/web/src/js/ducks/flows.js b/web/src/js/ducks/flows.js index f18e48e6..f96653a9 100644 --- a/web/src/js/ducks/flows.js +++ b/web/src/js/ducks/flows.js @@ -112,10 +112,9 @@ export function update(flow, data) {      return dispatch => fetchApi.put(`/flows/${flow.id}`, data)  } -export function updateContent(flow, file, type) { +export function uploadContent(flow, file, type) {      const body = new FormData() -    if (typeof file !== File) -        file = new Blob([file], {type: 'plain/text'}) +    file = new Blob([file], {type: 'plain/text'})      body.append('file', file)      return dispatch => fetchApi(`/flows/${flow.id}/${type}/content`, {method: 'post',  body} )  } diff --git a/web/src/js/ducks/ui/flow.js b/web/src/js/ducks/ui/flow.js index b1fe535f..c9435676 100644 --- a/web/src/js/ducks/ui/flow.js +++ b/web/src/js/ducks/ui/flow.js @@ -1,4 +1,6 @@  import * as flowsActions from '../flows' +import { getDiff } from "../../utils" +  import _ from 'lodash'  export const SET_CONTENT_VIEW = 'UI_FLOWVIEW_SET_CONTENT_VIEW', @@ -6,7 +8,7 @@ export const SET_CONTENT_VIEW = 'UI_FLOWVIEW_SET_CONTENT_VIEW',               SET_TAB          = "UI_FLOWVIEW_SET_TAB",               START_EDIT       = 'UI_FLOWVIEW_START_EDIT',               UPDATE_EDIT      = 'UI_FLOWVIEW_UPDATE_EDIT', -             STOP_EDIT        = 'UI_FLOWVIEW_STOP_EDIT' +             UPLOAD_CONTENT   = 'UI_FLOWVIEW_UPLOAD_CONTENT'  const defaultState = { @@ -22,7 +24,7 @@ export default function reducer(state = defaultState, action) {          case START_EDIT:              return {                  ...state, -                modifiedFlow: action.flow +                modifiedFlow: action.flow,              }          case UPDATE_EDIT: @@ -31,12 +33,6 @@ export default function reducer(state = defaultState, action) {                  modifiedFlow: _.merge({}, state.modifiedFlow, action.update)              } -        case STOP_EDIT: -            return { -                ...state, -                modifiedFlow: false -            } -          case flowsActions.SELECT:              return {                  ...state, @@ -44,6 +40,21 @@ export default function reducer(state = defaultState, action) {                  displayLarge: false,              } +        case flowsActions.UPDATE: +            // There is no explicit "stop edit" event. +            // We stop editing when we receive an update for +            // the currently edited flow from the server +            if (action.item.id === state.modifiedFlow.id) { +                return { +                    ...state, +                    modifiedFlow: false, +                    displayLarge: false, +                } +            } else { +                return state +            } + +          case SET_TAB:              return {                  ...state, @@ -87,11 +98,7 @@ export function updateEdit(update) {      return { type: UPDATE_EDIT, update }  } -export function stopEdit(flow) { -    return (dispatch) => { -        dispatch(flowsActions.update(flow, flow)).then(() => { -            dispatch(flowsActions.updateFlow(flow)) -            dispatch({ type: STOP_EDIT }) -        }) -    } +export function stopEdit(flow, modifiedFlow) { +    let diff = getDiff(flow, modifiedFlow) +    return flowsActions.update(flow, diff)  } diff --git a/web/src/js/utils.js b/web/src/js/utils.js index eecacfbb..e44182d0 100644 --- a/web/src/js/utils.js +++ b/web/src/js/utils.js @@ -108,11 +108,22 @@ fetchApi.put = (url, json, options) => fetchApi(      }  ) +export function getDiff(obj1, obj2) { +    let result = {...obj2}; +    for(let key in obj1) { +        if(_.isEqual(obj2[key], obj1[key])) +            result[key] = undefined +        else if(!(Array.isArray(obj2[key]) && Array.isArray(obj1[key])) && +                typeof obj2[key] == 'object' && typeof obj1[key] == 'object') +            result[key] = getDiff(obj1[key], obj2[key]) +    } +    return result +} +  export const pure = renderFn => class extends React.Component {      static displayName = renderFn.name      shouldComponentUpdate(nextProps) { -        console.log(!shallowEqual(this.props, nextProps))          return !shallowEqual(this.props, nextProps)      }  | 
