From 6a136bd76a52d67006be333f28040c1c060be3cd Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 23 Nov 2016 11:33:24 -0800 Subject: [PATCH] Add multi-part form support (#49) * Add multi-part form support * tests for form and multipart * Better Analytics Tracking --- app/common/__tests__/network.test.js | 104 ++++++++++++++- app/common/__tests__/querystring.test.js | 4 +- app/common/__tests__/testfile.txt | 1 + app/common/constants.js | 6 +- app/common/network.js | 23 +++- app/common/querystring.js | 2 +- app/models/request.js | 40 ++---- app/ui/components/RenderedQueryString.js | 2 +- app/ui/components/RequestPane.js | 22 ++-- app/ui/components/ResponsePane.js | 12 +- app/ui/components/Toast.js | 22 +++- app/ui/components/base/Editable.js | 9 +- app/ui/components/base/Editor.js | 2 + app/ui/components/base/FileInputButton.js | 32 ++--- app/ui/components/base/KeyValueEditor.js | 120 +++++++++++++----- app/ui/components/base/Link.js | 25 +++- .../dropdowns/ContentTypeDropdown.js | 6 +- .../dropdowns/EnvironmentsDropdown.js | 11 +- .../dropdowns/PreviewModeDropdown.js | 6 +- .../dropdowns/RequestActionsDropdown.js | 21 ++- .../dropdowns/RequestGroupActionsDropdown.js | 14 +- app/ui/components/dropdowns/SyncDropdown.js | 14 +- .../components/dropdowns/WorkspaceDropdown.js | 21 ++- app/ui/components/editors/AuthEditor.js | 4 + .../editors/RequestHeadersEditor.js | 4 + app/ui/components/editors/body/FileEditor.js | 26 +++- app/ui/components/editors/body/FormEditor.js | 12 +- .../editors/body/UrlEncodedEditor.js | 9 +- app/ui/components/modals/CookiesModal.js | 6 + app/ui/components/modals/LoginModal.js | 4 +- .../components/modals/RequestSwitcherModal.js | 6 +- app/ui/components/modals/SettingsModal.js | 15 ++- app/ui/components/modals/SignupModal.js | 2 +- .../modals/WorkspaceEnvironmentsEditModal.js | 13 +- app/ui/components/settings/SettingsGeneral.js | 1 - app/ui/components/settings/SettingsSync.js | 4 +- app/ui/components/sidebar/SidebarFilter.js | 12 +- .../sidebar/SidebarRequestGroupRow.js | 7 +- .../components/sidebar/SidebarRequestRow.js | 13 +- app/ui/components/viewers/ResponseError.js | 7 +- app/ui/containers/App.js | 18 ++- app/ui/css/components/keyvalueeditor.less | 8 +- package.json | 2 +- 43 files changed, 513 insertions(+), 179 deletions(-) create mode 100644 app/common/__tests__/testfile.txt diff --git a/app/common/__tests__/network.test.js b/app/common/__tests__/network.test.js index 7dd01eb34f..d5e5daccee 100644 --- a/app/common/__tests__/network.test.js +++ b/app/common/__tests__/network.test.js @@ -1,9 +1,12 @@ import * as networkUtils from '../network'; import * as db from '../database'; import nock from 'nock'; +import {resolve as pathResolve, join as pathJoin} from 'path'; import {getRenderedRequest} from '../render'; import * as models from '../../models'; import {CONTENT_TYPE_FORM_URLENCODED} from '../constants'; +import {CONTENT_TYPE_FILE} from '../constants'; +import {CONTENT_TYPE_FORM_DATA} from '../constants'; describe('buildRequestConfig()', () => { beforeEach(() => db.init(models.types(), {inMemoryOnly: true}, true)); @@ -128,7 +131,7 @@ describe('actuallySend()', () => { .matchHeader('Content-Type', 'application/json') .matchHeader('Authorization', 'Basic dXNlcjpwYXNz') .matchHeader('Cookie', 'foo=barrrrr') - .post('/') + .post('/', 'foo=bar') .query({'foo bar': 'hello&world'}) .reply(200, 'response body') .log(console.log); @@ -141,7 +144,7 @@ describe('actuallySend()', () => { method: 'POST', body: { mimeType: CONTENT_TYPE_FORM_URLENCODED, - text: 'foo=bar' + params: [{name: 'foo', value: 'bar'}] }, url: 'http://localhost', authentication: { @@ -152,9 +155,106 @@ describe('actuallySend()', () => { const renderedRequest = await getRenderedRequest(request); const response = await networkUtils._actuallySend(renderedRequest, settings); + expect(mock.basePath).toBe('http://::1:80'); + expect(response.error).toBe(''); 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); }); + + it('sends a file', async () => { + let mock; + + const workspace = await models.workspace.create(); + const settings = await models.settings.create(); + await models.cookieJar.create({parentId: workspace._id}); + + mock = nock('http://[::1]:80') + .matchHeader('Content-Type', 'application/octet-stream') + .post('/', 'Hello World!') + .reply(200, 'response body') + .log(console.log); + + const request = Object.assign(models.request.init(), { + _id: 'req_123', + parentId: workspace._id, + headers: [{name: 'Content-Type', value: 'application/octet-stream'}], + url: 'http://localhost', + method: 'POST', + body: { + mimeType: CONTENT_TYPE_FILE, + fileName: pathResolve(pathJoin(__dirname, './testfile.txt')) // Let's send ourselves + } + }); + + const renderedRequest = await getRenderedRequest(request); + const response = await networkUtils._actuallySend(renderedRequest, settings); + + expect(mock.basePath).toBe('http://::1:80'); + expect(response.error).toBe(''); + expect(response.url).toBe('http://localhost/'); + expect(response.body).toBe(new Buffer('response body').toString('base64')); + expect(response.statusCode).toBe(200); + }); + + it('sends multipart form data', async () => { + let mock; + + const workspace = await models.workspace.create(); + const settings = await models.settings.create(); + await models.cookieJar.create({parentId: workspace._id}); + const fileName = pathResolve(pathJoin(__dirname, './testfile.txt')); + let requestBody = 'n/a'; + mock = nock('http://[::1]:80') + .matchHeader('Content-Type', /^multipart\/form-data/) + .post('/', body => { + requestBody = body; + return true; + }) + .reply(200, 'response body') + .log(console.log); + + const request = Object.assign(models.request.init(), { + _id: 'req_123', + parentId: workspace._id, + headers: [{name: 'Content-Type', value: 'multipart/form-data'}], + url: 'http://localhost', + method: 'POST', + body: { + mimeType: CONTENT_TYPE_FORM_DATA, + params: [ + // Should ignore value and send the file since type is set to file + {name: 'foo', fileName: fileName, value: 'bar', type: 'file'}, + + // Some extra params + {name: 'a', value: 'AA'}, + {name: 'baz', value: 'qux', disabled: true}, + ] + }, + }); + + const renderedRequest = await getRenderedRequest(request); + const response = await networkUtils._actuallySend(renderedRequest, settings); + + expect(mock.basePath).toBe('http://::1:80'); + expect(response.error).toBe(''); + expect(response.url).toBe('http://localhost/'); + expect(response.body).toBe(new Buffer('response body').toString('base64')); + expect(response.statusCode).toBe(200); + + const lines = requestBody.split(/\r\n/); + expect(lines.length).toBe(11); + expect(lines[0]).toMatch(/^----------------------------\d{24}/); + expect(lines[1]).toBe('Content-Disposition: form-data; name="foo"'); + expect(lines[2]).toBe('Content-Type: text/plain'); + expect(lines[3]).toBe(''); + expect(lines[4]).toBe('Hello World!\n'); + expect(lines[5]).toMatch(/^----------------------------\d{24}/); + expect(lines[6]).toBe('Content-Disposition: form-data; name="a"'); + expect(lines[7]).toBe(''); + expect(lines[8]).toBe('AA'); + expect(lines[9]).toMatch(/^----------------------------\d{24}--/); + expect(lines[10]).toBe(''); + }); }); diff --git a/app/common/__tests__/querystring.test.js b/app/common/__tests__/querystring.test.js index b9d08af606..443afd09c3 100644 --- a/app/common/__tests__/querystring.test.js +++ b/app/common/__tests__/querystring.test.js @@ -31,7 +31,7 @@ describe('getBasicAuthHeader()', () => { describe('joinUrl()', () => { it('gets joiner for bare URL', () => { - const url = querystringUtils.joinURL( + const url = querystringUtils.joinUrl( 'http://google.com', 'foo=bar' ); @@ -39,7 +39,7 @@ describe('joinUrl()', () => { }); it('gets joiner for URL with querystring', () => { - const url = querystringUtils.joinURL( + const url = querystringUtils.joinUrl( 'http://google.com?hi=there', 'foo=bar%20baz' ); diff --git a/app/common/__tests__/testfile.txt b/app/common/__tests__/testfile.txt new file mode 100644 index 0000000000..980a0d5f19 --- /dev/null +++ b/app/common/__tests__/testfile.txt @@ -0,0 +1 @@ +Hello World! diff --git a/app/common/constants.js b/app/common/constants.js index aeb0330202..12ce4adf2d 100644 --- a/app/common/constants.js +++ b/app/common/constants.js @@ -122,9 +122,9 @@ export const CONTENT_TYPE_RAW = ''; export const contentTypesMap = { [CONTENT_TYPE_JSON]: 'JSON', [CONTENT_TYPE_XML]: 'XML', - // [CONTENT_TYPE_FORM_DATA]: 'Form Data', + [CONTENT_TYPE_FORM_DATA]: 'Form Data', [CONTENT_TYPE_FORM_URLENCODED]: 'Form Url Encoded', - [CONTENT_TYPE_FILE]: 'File Upload', + [CONTENT_TYPE_FILE]: 'Binary', [CONTENT_TYPE_TEXT]: 'Plain Text', [CONTENT_TYPE_RAW]: 'Raw Body', }; @@ -136,7 +136,7 @@ export const contentTypesMap = { * @returns {*|string} */ export function getContentTypeName (contentType) { - return contentTypesMap[contentType] || 'Other'; + return contentTypesMap[contentType] || 'Body'; } export function getContentTypeFromHeaders (headers) { diff --git a/app/common/network.js b/app/common/network.js index 8a9405714c..4fdda7e94b 100644 --- a/app/common/network.js +++ b/app/common/network.js @@ -1,15 +1,16 @@ import networkRequest from 'request'; import {parse as urlParse} from 'url'; +import mime from 'mime-types'; +import {basename as pathBasename} from 'path'; import * as models from '../models'; import * as querystring from './querystring'; import {buildFromParams} from './querystring'; import * as util from './misc.js'; -import {DEBOUNCE_MILLIS, STATUS_CODE_PEBKAC, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED} from './constants'; +import {DEBOUNCE_MILLIS, STATUS_CODE_PEBKAC, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, CONTENT_TYPE_FILE} from './constants'; import {jarFromCookies, cookiesFromJar, cookieHeaderValueForUri} from './cookies'; import {setDefaultProtocol} from './misc'; import {getRenderedRequest} from './render'; import {swapHost} from './dns'; -import {CONTENT_TYPE_FILE} from './constants'; import * as fs from 'fs'; let cancelRequestFunction = null; @@ -51,7 +52,21 @@ export function _buildRequestConfig (renderedRequest, patch = {}) { if (renderedRequest.body.mimeType === CONTENT_TYPE_FORM_URLENCODED) { config.body = buildFromParams(renderedRequest.body.params || [], true); } else if (renderedRequest.body.mimeType === CONTENT_TYPE_FORM_DATA) { - // TODO: This + const formData = {}; + for (const param of renderedRequest.body.params) { + if (param.type === 'file' && param.fileName) { + formData[param.name] = { + value: fs.readFileSync(param.fileName), + options: { + fileName: pathBasename(param.fileName), + contentType: mime.lookup(param.fileName) // Guess the mime-type + } + } + } else { + formData[param.name] = param.value || ''; + } + } + config.formData = formData; } else if (renderedRequest.body.mimeType === CONTENT_TYPE_FILE) { config.body = fs.readFileSync(renderedRequest.body.fileName); } else { @@ -73,7 +88,7 @@ export function _buildRequestConfig (renderedRequest, patch = {}) { // Set the URL, including the query parameters const qs = querystring.buildFromParams(renderedRequest.parameters); - const url = querystring.joinURL(renderedRequest.url, qs); + const url = querystring.joinUrl(renderedRequest.url, qs); config.url = util.prepareUrlForSending(url); config.headers.host = urlParse(config.url).host; diff --git a/app/common/querystring.js b/app/common/querystring.js index 99b11e6dbc..5c5a817681 100644 --- a/app/common/querystring.js +++ b/app/common/querystring.js @@ -1,7 +1,7 @@ import * as util from './misc.js'; /** Join a URL with a querystring */ -export function joinURL (url, qs) { +export function joinUrl (url, qs) { if (!qs) { return url; } diff --git a/app/models/request.js b/app/models/request.js index 6bf485d783..bd8f9898fd 100644 --- a/app/models/request.js +++ b/app/models/request.js @@ -25,24 +25,6 @@ export function init () { }; } -export function getBodyDescription (body) { - if (body.fileName) { - return 'File Upload'; - } else if (body.mimeType === CONTENT_TYPE_FORM_URLENCODED) { - return 'Form Url Encoded'; - } else if (body.mimeType === CONTENT_TYPE_FORM_DATA) { - return 'Form Data'; - } else if (body.mimeType === CONTENT_TYPE_JSON) { - return 'JSON'; - } else if (body.mimeType === CONTENT_TYPE_XML) { - return 'XML'; - } else if (body.mimeType === CONTENT_TYPE_TEXT) { - return 'Plain Text'; - } else { - return 'Raw Body'; - } -} - export function newBodyRaw (rawBody, contentType) { if (!contentType) { return {text: rawBody}; @@ -53,6 +35,11 @@ export function newBodyRaw (rawBody, contentType) { } export function newBodyFormUrlEncoded (parameters) { + // Remove any properties (eg. fileName) that might not fit + parameters = (parameters || []).map( + p => ({name: p.name, value: p.value}) + ); + return { mimeType: CONTENT_TYPE_FORM_URLENCODED, params: parameters @@ -121,19 +108,20 @@ export function updateMimeType (request, mimeType) { // 2. Make a new request body // TODO: When switching mime-type, try to convert formats nicely - if (mimeType === CONTENT_TYPE_FORM_URLENCODED) { - request.body = newBodyFormUrlEncoded(request.body.params || []); + let body; + if (mimeType === request.body.mimeType) { + body = request.body; + } else if (mimeType === CONTENT_TYPE_FORM_URLENCODED) { + body = newBodyFormUrlEncoded(request.body.params); } else if (mimeType === CONTENT_TYPE_FORM_DATA) { - request.body = newBodyForm(request.body.params || []); - } else if (mimeType === CONTENT_TYPE_JSON) { - request.body = newBodyRaw(request.body.text || ''); + body = newBodyForm(request.body.params || []); } else if (mimeType === CONTENT_TYPE_FILE) { - request.body = newBodyFile(''); + body = newBodyFile(''); } else { - request.body = newBodyRaw(request.body.text || '', mimeType); + body = newBodyRaw(request.body.text || '', mimeType); } - return update(request, {headers}); + return update(request, {headers, body}); } export function duplicate (request) { diff --git a/app/ui/components/RenderedQueryString.js b/app/ui/components/RenderedQueryString.js index 96e30ef047..d0e12b7ca3 100644 --- a/app/ui/components/RenderedQueryString.js +++ b/app/ui/components/RenderedQueryString.js @@ -18,7 +18,7 @@ class RenderedQueryString extends Component { const {request, environmentId} = props; const {url, parameters} = await getRenderedRequest(request, environmentId); const qs = querystring.buildFromParams(parameters); - const fullUrl = querystring.joinURL(url, qs); + const fullUrl = querystring.joinUrl(url, qs); this.setState({string: util.prepareUrlForSending(fullUrl)}); }, delay ? 200 : 0); } diff --git a/app/ui/components/RequestPane.js b/app/ui/components/RequestPane.js index 3f20980b95..3343883953 100644 --- a/app/ui/components/RequestPane.js +++ b/app/ui/components/RequestPane.js @@ -7,9 +7,9 @@ import RenderedQueryString from './RenderedQueryString'; import BodyEditor from './editors/body/BodyEditor'; import AuthEditor from './editors/AuthEditor'; import RequestUrlBar from './RequestUrlBar.js'; -import {MOD_SYM, getContentTypeName, getContentTypeFromHeaders} from '../../common/constants'; +import {MOD_SYM, getContentTypeName} from '../../common/constants'; import {debounce} from '../../common/misc'; -import {getBodyDescription} from '../../models/request'; +import {trackEvent} from '../../analytics/index'; class RequestPane extends Component { render () { @@ -102,23 +102,23 @@ class RequestPane extends Component { - + trackEvent('Request Pane', 'View', 'Body')}> - + trackEvent('Request Pane', 'View', 'Auth')}> - + trackEvent('Request Pane', 'View', 'Query')}> - + trackEvent('Request Pane', 'View', 'Headers')}> @@ -159,6 +159,9 @@ class RequestPane extends Component { key={request._id} namePlaceholder="name" valuePlaceholder="value" + onToggleDisable={pair => trackEvent('Query', 'Toggle', pair.disabled ? 'Disable' : 'Enable')} + onCreate={() => trackEvent('Query', 'Create')} + onDelete={() => trackEvent('Query', 'Delete')} pairs={request.parameters} onChange={updateRequestParameters} /> @@ -175,7 +178,10 @@ class RequestPane extends Component {
diff --git a/app/ui/components/ResponsePane.js b/app/ui/components/ResponsePane.js index 2babe8315b..b735bf76bf 100644 --- a/app/ui/components/ResponsePane.js +++ b/app/ui/components/ResponsePane.js @@ -55,16 +55,16 @@ class ResponsePane extends Component { remote.dialog.showSaveDialog(options, filename => { if (!filename) { - trackEvent('Response', 'Save', 'Cancel'); + trackEvent('Response', 'Save Cancel'); return; } fs.writeFile(filename, bodyBuffer, {}, err => { if (err) { console.warn('Failed to save response body', err); - trackEvent('Response', 'Save', 'Failure'); + trackEvent('Response', 'Save Failure'); } else { - trackEvent('Response', 'Save', 'Success'); + trackEvent('Response', 'Save Success'); } }); @@ -197,7 +197,7 @@ class ResponsePane extends Component { )} - + trackEvent('Response Pane', 'View', 'Response')}> @@ -207,7 +207,7 @@ class ResponsePane extends Component { updatePreviewMode={handleSetPreviewMode} /> - + trackEvent('Response Pane', 'View', 'Cookies')}> - + trackEvent('Response Pane', 'View', 'Headers')}> ) } } FileInputButton.propTypes = { + // Required onChange: PropTypes.func.isRequired, path: PropTypes.string.isRequired, + + // Optional + showFileName: PropTypes.bool, }; export default FileInputButton; diff --git a/app/ui/components/base/KeyValueEditor.js b/app/ui/components/base/KeyValueEditor.js index 7318520839..ae7f2fd9fc 100644 --- a/app/ui/components/base/KeyValueEditor.js +++ b/app/ui/components/base/KeyValueEditor.js @@ -1,6 +1,8 @@ import React, {Component, PropTypes} from 'react'; import classnames from 'classnames'; import {DEBOUNCE_MILLIS} from '../../../common/constants'; +import FileInputButton from '../base/FileInputButton'; +import {Dropdown, DropdownItem, DropdownButton} from './dropdown/index'; const NAME = 'name'; const VALUE = 'value'; @@ -49,6 +51,8 @@ class KeyValueEditor extends Component { ...this.state.pairs.slice(position) ]; + this.props.onCreate && this.props.onCreate(); + this._onChange(pairs); } @@ -56,13 +60,20 @@ class KeyValueEditor extends Component { if (this._focusedPair >= position) { this._focusedPair = this._focusedPair - 1; } - this._onChange(this.state.pairs.filter((_, i) => i !== position)); + + const pair = this.state.pairs[position]; + this.props.onDelete && this.props.onDelete(pair); + + const pairs = this.state.pairs.filter((_, i) => i !== position); + + this._onChange(pairs); } _updatePair (position, pairPatch) { - const pairs = this.state.pairs.map( - (p, i) => i == position ? Object.assign({}, p, pairPatch) : p - ); + const pairs = this.state.pairs.map((p, i) => ( + i == position ? Object.assign({}, p, pairPatch) : p + )); + this._onChange(pairs); } @@ -70,6 +81,10 @@ class KeyValueEditor extends Component { const pairs = this.state.pairs.map( (p, i) => i == position ? Object.assign({}, p, {disabled: !p.disabled}) : p ); + + const pair = pairs[position]; + this.props.onToggleDisable && this.props.onToggleDisable(pair); + this._onChange(pairs, true); } @@ -169,7 +184,7 @@ class KeyValueEditor extends Component { render () { const {pairs} = this.state; - const {maxPairs, className, valueInputType} = this.props; + const {maxPairs, className, valueInputType, multipart} = this.props; return (
    @@ -200,35 +215,62 @@ class KeyValueEditor extends Component { /> -
    -
    - this._valueInputs[i] = n} - defaultValue={pair.value} - onChange={e => this._updatePair(i, {value: e.target.value})} - onFocus={e => { - this._focusedPair = i; - this._focusedField = VALUE; - this._focusedInput = e.target; - }} - onBlur={() => { - this._focusedPair = -1 - }} - onKeyDown={this._keyDown.bind(this)} - /> +
    +
    + {pair.type === 'file' ? ( + { + this._updatePair(i, {fileName}); + this.props.onChooseFile && this.props.onChooseFile(); + }} + path={pair.fileName || ''} + /> + ) : ( + this._valueInputs[i] = n} + defaultValue={pair.value} + onChange={e => this._updatePair(i, {value: e.target.value})} + onFocus={e => { + this._focusedPair = i; + this._focusedField = VALUE; + this._focusedInput = e.target; + }} + onBlur={() => { + this._focusedPair = -1 + }} + onKeyDown={this._keyDown.bind(this)} + /> + )}
    -
    + + {multipart ? ( + + ) : null} + + @@ -279,10 +329,16 @@ KeyValueEditor.propTypes = { pairs: PropTypes.arrayOf(PropTypes.object).isRequired, // Optional + multipart: PropTypes.bool, maxPairs: PropTypes.number, namePlaceholder: PropTypes.string, valuePlaceholder: PropTypes.string, - valueInputType: PropTypes.string + valueInputType: PropTypes.string, + onToggleDisable: PropTypes.func, + onChangeType: PropTypes.func, + onChooseFile: PropTypes.func, + onDelete: PropTypes.func, + onCreate: PropTypes.func, }; export default KeyValueEditor; diff --git a/app/ui/components/base/Link.js b/app/ui/components/base/Link.js index 2000569dc5..59df1eb76d 100644 --- a/app/ui/components/base/Link.js +++ b/app/ui/components/base/Link.js @@ -1,15 +1,36 @@ import React, {Component, PropTypes} from 'react'; import {shell} from 'electron'; +import {trackEvent} from '../../../analytics/index'; +import * as querystring from '../../../common/querystring'; +import {getAppVersion} from '../../../common/constants'; class Link extends Component { + constructor (props) { + super(props); + this._boundHandleClick = this._handleClick.bind(this); + } + _handleClick (e) { + e && e.preventDefault(); + const {href} = this.props; + if (href.match(/^http/i)) { + const qs = `utm_source=Insomnia&utm_medium=App&utm_campaign=v${getAppVersion()}`; + const attributedHref = querystring.joinUrl(href, qs); + shell.openExternal(attributedHref); + } else { + // Don't modify non-http urls + shell.openExternal(href); + } + + trackEvent('Link', 'Click', href) + } render () { const {button, href, children, ...other} = this.props; return button ? ( - ) :( - {e.preventDefault(); shell.openExternal(href)}} {...other}> + {children} ) diff --git a/app/ui/components/dropdowns/ContentTypeDropdown.js b/app/ui/components/dropdowns/ContentTypeDropdown.js index ca0c66716e..ea3c9e9f25 100644 --- a/app/ui/components/dropdowns/ContentTypeDropdown.js +++ b/app/ui/components/dropdowns/ContentTypeDropdown.js @@ -1,6 +1,7 @@ import React, {PropTypes} from 'react'; import {Dropdown, DropdownButton, DropdownItem} from '../base/dropdown'; import {contentTypesMap} from '../../../common/constants'; +import {trackEvent} from '../../../analytics/index'; const ContentTypeDropdown = ({updateRequestMimeType}) => { return ( @@ -9,7 +10,10 @@ const ContentTypeDropdown = ({updateRequestMimeType}) => { {Object.keys(contentTypesMap).map(mimeType => ( - updateRequestMimeType(mimeType)}> + { + updateRequestMimeType(mimeType); + trackEvent('Request', 'Content-Type Change', contentTypesMap[mimeType]); + }}> {contentTypesMap[mimeType]} ))} diff --git a/app/ui/components/dropdowns/EnvironmentsDropdown.js b/app/ui/components/dropdowns/EnvironmentsDropdown.js index 8376579bc5..1b3e2ee93e 100644 --- a/app/ui/components/dropdowns/EnvironmentsDropdown.js +++ b/app/ui/components/dropdowns/EnvironmentsDropdown.js @@ -4,6 +4,7 @@ import classnames from 'classnames'; import EnvironmentsModal from '../modals/WorkspaceEnvironmentsEditModal'; import {Dropdown, DropdownDivider, DropdownButton, DropdownItem} from '../base/dropdown'; import {showModal} from '../modals/index'; +import {trackEvent} from '../../../analytics/index'; const EnvironmentsDropdown = ({ @@ -37,11 +38,17 @@ const EnvironmentsDropdown = ({ {subEnvironments.map(environment => ( handleChangeEnvironment(environment._id)}> + onClick={e => { + handleChangeEnvironment(environment._id); + trackEvent('Environment', 'Activate'); + }}> Use {environment.name} ))} - baseEnvironment && handleChangeEnvironment(null)}> + { + baseEnvironment && handleChangeEnvironment(null); + trackEvent('Environment', 'Deactivate'); + }}> No Environment diff --git a/app/ui/components/dropdowns/PreviewModeDropdown.js b/app/ui/components/dropdowns/PreviewModeDropdown.js index 98c83d6fce..74e8b1ecbd 100644 --- a/app/ui/components/dropdowns/PreviewModeDropdown.js +++ b/app/ui/components/dropdowns/PreviewModeDropdown.js @@ -1,6 +1,7 @@ import React, {PropTypes} from 'react'; import {Dropdown, DropdownDivider, DropdownButton, DropdownItem} from '../base/dropdown'; import {PREVIEW_MODES, getPreviewModeName} from '../../../common/constants'; +import {trackEvent} from '../../../analytics/index'; const PreviewModeDropdown = ({updatePreviewMode, download}) => ( @@ -8,7 +9,10 @@ const PreviewModeDropdown = ({updatePreviewMode, download}) => ( {PREVIEW_MODES.map(previewMode => ( - updatePreviewMode(previewMode)}> + { + updatePreviewMode(previewMode); + trackEvent('Response', 'Preview Mode Change', previewMode); + }}> {getPreviewModeName(previewMode)} ))} diff --git a/app/ui/components/dropdowns/RequestActionsDropdown.js b/app/ui/components/dropdowns/RequestActionsDropdown.js index a9702c8a6a..dd02e84b3e 100644 --- a/app/ui/components/dropdowns/RequestActionsDropdown.js +++ b/app/ui/components/dropdowns/RequestActionsDropdown.js @@ -5,6 +5,7 @@ import GenerateCodeModal from '../modals/GenerateCodeModal'; import PromptModal from '../modals/PromptModal'; import * as models from '../../../models'; import {showModal} from '../modals/index'; +import {trackEvent} from '../../../analytics/index'; class RequestActionsDropdown extends Component { @@ -28,18 +29,30 @@ class RequestActionsDropdown extends Component { - models.request.duplicate(request)}> + { + models.request.duplicate(request); + trackEvent('Request', 'Duplicate', 'Action'); + }}> Duplicate - this._promptUpdateName()}> + { + this._promptUpdateName(); + trackEvent('Request', 'Rename', 'Action'); + }}> Rename - showModal(GenerateCodeModal, request)}> + { + showModal(GenerateCodeModal, request); + trackEvent('Request', 'Action', 'Generate Code'); + }}> Generate Code models.request.remove(request)} + onClick={e => { + models.request.remove(request); + trackEvent('Request', 'Delete', 'Action'); + }} addIcon={true}> Delete diff --git a/app/ui/components/dropdowns/RequestGroupActionsDropdown.js b/app/ui/components/dropdowns/RequestGroupActionsDropdown.js index cd67662cb6..8233ff8dd5 100644 --- a/app/ui/components/dropdowns/RequestGroupActionsDropdown.js +++ b/app/ui/components/dropdowns/RequestGroupActionsDropdown.js @@ -5,6 +5,7 @@ import EnvironmentEditModal from '../modals/EnvironmentEditModal'; import PromptModal from '../modals/PromptModal'; import * as models from '../../../models'; import {showModal} from '../modals'; +import {trackEvent} from '../../../analytics/index'; class RequestGroupActionsDropdown extends Component { async _promptUpdateName () { @@ -16,6 +17,8 @@ class RequestGroupActionsDropdown extends Component { }); models.requestGroup.update(requestGroup, {name}); + + trackEvent('Folder', 'Rename', 'Folder Action'); } async _requestCreate () { @@ -29,11 +32,13 @@ class RequestGroupActionsDropdown extends Component { const parentId = requestGroup._id; const request = await models.request.create({parentId, name}); this.props.actions.global.activateRequest(workspace, request); + trackEvent('Request', 'Create', 'Folder Action'); } _requestGroupDuplicate () { const {requestGroup} = this.props; models.requestGroup.duplicate(requestGroup); + trackEvent('Folder', 'Duplicate', 'Folder Action'); } async _requestGroupCreate () { @@ -45,6 +50,8 @@ class RequestGroupActionsDropdown extends Component { const {requestGroup} = this.props; models.requestGroup.create({parentId: requestGroup._id, name}); + + trackEvent('Folder', 'Create', 'Folder Action'); } render () { @@ -73,9 +80,10 @@ class RequestGroupActionsDropdown extends Component { onClick={e => showModal(EnvironmentEditModal, requestGroup)}> Environment - models.requestGroup.remove(requestGroup)} - addIcon={true}> + { + models.requestGroup.remove(requestGroup); + trackEvent('Folder', 'Delete', 'Folder Action'); + }}> Delete diff --git a/app/ui/components/dropdowns/SyncDropdown.js b/app/ui/components/dropdowns/SyncDropdown.js index 79b784f382..394296c168 100644 --- a/app/ui/components/dropdowns/SyncDropdown.js +++ b/app/ui/components/dropdowns/SyncDropdown.js @@ -120,10 +120,9 @@ class SyncDropdown extends Component { if (!loggedIn) { return (
    - trackEvent('Sync', 'Show Menu', 'Guest')}> - + + trackEvent('Sync', 'Show Menu', 'Guest')}> Sync Settings @@ -167,10 +166,9 @@ class SyncDropdown extends Component { return (
    - trackEvent('Sync', 'Show Menu', 'Authenticated')}> - + + trackEvent('Sync', 'Show Menu', 'Authenticated')}> {description} diff --git a/app/ui/components/dropdowns/WorkspaceDropdown.js b/app/ui/components/dropdowns/WorkspaceDropdown.js index e5ce6013b9..38cc762e7e 100644 --- a/app/ui/components/dropdowns/WorkspaceDropdown.js +++ b/app/ui/components/dropdowns/WorkspaceDropdown.js @@ -10,6 +10,7 @@ import * as models from '../../../models'; import {getAppVersion} from '../../../common/constants'; import {showModal} from '../modals/index'; import {TAB_INDEX_EXPORT} from '../modals/SettingsModal'; +import {trackEvent} from '../../../analytics/index'; class WorkspaceDropdown extends Component { async _promptUpdateName () { @@ -72,13 +73,19 @@ class WorkspaceDropdown extends Component { - this._promptUpdateName()}> + { + this._promptUpdateName(); + trackEvent('Workspace', 'Rename'); + }}> Rename {" "} {activeWorkspace.name} this._workspaceRemove()} + onClick={e => { + this._workspaceRemove(); + trackEvent('Workspace', 'Delete'); + }} addIcon={true}> Delete {" "} @@ -88,13 +95,19 @@ class WorkspaceDropdown extends Component { {workspaces.map(w => w._id === activeWorkspace._id ? null : ( - handleSetActiveWorkspace(w._id)}> + { + handleSetActiveWorkspace(w._id); + trackEvent('Workspace', 'Switch'); + }}> Switch to {" "} {w.name} ))} - this._workspaceCreate()}> + { + this._workspaceCreate(); + trackEvent('Workspace', 'Create'); + }}> New Workspace diff --git a/app/ui/components/editors/AuthEditor.js b/app/ui/components/editors/AuthEditor.js index fc03eabdfc..4f0e560aa5 100644 --- a/app/ui/components/editors/AuthEditor.js +++ b/app/ui/components/editors/AuthEditor.js @@ -1,5 +1,6 @@ import React, {PropTypes} from 'react'; import KeyValueEditor from '../base/KeyValueEditor'; +import {trackEvent} from '../../../analytics/index'; const AuthEditor = ({request, showPasswords, onChange, ...other}) => { const auth = request.authentication; @@ -16,6 +17,9 @@ const AuthEditor = ({request, showPasswords, onChange, ...other}) => { namePlaceholder="Username" valuePlaceholder="********" valueInputType={showPasswords ? 'text' : 'password'} + onToggleDisable={pair => trackEvent('Auth Editor', 'Toggle', pair.disabled ? 'Disable' : 'Enable')} + onCreate={() => trackEvent('Auth Editor', 'Create')} + onDelete={() => trackEvent('Auth Editor', 'Delete')} onChange={pairs => onChange({ username: pairs.length ? pairs[0].name : '', password: pairs.length ? pairs[0].value : '', diff --git a/app/ui/components/editors/RequestHeadersEditor.js b/app/ui/components/editors/RequestHeadersEditor.js index 8c72dcbe6c..500afe0f3d 100644 --- a/app/ui/components/editors/RequestHeadersEditor.js +++ b/app/ui/components/editors/RequestHeadersEditor.js @@ -2,6 +2,7 @@ import React, {Component, PropTypes} from 'react'; import KeyValueEditor from '../base/KeyValueEditor'; import Editor from '../base/Editor'; +import {trackEvent} from '../../../analytics/index'; class RequestHeadersEditor extends Component { _handleBulkUpdate (headersString) { @@ -73,6 +74,9 @@ class RequestHeadersEditor extends Component { namePlaceholder="My-Header" valuePlaceholder="Value" pairs={headers} + onToggleDisable={pair => trackEvent('Headers Editor', 'Toggle', pair.disabled ? 'Disable' : 'Enable')} + onCreate={() => trackEvent('Headers Editor', 'Create')} + onDelete={() => trackEvent('Headers Editor', 'Delete')} onChange={onChange} />
    diff --git a/app/ui/components/editors/body/FileEditor.js b/app/ui/components/editors/body/FileEditor.js index ca10f794d4..d8f1e19bd8 100644 --- a/app/ui/components/editors/body/FileEditor.js +++ b/app/ui/components/editors/body/FileEditor.js @@ -2,7 +2,9 @@ import fs from 'fs'; import electron from 'electron'; import React, {PropTypes, Component} from 'react'; import FileInputButton from '../../base/FileInputButton'; +import PromptButton from '../../base/PromptButton'; import * as misc from '../../../../common/misc'; +import {trackEvent} from '../../../../analytics/index'; class FileEditor extends Component { render () { @@ -35,11 +37,25 @@ class FileEditor extends Component { No file selected )}

    - +
    + { + onChange(''); + trackEvent('File Editor', 'Reset') + }}> + Reset File + +    + { + onChange(path); + trackEvent('File Editor', 'Choose') + }} + /> +
    ) } diff --git a/app/ui/components/editors/body/FormEditor.js b/app/ui/components/editors/body/FormEditor.js index b77ab21ee9..bac8f50369 100644 --- a/app/ui/components/editors/body/FormEditor.js +++ b/app/ui/components/editors/body/FormEditor.js @@ -1,5 +1,6 @@ import React, {PropTypes, Component} from 'react'; import KeyValueEditor from '../../base/KeyValueEditor'; +import {trackEvent} from '../../../../analytics/index'; class FormEditor extends Component { render () { @@ -8,7 +9,16 @@ class FormEditor extends Component { return (
    - + trackEvent('Form Editor', `Toggle ${pair.type || 'text'}`, pair.disabled ? 'Disable' : 'Enable')} + onChangeType={type => trackEvent('Form Editor', 'Change Type', type)} + onChooseFile={() => trackEvent('Form Editor', 'Choose File')} + onCreate={() => trackEvent('Form Editor', 'Create')} + onDelete={() => trackEvent('Form Editor', 'Delete')} + onChange={onChange} + pairs={parameters} + multipart={true} + />
    ) diff --git a/app/ui/components/editors/body/UrlEncodedEditor.js b/app/ui/components/editors/body/UrlEncodedEditor.js index 0e938c89bd..299ca1dc10 100644 --- a/app/ui/components/editors/body/UrlEncodedEditor.js +++ b/app/ui/components/editors/body/UrlEncodedEditor.js @@ -1,5 +1,6 @@ import React, {PropTypes, Component} from 'react'; import KeyValueEditor from '../../base/KeyValueEditor'; +import {trackEvent} from '../../../../analytics/index'; class UrlEncodedEditor extends Component { render () { @@ -8,7 +9,13 @@ class UrlEncodedEditor extends Component { return (
    - + trackEvent('Url Encoded Editor', 'Toggle', pair.disabled ? 'Disable' : 'Enable')} + onCreate={() => trackEvent('Url Encoded Editor', 'Create')} + onDelete={() => trackEvent('Url Encoded Editor', 'Delete')} + pairs={parameters} + />
    ) diff --git a/app/ui/components/modals/CookiesModal.js b/app/ui/components/modals/CookiesModal.js index 38ffa8c4ba..5319b1c10a 100644 --- a/app/ui/components/modals/CookiesModal.js +++ b/app/ui/components/modals/CookiesModal.js @@ -5,6 +5,7 @@ import ModalHeader from '../base/ModalHeader'; import ModalFooter from '../base/ModalFooter'; import CookiesEditor from '../editors/CookiesEditor'; import * as models from '../../../models'; +import {trackEvent} from '../../../analytics/index'; class CookiesModal extends Component { constructor (props) { @@ -34,6 +35,7 @@ class CookiesModal extends Component { ]; this._saveChanges(cookieJar); + trackEvent('Cookie', 'Update'); } _handleCookieAdd (cookie) { @@ -41,6 +43,7 @@ class CookiesModal extends Component { const {cookies} = cookieJar; cookieJar.cookies = [cookie, ...cookies]; this._saveChanges(cookieJar); + trackEvent('Cookie', 'Create'); } _handleCookieDelete (cookie) { @@ -51,10 +54,12 @@ class CookiesModal extends Component { cookieJar.cookies = cookies.filter(c => c !== cookie); this._saveChanges(cookieJar); + trackEvent('Cookie', 'Delete'); } _onFilterChange (filter) { this.setState({filter}); + trackEvent('Cookie Editor', 'Filter Change'); } _getFilteredSortedCookies () { @@ -81,6 +86,7 @@ class CookiesModal extends Component { await this._load(workspace); this.modal.show(); this.filterInput.focus(); + trackEvent('Cookie Editor', 'Show'); } toggle (workspace) { diff --git a/app/ui/components/modals/LoginModal.js b/app/ui/components/modals/LoginModal.js index 707c49c8e5..913181be8c 100644 --- a/app/ui/components/modals/LoginModal.js +++ b/app/ui/components/modals/LoginModal.js @@ -49,7 +49,7 @@ class LoginModal extends Component { this.modal.hide(); showModal(SignupModal); - trackEvent('Auth', 'Switch', 'To Signup'); + trackEvent('Login', 'Switch to Signup'); } show (options = {}) { @@ -113,7 +113,7 @@ class LoginModal extends Component {

    If you have any questions or concerns, send you email to {" "} - + support@insomnia.rest

    diff --git a/app/ui/components/modals/RequestSwitcherModal.js b/app/ui/components/modals/RequestSwitcherModal.js index 902d0306fd..1f7ebc6cee 100644 --- a/app/ui/components/modals/RequestSwitcherModal.js +++ b/app/ui/components/modals/RequestSwitcherModal.js @@ -207,7 +207,7 @@ class RequestSwitcherModal extends Component { this.modal = m} top={true} dontFocus={true} {...this.props}> -

    +

    tab or   ↑ ↓  to navigate @@ -215,8 +215,8 @@ class RequestSwitcherModal extends Component {  to select     esc to dismiss -

    -

    Quick Switch

    +
    +
    Quick Switch
    diff --git a/app/ui/components/modals/SettingsModal.js b/app/ui/components/modals/SettingsModal.js index 2ab80428b1..e20cfad0bd 100644 --- a/app/ui/components/modals/SettingsModal.js +++ b/app/ui/components/modals/SettingsModal.js @@ -74,25 +74,28 @@ class SettingsModal extends Component { this._handleTabSelect(i)} selectedIndex={currentTabIndex}> - + - + - + - + - + models.settings.update(settings, {[key]: value})} + updateSetting={(key, value) => { + models.settings.update(settings, {[key]: value}); + trackEvent('Setting', 'Change', key) + }} /> diff --git a/app/ui/components/modals/SignupModal.js b/app/ui/components/modals/SignupModal.js index 453ab3728c..cb97f3d9c4 100644 --- a/app/ui/components/modals/SignupModal.js +++ b/app/ui/components/modals/SignupModal.js @@ -54,7 +54,7 @@ class SignupModal extends Component { this.modal.hide(); showModal(LoginModal, {}); - trackEvent('Auth', 'Switch', 'To Login'); + trackEvent('Signup', 'Switch to Login'); } _checkPasswordsMatch () { diff --git a/app/ui/components/modals/WorkspaceEnvironmentsEditModal.js b/app/ui/components/modals/WorkspaceEnvironmentsEditModal.js index a12daaade7..b97940f615 100644 --- a/app/ui/components/modals/WorkspaceEnvironmentsEditModal.js +++ b/app/ui/components/modals/WorkspaceEnvironmentsEditModal.js @@ -10,6 +10,7 @@ import ModalBody from '../base/ModalBody'; import ModalHeader from '../base/ModalHeader'; import ModalFooter from '../base/ModalFooter'; import * as models from '../../../models' +import {trackEvent} from '../../../analytics/index'; class WorkspaceEnvironmentsEditModal extends Component { @@ -28,6 +29,7 @@ class WorkspaceEnvironmentsEditModal extends Component { show (workspace) { this.modal.show(); this._load(workspace); + trackEvent('Environment Editor', 'Show'); } toggle (workspace) { @@ -60,9 +62,10 @@ class WorkspaceEnvironmentsEditModal extends Component { const parentId = rootEnvironment._id; const environment = await models.environment.create({parentId}); this._load(workspace, environment); + trackEvent('Environment', 'Create'); } - _handleActivateEnvironment (environment) { + _handleShowEnvironment (environment) { // Don't allow switching if the current one has errors if (!this._envEditor.isValid()) { return; @@ -70,6 +73,7 @@ class WorkspaceEnvironmentsEditModal extends Component { const {workspace} = this.state; this._load(workspace, environment); + trackEvent('Environment Editor', 'Show Environment'); } async _handleDeleteEnvironment (environment) { @@ -84,6 +88,7 @@ class WorkspaceEnvironmentsEditModal extends Component { await models.environment.remove(environment); this._load(workspace, rootEnvironment); + trackEvent('Environment', 'Delete'); } async _handleChangeEnvironmentName (environment, name) { @@ -94,6 +99,8 @@ class WorkspaceEnvironmentsEditModal extends Component { const realEnvironment = await models.environment.getById(environment._id); await models.environment.update(realEnvironment, {name}); this._load(workspace); + + trackEvent('Environment', 'Rename'); } _didChange () { @@ -138,7 +145,7 @@ class WorkspaceEnvironmentsEditModal extends Component { Manage Environments (JSON Format)
    -
  • this._handleActivateEnvironment(rootEnvironment)} +
  • this._handleShowEnvironment(rootEnvironment)} className={classnames( 'env-modal__sidebar-root-item', {'env-modal__sidebar-item--active': activeEnvironment === rootEnvironment} @@ -161,7 +168,7 @@ class WorkspaceEnvironmentsEditModal extends Component { return (
  • @@ -61,11 +65,15 @@ class SidebarRequestRow extends Component { node = (
  • -
    diff --git a/app/ui/containers/App.js b/app/ui/containers/App.js index 0ae5b0256e..0ac1a37376 100644 --- a/app/ui/containers/App.js +++ b/app/ui/containers/App.js @@ -16,16 +16,14 @@ import RequestSwitcherModal from '../components/modals/RequestSwitcherModal'; import PromptModal from '../components/modals/PromptModal'; import ChangelogModal from '../components/modals/ChangelogModal'; import SettingsModal from '../components/modals/SettingsModal'; -import {MAX_PANE_WIDTH, MIN_PANE_WIDTH, DEFAULT_PANE_WIDTH, MAX_SIDEBAR_REMS, MIN_SIDEBAR_REMS, DEFAULT_SIDEBAR_WIDTH, getAppVersion} from '../../common/constants'; +import {MAX_PANE_WIDTH, MIN_PANE_WIDTH, DEFAULT_PANE_WIDTH, MAX_SIDEBAR_REMS, MIN_SIDEBAR_REMS, DEFAULT_SIDEBAR_WIDTH, getAppVersion, PREVIEW_MODE_SOURCE} from '../../common/constants'; import * as globalActions from '../redux/modules/global'; import * as workspaceMetaActions from '../redux/modules/workspaceMeta'; import * as requestMetaActions from '../redux/modules/requestMeta'; import * as requestGroupMetaActions from '../redux/modules/requestGroupMeta'; import * as db from '../../common/database'; import * as models from '../../models'; -import {importRaw} from '../../common/import'; import {trackEvent, trackLegacyEvent} from '../../analytics'; -import {PREVIEW_MODE_SOURCE} from '../../common/constants'; class App extends Component { @@ -56,6 +54,7 @@ class App extends Component { // Show Request Switcher 'mod+p': () => { toggleModal(RequestSwitcherModal); + trackEvent('HotKey', 'Quick Switcher'); }, // Request Send @@ -65,24 +64,28 @@ class App extends Component { activeRequest ? activeRequest._id : 'n/a', activeEnvironment ? activeEnvironment._id : 'n/a', ); + trackEvent('HotKey', 'Send'); }, // Edit Workspace Environments 'mod+e': () => { const {activeWorkspace} = this.props; toggleModal(WorkspaceEnvironmentsEditModal, activeWorkspace); + trackEvent('HotKey', 'Environments'); }, // Focus URL Bar 'mod+l': () => { const node = document.body.querySelector('.urlbar input'); node && node.focus(); + trackEvent('HotKey', 'Url'); }, // Edit Cookies 'mod+k': () => { const {activeWorkspace} = this.props; toggleModal(CookiesModal, activeWorkspace); + trackEvent('HotKey', 'Cookies'); }, // Request Create @@ -91,6 +94,7 @@ class App extends Component { const parentId = activeRequest ? activeRequest.parentId : activeWorkspace._id; this._requestCreate(parentId); + trackEvent('HotKey', 'Request Create'); }, // Request Duplicate @@ -102,7 +106,8 @@ class App extends Component { } const request = await models.request.duplicate(activeRequest); - handleSetActiveRequest(activeWorkspace._id, request._id) + handleSetActiveRequest(activeWorkspace._id, request._id); + trackEvent('HotKey', 'Request Duplicate'); } } } @@ -160,10 +165,12 @@ class App extends Component { } _startDragSidebar () { + trackEvent('Sidebar', 'Drag Start'); this.setState({draggingSidebar: true}) } _resetDragSidebar () { + trackEvent('Sidebar', 'Drag Reset'); // TODO: Remove setTimeout need be not triggering drag on double click setTimeout(() => { const {handleSetSidebarWidth, activeWorkspace} = this.props; @@ -172,10 +179,12 @@ class App extends Component { } _startDragPane () { + trackEvent('App Pane', 'Drag Start'); this.setState({draggingPane: true}) } _resetDragPane () { + trackEvent('App Pane', 'Reset'); // TODO: Remove setTimeout need be not triggering drag on double click setTimeout(() => { const {handleSetPaneWidth, activeWorkspace} = this.props; @@ -217,6 +226,7 @@ class App extends Component { _handleToggleSidebar () { const {activeWorkspace, sidebarHidden, handleSetSidebarHidden} = this.props; handleSetSidebarHidden(activeWorkspace._id, !sidebarHidden); + trackEvent('Sidebar', 'Toggle Visibility', !sidebarHidden ? 'Hide' : 'Show'); } _forceHardRefresh () { diff --git a/app/ui/css/components/keyvalueeditor.less b/app/ui/css/components/keyvalueeditor.less index cc799e8b91..f943ed89ad 100644 --- a/app/ui/css/components/keyvalueeditor.less +++ b/app/ui/css/components/keyvalueeditor.less @@ -12,7 +12,7 @@ .key-value-editor__row { display: grid; - grid-template-columns: minmax(0, 0.5fr) minmax(0, 0.5fr) auto auto; + grid-template-columns: minmax(0, 0.5fr) minmax(0, 0.5fr) auto auto auto; grid-template-rows: auto; &.key-value-editor__row--disabled input { @@ -32,7 +32,8 @@ } } - & > button { + & > button, + .dropdown > button { color: @hl; &:hover, @@ -40,7 +41,8 @@ background: transparent; } - &:hover { + &:hover, + &:focus { color: inherit; } } diff --git a/package.json b/package.json index cc469c9d50..b71f5123c7 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "json-loader": "^0.5.4", "less": "^2.7.1", "less-loader": "^2.2.3", - "nock": "^8.0.0", + "nock": "^9.0.2", "react-addons-perf": "^15.3.0", "react-addons-test-utils": "^15.1.0", "react-hot-loader": "^1.3.0",