diff --git a/app/common/__tests__/network.test.js b/app/common/__tests__/network.test.js index 6b87af4a2..528cd65f6 100644 --- a/app/common/__tests__/network.test.js +++ b/app/common/__tests__/network.test.js @@ -167,7 +167,7 @@ describe('actuallySend()', () => { ); expect(mock.basePath).toBe('http://localhost:80'); - expect(response.error).toBe(''); + expect(response.error).toBe(undefined); expect(response.url).toBe('http://localhost/?foo%20bar=hello%26world'); expect(response.body).toBe(new Buffer('response body').toString('base64')); expect(response.statusCode).toBe(200); @@ -208,7 +208,7 @@ describe('actuallySend()', () => { ); expect(mock.basePath).toBe('http://localhost:80'); - expect(response.error).toBe(''); + expect(response.error).toBe(undefined); expect(response.url).toBe('http://localhost/'); expect(response.body).toBe(new Buffer('response body').toString('base64')); expect(response.statusCode).toBe(200); @@ -260,7 +260,7 @@ describe('actuallySend()', () => { ); expect(mock.basePath).toBe('http://localhost:80'); - expect(response.error).toBe(''); + expect(response.error).toBe(undefined); expect(response.url).toBe('http://localhost/'); expect(response.body).toBe(new Buffer('response body').toString('base64')); expect(response.statusCode).toBe(200); diff --git a/app/common/network.js b/app/common/network.js index 05bffa5c6..e4ae1b979 100644 --- a/app/common/network.js +++ b/app/common/network.js @@ -118,17 +118,15 @@ export function _buildRequestConfig (renderedRequest, patch = {}) { } export function _actuallySend (renderedRequest, workspace, settings, familyIndex = 0) { - return new Promise(async (resolve, reject) => { + return new Promise(async resolve => { async function handleError (err, prefix = '') { - await models.response.create({ + resolve({ url: renderedRequest.url, parentId: renderedRequest._id, elapsedTime: 0, statusMessage: 'Error', error: prefix ? `${prefix}: ${err.message}` : err.message }); - - reject(err); } // Detect and set the proxy based on the request protocol @@ -215,10 +213,10 @@ export function _actuallySend (renderedRequest, workspace, settings, familyIndex if (isNetworkRelatedError && nextFamilyIndex < FAMILY_FALLBACKS.length) { const family = FAMILY_FALLBACKS[nextFamilyIndex]; console.log(`-- Falling back to family ${family} --`); - _actuallySend( + + return _actuallySend( renderedRequest, workspace, settings, nextFamilyIndex ).then(resolve, reject); - return; } let message = err.toString(); @@ -227,14 +225,12 @@ export function _actuallySend (renderedRequest, workspace, settings, familyIndex message += `Code: ${err.code}`; } - await models.response.create({ + return resolve({ url: config.url, parentId: renderedRequest._id, statusMessage: 'Error', error: message }); - - return reject(err); } // handle response headers @@ -272,7 +268,7 @@ export function _actuallySend (renderedRequest, workspace, settings, familyIndex } const bodyEncoding = 'base64'; - const responsePatch = { + return resolve({ parentId: renderedRequest._id, statusCode: networkResponse.statusCode, statusMessage: networkResponse.statusMessage, @@ -283,9 +279,7 @@ export function _actuallySend (renderedRequest, workspace, settings, familyIndex body: networkResponse.body.toString(bodyEncoding), encoding: bodyEncoding, headers: headers - }; - - models.response.create(responsePatch).then(resolve, reject); + }); }; const requestStartTime = Date.now(); @@ -295,15 +289,13 @@ export function _actuallySend (renderedRequest, workspace, settings, familyIndex cancelRequestFunction = async () => { req.abort(); - await models.response.create({ + resolve({ url: config.url, parentId: renderedRequest._id, elapsedTime: Date.now() - requestStartTime, statusMessage: 'Cancelled', error: 'The request was cancelled' }); - - return reject(new Error('Cancelled')); } }) } @@ -321,7 +313,7 @@ export async function send (requestId, environmentId) { renderedRequest = await getRenderedRequest(request, environmentId); } catch (e) { // Failed to render. Must be the user's fault - return await models.response.create({ + return resolve({ parentId: request._id, statusCode: STATUS_CODE_RENDER_FAILED, error: e.message @@ -333,5 +325,5 @@ export async function send (requestId, environmentId) { const workspace = ancestors.find(doc => doc.type === models.workspace.type); // Render succeeded so we're good to go! - return await _actuallySend(renderedRequest, workspace, settings); + return _actuallySend(renderedRequest, workspace, settings); } diff --git a/app/ui/components/RequestPane.js b/app/ui/components/RequestPane.js index 103baa848..0c20c302f 100644 --- a/app/ui/components/RequestPane.js +++ b/app/ui/components/RequestPane.js @@ -71,6 +71,7 @@ class RequestPane extends PureComponent { editorKeyMap, editorLineWrapping, handleSend, + handleSendAndDownload, forceRefreshCounter, useBulkHeaderEditor, handleGenerateCode, @@ -152,6 +153,7 @@ class RequestPane extends PureComponent { onUrlPaste={importRequest} handleGenerateCode={handleGenerateCode} handleSend={handleSend} + handleSendAndDownload={handleSendAndDownload} url={request.url} /> @@ -279,6 +281,7 @@ class RequestPane extends PureComponent { RequestPane.propTypes = { // Functions handleSend: PropTypes.func.isRequired, + handleSendAndDownload: PropTypes.func.isRequired, handleCreateRequest: PropTypes.func.isRequired, handleGenerateCode: PropTypes.func.isRequired, updateRequest: PropTypes.func.isRequired, diff --git a/app/ui/components/RequestUrlBar.js b/app/ui/components/RequestUrlBar.js index 3c9bbf72e..39abad9dd 100644 --- a/app/ui/components/RequestUrlBar.js +++ b/app/ui/components/RequestUrlBar.js @@ -1,23 +1,28 @@ import React, {Component, PropTypes} from 'react'; -import classnames from 'classnames'; +import {remote} from 'electron'; import {DEBOUNCE_MILLIS, isMac} from '../../common/constants'; import {Dropdown, DropdownButton, DropdownItem, DropdownDivider, DropdownHint} from './base/dropdown'; import {trackEvent} from '../../analytics'; import MethodDropdown from './dropdowns/MethodDropdown'; import PromptModal from './modals/PromptModal'; import {showModal} from './modals/index'; +import PromptButton from './base/PromptButton'; class RequestUrlBar extends Component { state = { currentInterval: null, currentTimeout: null, + downloadPath: null }; + _urlChangeDebounceTimeout = null; + _handleFormSubmit = e => { e.preventDefault(); e.stopPropagation(); - this.props.handleSend(); + + this._handleSend(); }; _handleMethodChange = method => { @@ -28,15 +33,28 @@ class RequestUrlBar extends Component { _handleUrlChange = e => { const url = e.target.value; - clearTimeout(this._timeout); - this._timeout = setTimeout(() => { + clearTimeout(this._urlChangeDebounceTimeout); + this._urlChangeDebounceTimeout = setTimeout(() => { this.props.onUrlChange(url); }, DEBOUNCE_MILLIS); }; _handleUrlPaste = e => { + /* + * Prevent the change handler from being called. Note that this is in a timeout + * because we want it to happen after the onChange callback. If it happens before, + * then the change will overwrite anything that we do. + * + * Also, note that there is still a potential race condition here if, for some reason, + * the onChange callback is not called before DEBOUNCE_MILLIS is over. This is extremely + * unlikely since it should happen in the same tick. + */ const text = e.clipboardData.getData('text/plain'); - this.props.onUrlPaste(text); + setTimeout(() => { + // Clear any update timeouts that may have happened since we started waiting + clearTimeout(this._urlChangeDebounceTimeout); + this.props.onUrlPaste(text); + }, DEBOUNCE_MILLIS); }; _handleGenerateCode = () => { @@ -44,6 +62,27 @@ class RequestUrlBar extends Component { trackEvent('Request', 'Generate Code', 'Send Action'); }; + _handleSetDownloadLocation = () => { + const options = { + title: 'Select Download Location', + buttonLabel: 'Select', + properties: ['openDirectory'], + }; + + remote.dialog.showOpenDialog(options, paths => { + if (!paths || paths.length === 0) { + trackEvent('Response', 'Download Select Cancel'); + return; + } + + this.setState({downloadPath: paths[0]}); + }); + }; + + _handleClearDownloadLocation = () => { + this.setState({downloadPath: null}); + }; + _handleKeyDown = e => { if (!this._input) { return; @@ -63,7 +102,13 @@ class RequestUrlBar extends Component { // XXX this._handleStopInterval(); XXX this._handleStopTimeout(); - this.props.handleSend(); + + const {downloadPath} = this.state; + if (downloadPath) { + this.props.handleSendAndDownload(downloadPath); + } else { + this.props.handleSend(); + } }; _handleSendAfterDelay = async () => { @@ -130,16 +175,14 @@ class RequestUrlBar extends Component { componentDidMount () { document.body.addEventListener('keydown', this._handleKeyDown); - document.body.addEventListener('keyup', this._handleKeyUp); } componentWillUnmount () { document.body.removeEventListener('keydown', this._handleKeyDown); - document.body.removeEventListener('keyup', this._handleKeyUp); } renderSendButton () { - const {currentInterval, currentTimeout} = this.state; + const {currentInterval, currentTimeout, downloadPath} = this.state; let cancelButton = null; if (currentInterval) { @@ -169,7 +212,7 @@ class RequestUrlBar extends Component { - Send + {downloadPath ? "Download" : "Send"} Basic @@ -186,6 +229,18 @@ class RequestUrlBar extends Component { Repeat on Interval + {downloadPath ? ( + + Stop Auto-Download + + ) : ( + + Download After Send + + )} ) } @@ -220,6 +275,7 @@ class RequestUrlBar extends Component { RequestUrlBar.propTypes = { handleSend: PropTypes.func.isRequired, + handleSendAndDownload: PropTypes.func.isRequired, onUrlChange: PropTypes.func.isRequired, onUrlPaste: PropTypes.func.isRequired, onMethodChange: PropTypes.func.isRequired, diff --git a/app/ui/components/Wrapper.js b/app/ui/components/Wrapper.js index 8188fa925..394603d4f 100644 --- a/app/ui/components/Wrapper.js +++ b/app/ui/components/Wrapper.js @@ -119,6 +119,13 @@ class Wrapper extends Component { handleSendRequestWithEnvironment(activeRequestId, activeEnvironmentId); }; + _handleSendAndDownloadRequestWithActiveEnvironment = filename => { + const {activeRequest, activeEnvironment, handleSendAndDownloadRequestWithEnvironment} = this.props; + const activeRequestId = activeRequest ? activeRequest._id : 'n/a'; + const activeEnvironmentId = activeEnvironment ? activeEnvironment._id : 'n/a'; + handleSendAndDownloadRequestWithEnvironment(activeRequestId, activeEnvironmentId, filename); + }; + _handleSetPreviewMode = previewMode => { const activeRequest = this.props.activeRequest; const activeRequestId = activeRequest ? activeRequest._id : 'n/a'; @@ -162,6 +169,7 @@ class Wrapper extends Component { handleStartDragPane, handleStartDragSidebar, handleSetSidebarFilter, + handleGenerateCodeForActiveRequest, handleGenerateCode, isLoading, loadStartTime, @@ -234,7 +242,7 @@ class Wrapper extends Component { environmentId={activeEnvironment ? activeEnvironment._id : 'n/a'} workspace={activeWorkspace} handleCreateRequest={handleCreateRequestForWorkspace} - handleGenerateCode={handleGenerateCode} + handleGenerateCode={handleGenerateCodeForActiveRequest} updateRequest={this._handleUpdateRequest} updateRequestBody={this._handleUpdateRequestBody} updateRequestUrl={this._handleUpdateRequestUrl} @@ -248,6 +256,7 @@ class Wrapper extends Component { updateSettingsUseBulkHeaderEditor={this._handleUpdateSettingsUseBulkHeaderEditor} forceRefreshCounter={this.state.forceRefreshRequestPaneCounter} handleSend={this._handleSendRequestWithActiveEnvironment} + handleSendAndDownload={this._handleSendAndDownloadRequestWithActiveEnvironment} />
@@ -339,6 +348,7 @@ Wrapper.propTypes = { handleDuplicateRequest: PropTypes.func.isRequired, handleDuplicateRequestGroup: PropTypes.func.isRequired, handleCreateRequestGroup: PropTypes.func.isRequired, + handleGenerateCodeForActiveRequest: PropTypes.func.isRequired, handleGenerateCode: PropTypes.func.isRequired, handleCreateRequestForWorkspace: PropTypes.func.isRequired, handleSetRequestPaneRef: PropTypes.func.isRequired, @@ -353,6 +363,7 @@ Wrapper.propTypes = { handleResetDragPane: PropTypes.func.isRequired, handleSetRequestGroupCollapsed: PropTypes.func.isRequired, handleSendRequestWithEnvironment: PropTypes.func.isRequired, + handleSendAndDownloadRequestWithEnvironment: PropTypes.func.isRequired, // Properties loadStartTime: PropTypes.number.isRequired, diff --git a/app/ui/components/base/PromptButton.js b/app/ui/components/base/PromptButton.js index 470f3e37c..6b2360c4b 100644 --- a/app/ui/components/base/PromptButton.js +++ b/app/ui/components/base/PromptButton.js @@ -66,7 +66,7 @@ class PromptButton extends Component { if (state === STATE_ASK && addIcon) { innerMsg = ( - {CONFIRM_MESSAGE} + {CONFIRM_MESSAGE} ) } else if (state === STATE_ASK) { diff --git a/app/ui/containers/App.js b/app/ui/containers/App.js index 3d26f36f6..c9eb54eba 100644 --- a/app/ui/containers/App.js +++ b/app/ui/containers/App.js @@ -1,4 +1,5 @@ import React, {Component, PropTypes} from 'react'; +import fs from 'fs'; import {ipcRenderer} from 'electron'; import ReactDOM from 'react-dom'; import {connect} from 'react-redux'; @@ -25,6 +26,8 @@ import GenerateCodeModal from '../components/modals/GenerateCodeModal'; import WorkspaceSettingsModal from '../components/modals/WorkspaceSettingsModal'; import * as network from '../../common/network'; import {debounce} from '../../common/misc'; +import * as mime from 'mime-types'; +import * as path from 'path'; const KEY_ENTER = 13; const KEY_COMMA = 188; @@ -158,8 +161,12 @@ class App extends Component { this._handleSetActiveRequest(newRequest._id) }; - _handleGenerateCode = () => { - showModal(GenerateCodeModal, this.props.activeRequest); + _handleGenerateCodeForActiveRequest = () => { + this._handleGenerateCode(this.props.activeRequest); + }; + + _handleGenerateCode = request => { + showModal(GenerateCodeModal, request); }; _updateRequestGroupMetaByParentId = async (requestGroupId, patch) => { @@ -233,6 +240,51 @@ class App extends Component { this._updateRequestMetaByParentId(requestId, {responseFilter}); }; + _handleSendAndDownloadRequestWithEnvironment = async (requestId, environmentId, dir) => { + const request = await models.request.getById(requestId); + if (!request) { + return; + } + + // NOTE: Since request is by far the most popular event, we will throttle + // it so that we only track it if the request has changed since the last one + const key = request._id; + if (this._sendRequestTrackingKey !== key) { + trackEvent('Request', 'Send and Download'); + trackLegacyEvent('Request Send'); + this._sendRequestTrackingKey = key; + } + + // Start loading + this.props.handleStartLoading(requestId); + + try { + const responsePatch = await network.send(requestId, environmentId); + if (responsePatch.statusCode >= 200 && responsePatch.statusCode < 300) { + const extension = mime.extension(responsePatch.contentType) || ''; + const name = request.name.replace(/\s/g, '-').toLowerCase(); + const filename = path.join(dir, `${name}.${extension}`); + const partialResponse = Object.assign({}, responsePatch, { + contentType: "text/plain", + body: `Saved to ${filename}`, + encoding: 'utf8', + }); + await models.response.create(partialResponse); + fs.writeFile(filename, responsePatch.body, responsePatch.encoding) + } else { + await models.response.create(responsePatch); + } + } catch (e) { + // It's OK + } + + // Unset active response because we just made a new one + await this._updateRequestMetaByParentId(requestId, {activeResponseId: null}); + + // Stop loading + this.props.handleStopLoading(requestId); + }; + _handleSendRequestWithEnvironment = async (requestId, environmentId) => { const request = await models.request.getById(requestId); if (!request) { @@ -251,7 +303,8 @@ class App extends Component { this.props.handleStartLoading(requestId); try { - await network.send(requestId, environmentId); + const responsePatch = await network.send(requestId, environmentId); + await models.response.create(responsePatch); } catch (e) { // It's OK } @@ -450,9 +503,11 @@ class App extends Component { handleDuplicateRequestGroup={this._requestGroupDuplicate} handleCreateRequestGroup={this._requestGroupCreate} handleGenerateCode={this._handleGenerateCode} + handleGenerateCodeForActiveRequest={this._handleGenerateCodeForActiveRequest} handleSetResponsePreviewMode={this._handleSetResponsePreviewMode} handleSetResponseFilter={this._handleSetResponseFilter} handleSendRequestWithEnvironment={this._handleSendRequestWithEnvironment} + handleSendAndDownloadRequestWithEnvironment={this._handleSendAndDownloadRequestWithEnvironment} handleSetActiveResponse={this._handleSetActiveResponse} handleSetActiveRequest={this._handleSetActiveRequest} handleSetActiveEnvironment={this._handleSetActiveEnvironment}