web: refactor ContentLoader

This commit is contained in:
Maximilian Hils 2016-07-25 17:03:50 -07:00
parent 79ebcb046e
commit 70dbd1b32d
9 changed files with 164 additions and 120 deletions

View File

@ -11,15 +11,13 @@
"<rootDir>/src/js" "<rootDir>/src/js"
], ],
"unmockedModulePathPatterns": [ "unmockedModulePathPatterns": [
"react", "react"
"jquery"
] ]
}, },
"dependencies": { "dependencies": {
"bootstrap": "^3.3.6", "bootstrap": "^3.3.6",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"flux": "^2.1.1", "flux": "^2.1.1",
"jquery": "^2.2.3",
"lodash": "^4.11.2", "lodash": "^4.11.2",
"react": "^15.1.0", "react": "^15.1.0",
"react-dom": "^15.1.0", "react-dom": "^15.1.0",
@ -55,7 +53,9 @@
"gulp-sourcemaps": "^1.6.0", "gulp-sourcemaps": "^1.6.0",
"gulp-util": "^3.0.7", "gulp-util": "^3.0.7",
"jest": "^12.1.1", "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", "uglifyify": "^3.0.1",
"vinyl-buffer": "^1.0.0", "vinyl-buffer": "^1.0.0",
"vinyl-source-stream": "^1.1.0", "vinyl-source-stream": "^1.1.0",

View File

@ -1,10 +1,11 @@
import React, { Component, PropTypes } from 'react' import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { MessageUtils } from '../flow/utils.js'
import * as ContentViews from './ContentView/ContentViews' import * as ContentViews from './ContentView/ContentViews'
import * as MetaViews from './ContentView/MetaViews' import * as MetaViews from './ContentView/MetaViews'
import ContentLoader from './ContentView/ContentLoader'
import ViewSelector from './ContentView/ViewSelector' import ViewSelector from './ContentView/ViewSelector'
import UploadContentButton from './ContentView/UploadContentButton'
import DownloadContentButton from './ContentView/DownloadContentButton'
import { setContentView, displayLarge, updateEdit } from '../ducks/ui/flow' import { setContentView, displayLarge, updateEdit } from '../ducks/ui/flow'
ContentView.propTypes = { ContentView.propTypes = {
@ -18,7 +19,7 @@ ContentView.propTypes = {
ContentView.isContentTooLarge = msg => msg.contentLength > 1024 * 1024 * (ContentViews.ViewImage.matches(msg) ? 10 : 0.2) ContentView.isContentTooLarge = msg => msg.contentLength > 1024 * 1024 * (ContentViews.ViewImage.matches(msg) ? 10 : 0.2)
function ContentView(props) { function ContentView(props) {
const { flow, message, contentView, selectView, displayLarge, setDisplayLarge, uploadContent, onContentChange, content, readonly } = props const { flow, message, contentView, selectView, displayLarge, setDisplayLarge, uploadContent, onContentChange, readonly } = props
if (message.contentLength === 0) { if (message.contentLength === 0) {
return <MetaViews.ContentEmpty {...props}/> return <MetaViews.ContentEmpty {...props}/>
@ -35,34 +36,14 @@ function ContentView(props) {
const View = ContentViews[contentView] const View = ContentViews[contentView]
return ( return (
<div> <div>
{View.textView ? ( <View flow={flow} message={message} readonly={readonly} onChange={onContentChange}/>
<ContentLoader flow={flow} readonly={readonly} message={message}>
<View readonly={readonly} onChange={onContentChange} content="" />
</ContentLoader>
) : (
<View flow={flow} readonly={readonly} onChange={onContentChange} content={content} message={message} />
)}
<div className="view-options text-center"> <div className="view-options text-center">
<ViewSelector onSelectView={selectView} active={View} message={message}/> <ViewSelector onSelectView={selectView} active={View} message={message}/>
&nbsp; &nbsp;
<a className="btn btn-default btn-xs" <DownloadContentButton flow={flow} message={message}/>
href={MessageUtils.getContentURL(flow, message)}
title="Download the content of the flow.">
<i className="fa fa-download"/>
</a>
&nbsp; &nbsp;
<a className="btn btn-default btn-xs" <UploadContentButton uploadContent={uploadContent}/>
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) uploadContent(e.target.files[0])}}
/>
</div> </div>
</div> </div>
) )

View File

@ -1,58 +1,34 @@
import React, { Component, PropTypes } from 'react' import React, { Component, PropTypes } from 'react'
import { MessageUtils } from '../../flow/utils.js' 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 = { static propTypes = {
...View.propTypes,
content: PropTypes.string, // mark as non-required
flow: PropTypes.object.isRequired, flow: PropTypes.object.isRequired,
message: PropTypes.object.isRequired, message: PropTypes.object.isRequired,
} }
constructor(props, context) { constructor(props) {
super(props, context) super(props)
this.state = { content: null, request: null } this.state = {
content: undefined,
request: undefined,
} }
requestContent(nextProps) {
if (this.state.request) {
this.state.request.abort()
}
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() { componentWillMount() {
this.requestContent(this.props) this.startRequest(this.props)
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
let reload = nextProps.message !== this.props.message if (nextProps.message.contentHash !== this.props.message.contentHash) {
let isUserEdit = !nextProps.readonly && nextProps.message.content this.startRequest(nextProps)
}
if (isUserEdit)
this.setState({content: nextProps.message.content})
else if(reload)
this.requestContent(nextProps)
} }
componentWillUnmount() { componentWillUnmount() {
@ -61,15 +37,50 @@ export default class ContentLoader extends Component {
} }
} }
startRequest(props) {
if (this.state.request) {
this.state.request.abort()
}
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() { render() {
return this.state.content ? ( return this.state.content ? (
React.cloneElement(this.props.children, { <View content={this.state.content} {...this.props}/>
content: this.state.content
})
) : ( ) : (
<div className="text-center"> <div className="text-center">
<i className="fa fa-spinner fa-spin"></i> <i className="fa fa-spinner fa-spin"></i>
</div> </div>
) )
} }
} };

View File

@ -1,20 +1,16 @@
import React, { PropTypes } from 'react' import React, { PropTypes } from 'react'
import ContentLoader from './ContentLoader' import ContentLoader from './ContentLoader'
import { MessageUtils } from '../../flow/utils.js' import { MessageUtils } from '../../flow/utils'
import CodeEditor from '../common/CodeEditor' import CodeEditor from './CodeEditor'
const views = [ViewAuto, ViewImage, ViewJSON, ViewRaw] const isImage = /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i
ViewImage.matches = msg => isImage.test(MessageUtils.getContentType(msg))
ViewImage.regex = /^image\/(png|jpe?g|gif|vnc.microsoft.icon|x-icon)$/i
ViewImage.matches = msg => ViewImage.regex.test(MessageUtils.getContentType(msg))
ViewImage.propTypes = { ViewImage.propTypes = {
flow: PropTypes.object.isRequired, flow: PropTypes.object.isRequired,
message: PropTypes.object.isRequired, message: PropTypes.object.isRequired,
} }
function ViewImage({ flow, message }) {
export function ViewImage({ flow, message }) {
return ( return (
<div className="flowview-image"> <div className="flowview-image">
<img src={MessageUtils.getContentURL(flow, message)} alt="preview" className="img-thumbnail"/> <img src={MessageUtils.getContentURL(flow, message)} alt="preview" className="img-thumbnail"/>
@ -22,26 +18,23 @@ export function ViewImage({ flow, message }) {
) )
} }
ViewRaw.textView = true
ViewRaw.matches = () => true
ViewRaw.matches = () => true
ViewRaw.propTypes = { ViewRaw.propTypes = {
content: React.PropTypes.string.isRequired, content: React.PropTypes.string.isRequired,
} }
function ViewRaw({ content, readonly, onChange }) {
export function ViewRaw({ content, readonly, onChange }) {
return readonly ? <pre>{content}</pre> : <CodeEditor content={content} onChange={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 = { ViewJSON.propTypes = {
content: React.PropTypes.string.isRequired, content: React.PropTypes.string.isRequired,
} }
function ViewJSON({ content }) {
export function ViewJSON({ content }) {
let json = content let json = content
try { try {
json = JSON.stringify(JSON.parse(content), null, 2); json = JSON.stringify(JSON.parse(content), null, 2);
@ -50,23 +43,18 @@ export function ViewJSON({ content }) {
} }
return <pre>{json}</pre> return <pre>{json}</pre>
} }
ViewJSON = ContentLoader(ViewJSON)
ViewAuto.matches = () => false 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 = { ViewAuto.propTypes = {
message: React.PropTypes.object.isRequired, message: React.PropTypes.object.isRequired,
flow: React.PropTypes.object.isRequired, flow: React.PropTypes.object.isRequired,
} }
function ViewAuto({ message, flow, readonly, onChange }) {
export function ViewAuto({ message, flow, readonly, onChange }) {
const View = ViewAuto.findView(message) const View = ViewAuto.findView(message)
if (View.textView) { return <View message={message} flow={flow} readonly={readonly} onChange={onChange}/>
return <ContentLoader message={message} flow={flow}><View readonly={readonly} onChange={onChange} content="" /></ContentLoader>
} else {
return <View readonly={readonly} message={message} flow={flow} />
}
} }
export default views export { ViewImage, ViewRaw, ViewAuto, ViewJSON }

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -1,28 +1,47 @@
import React, { PropTypes } from 'react' import React, { PropTypes } from 'react'
import classnames from 'classnames' 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 = { ViewSelector.propTypes = {
active: PropTypes.func.isRequired,
message: PropTypes.object.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 ( return (
<div className="view-selector btn-group btn-group-xs"> <div className="view-selector btn-group btn-group-xs">
{views.map(View => (
<button <ViewButton name="AutoView">auto: {autoViewName}</ViewButton>
key={View.name}
onClick={() => onSelectView(View.name)} {Object.keys(ContentViews).map(name =>
className={classnames('btn btn-default', { active: View === active })}> name !== "ViewAuto" &&
{View === ViewAuto ? ( <ViewButton key={name} name={name}>{name.toLowerCase().replace('view', '')}</ViewButton>
`auto: ${ViewAuto.findView(message).name.toLowerCase().replace('view', '')}`
) : (
View.name.toLowerCase().replace('view', '')
)} )}
</button>
))}
</div> </div>
) )
} }

View File

@ -124,7 +124,6 @@ export const pure = renderFn => class extends React.Component {
static displayName = renderFn.name static displayName = renderFn.name
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
console.log(!shallowEqual(this.props, nextProps))
return !shallowEqual(this.props, nextProps) return !shallowEqual(this.props, nextProps)
} }