diff options
Diffstat (limited to 'web/src')
-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 | 97 | ||||
-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/UploadContentButton.jsx | 28 | ||||
-rw-r--r-- | web/src/js/components/ContentView/ViewSelector.jsx | 51 | ||||
-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/common/CodeEditor.jsx | 30 | ||||
-rw-r--r-- | web/src/js/ducks/flows.js | 5 | ||||
-rw-r--r-- | web/src/js/ducks/ui/flow.js | 14 | ||||
-rw-r--r-- | web/src/js/utils.js | 13 |
13 files changed, 239 insertions, 178 deletions
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..697085a9 100644 --- a/web/src/js/components/ContentView/ContentLoader.jsx +++ b/web/src/js/components/ContentView/ContentLoader.jsx @@ -1,53 +1,33 @@ 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.startRequest(this.props) } componentWillReceiveProps(nextProps) { - if (nextProps.message !== this.props.message) { - this.requestContent(nextProps) + if (nextProps.message.contentHash !== this.props.message.contentHash) { + this.startRequest(nextProps) } } @@ -57,15 +37,54 @@ export default class ContentLoader extends Component { } } + startRequest(props) { + if (this.state.request) { + this.state.request.abort() + } + 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/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/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/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..100bc771 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,8 @@ 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' + STOP_EDIT = 'UI_FLOWVIEW_STOP_EDIT', + UPLOAD_CONTENT = 'UI_FLOWVIEW_UPLOAD_CONTENT' const defaultState = { @@ -22,7 +25,7 @@ export default function reducer(state = defaultState, action) { case START_EDIT: return { ...state, - modifiedFlow: action.flow + modifiedFlow: action.flow, } case UPDATE_EDIT: @@ -87,10 +90,11 @@ export function updateEdit(update) { return { type: UPDATE_EDIT, update } } -export function stopEdit(flow) { +export function stopEdit(flow, modified_flow) { + let diff = getDiff(flow, modified_flow) return (dispatch) => { - dispatch(flowsActions.update(flow, flow)).then(() => { - dispatch(flowsActions.updateFlow(flow)) + dispatch(flowsActions.update(flow, diff)).then(() => { + dispatch(flowsActions.updateFlow(modified_flow)) dispatch({ type: STOP_EDIT }) }) } 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) } |