Add multi-part form support (#49)

* Add multi-part form support

* tests for form and multipart

* Better Analytics Tracking
This commit is contained in:
Gregory Schier
2016-11-23 11:33:24 -08:00
committed by GitHub
parent d620e73ef8
commit 6a136bd76a
43 changed files with 513 additions and 179 deletions

View File

@@ -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('');
});
});

View File

@@ -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'
);

View File

@@ -0,0 +1 @@
Hello World!

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

@@ -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 {
</header>
<Tabs className="pane__body">
<TabList>
<Tab>
<Tab onClick={() => trackEvent('Request Pane', 'View', 'Body')}>
<button>
{getContentTypeName(request.body.mimeType)}
{getContentTypeName(request.body.mimeType || '')}
</button>
<ContentTypeDropdown updateRequestMimeType={updateRequestMimeType}/>
</Tab>
<Tab>
<Tab onClick={() => trackEvent('Request Pane', 'View', 'Auth')}>
<button>
Auth {hasAuth ? <i className="fa fa-lock txt-sm"></i> : null}
</button>
</Tab>
<Tab>
<Tab onClick={() => trackEvent('Request Pane', 'View', 'Query')}>
<button>
Query {numParameters ? <span className="txt-sm">({numParameters})</span> : null}
</button>
</Tab>
<Tab>
<Tab onClick={() => trackEvent('Request Pane', 'View', 'Headers')}>
<button>
Headers {numHeaders ? <span className="txt-sm">({numHeaders})</span> : null}
</button>
@@ -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 {
<div className="pad-right text-right">
<button
className="margin-top-sm btn btn--outlined btn--super-compact"
onClick={() => updateSettingsUseBulkHeaderEditor(!useBulkHeaderEditor)}>
onClick={() => {
updateSettingsUseBulkHeaderEditor(!useBulkHeaderEditor);
trackEvent('Headers', 'Toggle Bulk', !useBulkHeaderEditor ? 'On' : 'Off');
}}>
{useBulkHeaderEditor ? 'Regular Edit' : 'Bulk Edit'}
</button>
</div>

View File

@@ -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 {
)}
<Tabs className="pane__body">
<TabList>
<Tab>
<Tab onClick={() => trackEvent('Response Pane', 'View', 'Response')}>
<button>
{getPreviewModeName(previewMode)}
</button>
@@ -207,7 +207,7 @@ class ResponsePane extends Component {
updatePreviewMode={handleSetPreviewMode}
/>
</Tab>
<Tab>
<Tab onClick={() => trackEvent('Response Pane', 'View', 'Cookies')}>
<button>
Cookies {cookieHeaders.length ? (
<span className="txt-sm">
@@ -216,7 +216,7 @@ class ResponsePane extends Component {
) : null}
</button>
</Tab>
<Tab>
<Tab onClick={() => trackEvent('Response Pane', 'View', 'Headers')}>
<button>
Headers {response.headers.length ? (
<span className="txt-sm">

View File

@@ -17,11 +17,14 @@ class Toast extends Component {
visible: false,
};
this._notifications = [{
key: KEY_PLUS_IS_HERE,
message: 'Cloud sync is here!',
cta: 'Show'
}];
this._notifications = [
// TODO: Fetch these from remote server
// {
// key: KEY_PLUS_IS_HERE,
// message: 'Cloud sync is here!',
// cta: 'Show'
// }
];
}
_loadSeen () {
@@ -70,6 +73,11 @@ class Toast extends Component {
if (notification) {
this.setState({visible: false});
}
// Give time for toast to fade out, then remove it
setTimeout(() => {
this.setState({notification: null});
}, 1000);
}
componentDidMount () {
@@ -80,6 +88,10 @@ class Toast extends Component {
render () {
const {notification, visible} = this.state;
if (!notification) {
return null;
}
return (
<div className={classnames('toast', {'toast--show': visible})}>
<div className="toast__message">

View File

@@ -16,6 +16,10 @@ class Editable extends Component {
this._input && this._input.focus();
this._input && this._input.select();
});
if (this.props.onEditStart) {
this.props.onEditStart();
}
}
async _handleEditEnd () {
@@ -47,7 +51,7 @@ class Editable extends Component {
}
render () {
const {value, singleClick, ...extra} = this.props;
const {value, singleClick, onEditStart, ...extra} = this.props;
const {editing} = this.state;
if (editing) {
@@ -79,7 +83,8 @@ Editable.propTypes = {
value: PropTypes.string.isRequired,
// Optional
singleClick: PropTypes.bool
singleClick: PropTypes.bool,
onEditStart: PropTypes.func,
};
export default Editable;

View File

@@ -43,6 +43,7 @@ import {showModal} from '../modals/index';
import AlertModal from '../modals/AlertModal';
import Link from '../base/Link';
import * as misc from '../../../common/misc';
import {trackEvent} from '../../../analytics/index';
const BASE_CODEMIRROR_OPTIONS = {
@@ -158,6 +159,7 @@ class Editor extends Component {
}
_handleBeautify () {
trackEvent('Request', 'Beautify');
this._prettify(this.codeMirror.getValue());
}

View File

@@ -1,13 +1,8 @@
import React, {Component, PropTypes} from 'react';
import {basename as pathBasename} from 'path';
import {remote} from 'electron';
import {Dropdown, DropdownButton, DropdownItem} from '../base/dropdown';
import PromptButton from '../base/PromptButton';
class FileInputButton extends Component {
_handleUnsetFile () {
this.props.onChange('');
}
_handleChooseFile () {
const options = {
title: 'Import File',
@@ -26,30 +21,23 @@ class FileInputButton extends Component {
}
render () {
const {className} = this.props;
const {showFileName, path, ...extraProps} = this.props;
const fileName = pathBasename(path);
return (
<Dropdown>
<DropdownButton className={className}>
Choose File <i className="fa fa-caret-down"></i>
</DropdownButton>
<DropdownItem onClick={e => this._handleChooseFile()}>
<i className="fa fa-file"></i>
Choose File
</DropdownItem>
<DropdownItem buttonClass={PromptButton}
addIcon={true}
onClick={e => this._handleUnsetFile()}>
<i className="fa fa-close"></i>
Clear File
</DropdownItem>
</Dropdown>
<button onClick={e => this._handleChooseFile()} {...extraProps}>
{showFileName && fileName ? `${fileName}`: 'Choose File'}
</button>
)
}
}
FileInputButton.propTypes = {
// Required
onChange: PropTypes.func.isRequired,
path: PropTypes.string.isRequired,
// Optional
showFileName: PropTypes.bool,
};
export default FileInputButton;

View File

@@ -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 (
<ul className={classnames('key-value-editor', 'wide', className)}>
@@ -200,35 +215,62 @@ class KeyValueEditor extends Component {
/>
</div>
</div>
<div>
<div className={classnames(
'form-control form-control--wide', {
'form-control--underlined': valueInputType !== 'file',
'form-control--padded': valueInputType === 'file',
}
)}>
<input
type={valueInputType || 'text'}
placeholder={this.props.valuePlaceholder || 'Value'}
ref={n => 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)}
/>
<div className="wide">
<div className="form-control form-control--wide form-control--underlined">
{pair.type === 'file' ? (
<FileInputButton
showFileName={true}
className="btn btn--super-compact btn--outlined wide ellipsis txt-sm"
onChange={fileName => {
this._updatePair(i, {fileName});
this.props.onChooseFile && this.props.onChooseFile();
}}
path={pair.fileName || ''}
/>
) : (
<input
type={valueInputType || 'text'}
placeholder={this.props.valuePlaceholder || 'Value'}
ref={n => 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)}
/>
)}
</div>
</div>
<button tabIndex="-1"
onClick={e => this._togglePair(i)}
title={pair.disabled ? 'Enable item' : 'Disable item'}>
{multipart ? (
<Dropdown right={true}>
<DropdownButton className="tall">
<i className="fa fa-caret-down"></i>
</DropdownButton>
<DropdownItem onClick={e => {
this._updatePair(i, {type: 'text', value: '', fileName: ''});
this.props.onChangeType && this.props.onChangeType('text');
}}>
Text
</DropdownItem>
<DropdownItem onClick={e => {
this._updatePair(i, {type: 'file', value: '', fileName: ''});
this.props.onChangeType && this.props.onChangeType('file');
}}>
File
</DropdownItem>
</Dropdown>
) : null}
<button
onClick={e => this._togglePair(i)}
title={pair.disabled ? 'Enable item' : 'Disable item'}>
{pair.disabled ?
<i className="fa fa-square-o"></i> :
<i className="fa fa-check-square-o"></i>
@@ -261,9 +303,17 @@ class KeyValueEditor extends Component {
this._addPair()
}}/>
</div>
{multipart ? (
<button disabled={true} tabIndex="-1">
<i className="fa fa-blank"></i>
</button>
) : null}
<button disabled={true} tabIndex="-1">
<i className="fa fa-blank"></i>
</button>
<button disabled={true} tabIndex="-1">
<i className="fa fa-blank"></i>
</button>
@@ -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;

View File

@@ -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 ? (
<button onClick={() => shell.openExternal(href)} {...other}>
<button onClick={this._boundHandleClick} {...other}>
{children}
</button>
) :(
<a href={href} onClick={e => {e.preventDefault(); shell.openExternal(href)}} {...other}>
<a href={href} onClick={this._boundHandleClick} {...other}>
{children}
</a>
)

View File

@@ -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}) => {
<i className="fa fa-caret-down"></i>
</DropdownButton>
{Object.keys(contentTypesMap).map(mimeType => (
<DropdownItem key={mimeType} onClick={e => updateRequestMimeType(mimeType)}>
<DropdownItem key={mimeType} onClick={e => {
updateRequestMimeType(mimeType);
trackEvent('Request', 'Content-Type Change', contentTypesMap[mimeType]);
}}>
{contentTypesMap[mimeType]}
</DropdownItem>
))}

View File

@@ -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 = ({
<DropdownDivider name="Switch Environment"/>
{subEnvironments.map(environment => (
<DropdownItem key={environment._id}
onClick={e => handleChangeEnvironment(environment._id)}>
onClick={e => {
handleChangeEnvironment(environment._id);
trackEvent('Environment', 'Activate');
}}>
<i className="fa fa-random"></i> Use <strong>{environment.name}</strong>
</DropdownItem>
))}
<DropdownItem onClick={() => baseEnvironment && handleChangeEnvironment(null)}>
<DropdownItem onClick={() => {
baseEnvironment && handleChangeEnvironment(null);
trackEvent('Environment', 'Deactivate');
}}>
<i className="fa fa-empty"></i> No Environment
</DropdownItem>
<DropdownDivider name="General"/>

View File

@@ -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}) => (
<Dropdown>
@@ -8,7 +9,10 @@ const PreviewModeDropdown = ({updatePreviewMode, download}) => (
<i className="fa fa-caret-down"></i>
</DropdownButton>
{PREVIEW_MODES.map(previewMode => (
<DropdownItem key={previewMode} onClick={() => updatePreviewMode(previewMode)}>
<DropdownItem key={previewMode} onClick={() => {
updatePreviewMode(previewMode);
trackEvent('Response', 'Preview Mode Change', previewMode);
}}>
{getPreviewModeName(previewMode)}
</DropdownItem>
))}

View File

@@ -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 {
<DropdownButton>
<i className="fa fa-caret-down"></i>
</DropdownButton>
<DropdownItem onClick={e => models.request.duplicate(request)}>
<DropdownItem onClick={e => {
models.request.duplicate(request);
trackEvent('Request', 'Duplicate', 'Action');
}}>
<i className="fa fa-copy"></i> Duplicate
<DropdownHint char="D"></DropdownHint>
</DropdownItem>
<DropdownItem onClick={e => this._promptUpdateName()}>
<DropdownItem onClick={e => {
this._promptUpdateName();
trackEvent('Request', 'Rename', 'Action');
}}>
<i className="fa fa-edit"></i> Rename
</DropdownItem>
<DropdownItem onClick={e => showModal(GenerateCodeModal, request)}>
<DropdownItem onClick={e => {
showModal(GenerateCodeModal, request);
trackEvent('Request', 'Action', 'Generate Code');
}}>
<i className="fa fa-code"></i> Generate Code
</DropdownItem>
<DropdownItem buttonClass={PromptButton}
onClick={e => models.request.remove(request)}
onClick={e => {
models.request.remove(request);
trackEvent('Request', 'Delete', 'Action');
}}
addIcon={true}>
<i className="fa fa-trash-o"></i> Delete
</DropdownItem>

View File

@@ -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)}>
<i className="fa fa-code"></i> Environment
</DropdownItem>
<DropdownItem buttonClass={PromptButton}
onClick={e => models.requestGroup.remove(requestGroup)}
addIcon={true}>
<DropdownItem buttonClass={PromptButton} addIcon={true} onClick={e => {
models.requestGroup.remove(requestGroup);
trackEvent('Folder', 'Delete', 'Folder Action');
}}>
<i className="fa fa-trash-o"></i> Delete
</DropdownItem>
</Dropdown>

View File

@@ -120,10 +120,9 @@ class SyncDropdown extends Component {
if (!loggedIn) {
return (
<div className={className}>
<Dropdown wide={true}
className="wide tall"
onClick={e => trackEvent('Sync', 'Show Menu', 'Guest')}>
<DropdownButton className="btn btn--compact wide">
<Dropdown wide={true} className="wide tall">
<DropdownButton className="btn btn--compact wide"
onClick={e => trackEvent('Sync', 'Show Menu', 'Guest')}>
Sync Settings
</DropdownButton>
<DropdownDivider name="Insomnia Cloud Sync"/>
@@ -167,10 +166,9 @@ class SyncDropdown extends Component {
return (
<div className={className}>
<Dropdown wide={true}
className="wide tall"
onClick={e => trackEvent('Sync', 'Show Menu', 'Authenticated')}>
<DropdownButton className="btn btn--compact wide">
<Dropdown wide={true} className="wide tall">
<DropdownButton className="btn btn--compact wide"
onClick={e => trackEvent('Sync', 'Show Menu', 'Authenticated')}>
{description}
</DropdownButton>
<DropdownDivider name={`Workspace Synced ${syncPercent}%`}/>

View File

@@ -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 {
</h1>
</DropdownButton>
<DropdownDivider name="Current Workspace"/>
<DropdownItem onClick={e => this._promptUpdateName()}>
<DropdownItem onClick={e => {
this._promptUpdateName();
trackEvent('Workspace', 'Rename');
}}>
<i className="fa fa-pencil-square-o"></i> Rename
{" "}
<strong>{activeWorkspace.name}</strong>
</DropdownItem>
<DropdownItem buttonClass={PromptButton}
onClick={e => this._workspaceRemove()}
onClick={e => {
this._workspaceRemove();
trackEvent('Workspace', 'Delete');
}}
addIcon={true}>
<i className="fa fa-trash-o"></i> Delete
{" "}
@@ -88,13 +95,19 @@ class WorkspaceDropdown extends Component {
<DropdownDivider name="Workspaces"/>
{workspaces.map(w => w._id === activeWorkspace._id ? null : (
<DropdownItem key={w._id} onClick={() => handleSetActiveWorkspace(w._id)}>
<DropdownItem key={w._id} onClick={() => {
handleSetActiveWorkspace(w._id);
trackEvent('Workspace', 'Switch');
}}>
<i className="fa fa-random"></i> Switch to
{" "}
<strong>{w.name}</strong>
</DropdownItem>
))}
<DropdownItem onClick={e => this._workspaceCreate()}>
<DropdownItem onClick={e => {
this._workspaceCreate();
trackEvent('Workspace', 'Create');
}}>
<i className="fa fa-blank"></i> New Workspace
</DropdownItem>

View File

@@ -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 : '',

View File

@@ -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}
/>
</div>

View File

@@ -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 {
<code className="super-faint">No file selected</code>
)}
</p>
<FileInputButton
path={path}
className="btn btn--super-compact btn--outlined"
onChange={onChange}
/>
<div>
<PromptButton className="btn btn--super-compact"
disabled={!path}
onClick={e => {
onChange('');
trackEvent('File Editor', 'Reset')
}}>
Reset File
</PromptButton>
&nbsp;&nbsp;
<FileInputButton
path={path}
className="btn btn--super-compact btn--outlined"
onChange={path => {
onChange(path);
trackEvent('File Editor', 'Choose')
}}
/>
</div>
</div>
)
}

View File

@@ -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 (
<div className="scrollable-container tall wide">
<div className="scrollable">
<KeyValueEditor onChange={onChange} pairs={parameters} valueInputType="file"/>
<KeyValueEditor
onToggleDisable={pair => 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}
/>
</div>
</div>
)

View File

@@ -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 (
<div className="scrollable-container tall wide">
<div className="scrollable">
<KeyValueEditor onChange={onChange} pairs={parameters}/>
<KeyValueEditor
onChange={onChange}
onToggleDisable={pair => trackEvent('Url Encoded Editor', 'Toggle', pair.disabled ? 'Disable' : 'Enable')}
onCreate={() => trackEvent('Url Encoded Editor', 'Create')}
onDelete={() => trackEvent('Url Encoded Editor', 'Delete')}
pairs={parameters}
/>
</div>
</div>
)

View File

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

View File

@@ -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 {
<p>
If you have any questions or concerns, send you email to
{" "}
<Link href="mailto:support@insomnia.rest">
<Link href="https://insomnia.rest/documentation/support-and-feedback/">
support@insomnia.rest
</Link>
</p>

View File

@@ -207,7 +207,7 @@ class RequestSwitcherModal extends Component {
<Modal ref={m => this.modal = m} top={true}
dontFocus={true} {...this.props}>
<ModalHeader hideCloseButton={true}>
<p className="pull-right txt-md">
<div className="pull-right txt-md">
<span className="monospace">tab</span> or
&nbsp;
<span className="monospace"> </span> &nbsp;to navigate
@@ -215,8 +215,8 @@ class RequestSwitcherModal extends Component {
<span className="monospace"></span> &nbsp;to select
&nbsp;&nbsp;&nbsp;
<span className="monospace">esc</span> to dismiss
</p>
<p>Quick Switch</p>
</div>
<div>Quick Switch</div>
</ModalHeader>
<ModalBody className="pad request-switcher">
<div className="form-control form-control--outlined no-margin">

View File

@@ -74,25 +74,28 @@ class SettingsModal extends Component {
<Tabs onSelect={i => this._handleTabSelect(i)} selectedIndex={currentTabIndex}>
<TabList>
<Tab selected={this._currentTabIndex === 0}>
<button>General</button>
<button onClick={e => trackEvent('Setting', 'Tab General')}>General</button>
</Tab>
<Tab selected={this._currentTabIndex === 1}>
<button>Import/Export</button>
<button onClick={e => trackEvent('Setting', 'Tab Import/Export')}>Import/Export</button>
</Tab>
<Tab selected={this._currentTabIndex === 3}>
<button>Shortcuts</button>
<button onClick={e => trackEvent('Setting', 'Tab Shortcuts')}>Shortcuts</button>
</Tab>
<Tab selected={this._currentTabIndex === 2}>
<button>Insomnia Plus</button>
<button onClick={e => trackEvent('Setting', 'Tab Plus')}>Insomnia Plus</button>
</Tab>
<Tab selected={this._currentTabIndex === 4}>
<button>About</button>
<button onClick={e => trackEvent('Setting', 'Tab About')}>About</button>
</Tab>
</TabList>
<TabPanel className="pad scrollable">
<SettingsGeneral
settings={settings}
updateSetting={(key, value) => models.settings.update(settings, {[key]: value})}
updateSetting={(key, value) => {
models.settings.update(settings, {[key]: value});
trackEvent('Setting', 'Change', key)
}}
/>
</TabPanel>
<TabPanel className="pad scrollable">

View File

@@ -54,7 +54,7 @@ class SignupModal extends Component {
this.modal.hide();
showModal(LoginModal, {});
trackEvent('Auth', 'Switch', 'To Login');
trackEvent('Signup', 'Switch to Login');
}
_checkPasswordsMatch () {

View File

@@ -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 {
<ModalHeader>Manage Environments (JSON Format)</ModalHeader>
<ModalBody noScroll={true} className="env-modal">
<div className="env-modal__sidebar">
<li onClick={() => this._handleActivateEnvironment(rootEnvironment)}
<li onClick={() => 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 (
<li key={environment._id} className={classes}>
<button
onClick={() => this._handleActivateEnvironment(environment)}>
onClick={() => this._handleShowEnvironment(environment)}>
<Editable
onSubmit={name => this._handleChangeEnvironmentName(environment, name)}
value={environment.name}

View File

@@ -2,7 +2,6 @@ import React, {PropTypes} from 'react';
const SettingsGeneral = ({settings, updateSetting}) => (
<div>
<div>
<input
id="setting-follow-redirects"

View File

@@ -1,6 +1,7 @@
import React, {PropTypes} from 'react';
import Link from '../base/Link';
import PromptButton from '../base/PromptButton';
import {trackEvent} from '../../../analytics/index';
const SettingsSync = ({
loggedIn,
@@ -46,7 +47,7 @@ const SettingsSync = ({
</p>
] : [
<p key="0">
<Link href="https://insomnia.rest/plus">Insomnia Plus</Link> helps you <i>rest</i> easy by
<Link href="https://insomnia.rest/plus/">Insomnia Plus</Link> helps you <i>rest</i> easy by
keeping your workspaces securely backed up and synced across all of your devices.
</p>,
<p key="1">
@@ -59,6 +60,7 @@ const SettingsSync = ({
<button className="btn txt-lg btn--outlined"
onClick={() => {
handleExit();
trackEvent('Settings Sync', 'Click Upgrade');
handleShowSignup()
}}>
Upgrade to Plus

View File

@@ -1,6 +1,7 @@
import React, {Component, PropTypes} from 'react';
import {Dropdown, DropdownHint, DropdownButton, DropdownItem} from '../base/dropdown';
import {DEBOUNCE_MILLIS} from '../../../common/constants';
import {trackEvent} from '../../../analytics/index';
class SidebarFilter extends Component {
@@ -8,6 +9,7 @@ class SidebarFilter extends Component {
clearTimeout(this._triggerTimeout);
this._triggerTimeout = setTimeout(() => {
this.props.onChange(value);
trackEvent('Sidebar', 'Filter');
}, DEBOUNCE_MILLIS);
}
@@ -28,11 +30,17 @@ class SidebarFilter extends Component {
<DropdownButton className="btn btn--compact">
<i className="fa fa-plus-circle"></i>
</DropdownButton>
<DropdownItem onClick={e => requestCreate()}>
<DropdownItem onClick={e => {
requestCreate();
trackEvent('Request', 'Create', 'Sidebar Filter');
}}>
<i className="fa fa-plus-circle"></i> New Request
<DropdownHint char="N"></DropdownHint>
</DropdownItem>
<DropdownItem onClick={e => requestGroupCreate()}>
<DropdownItem onClick={e => {
requestGroupCreate();
trackEvent('Folder', 'Create', 'Sidebar Filter');
}}>
<i className="fa fa-folder"></i> New Folder
</DropdownItem>
</Dropdown>

View File

@@ -5,6 +5,7 @@ import classnames from 'classnames';
import RequestGroupActionsDropdown from '../dropdowns/RequestGroupActionsDropdown';
import SidebarRequestRow from './SidebarRequestRow';
import {trackEvent} from '../../../analytics/index';
class SidebarRequestGroupRow extends Component {
constructor (props) {
@@ -60,7 +61,10 @@ class SidebarRequestGroupRow extends Component {
<li key={requestGroup._id} className={classes}>
<div
className={classnames('sidebar__item sidebar__item--big', {'sidebar__item--active': isActive})}>
<button onClick={e => handleSetRequestGroupCollapsed(requestGroup._id, !isCollapsed)}>
<button onClick={e => {
handleSetRequestGroupCollapsed(requestGroup._id, !isCollapsed);
trackEvent('Folder', 'Toggle Visible', !isCollapsed ? 'Close' : 'Open')
}}>
<div className="sidebar__clickable">
<i className={'sidebar__item__icon fa ' + folderIconClass}></i>
<span>{requestGroup.name}</span>
@@ -122,6 +126,7 @@ SidebarRequestGroupRow.propTypes = {
*/
const dragSource = {
beginDrag(props) {
trackEvent('Folder', 'Drag', 'Begin');
return {
requestGroup: props.requestGroup
};

View File

@@ -6,6 +6,7 @@ import RequestActionsDropdown from '../dropdowns/RequestActionsDropdown';
import Editable from '../base/Editable';
import MethodTag from '../tags/MethodTag';
import * as models from '../../../models';
import {trackEvent} from '../../../analytics/index';
class SidebarRequestRow extends Component {
@@ -51,7 +52,10 @@ class SidebarRequestRow extends Component {
<li className={classes}>
<div className="sidebar__item" tabIndex={0}>
<button className="sidebar__clickable"
onClick={() => requestCreate()}>
onClick={() => {
requestCreate();
trackEvent('Request', 'Create', 'Empty Folder');
}}>
<em>click to add first request...</em>
</button>
</div>
@@ -61,11 +65,15 @@ class SidebarRequestRow extends Component {
node = (
<li className={classes}>
<div className={classnames('sidebar__item', {'sidebar__item--active': isActive})}>
<button className="wide" onClick={e => handleActivateRequest(request)}>
<button className="wide" onClick={e => {
handleActivateRequest(request);
trackEvent('Request', 'Activate', 'Sidebar');
}}>
<div className="sidebar__clickable">
<MethodTag method={request.method}/>
<Editable
value={request.name}
onEditStart={() => trackEvent('Request', 'Rename', 'In Place')}
onSubmit={name => models.request.update(request, {name})}
/>
</div>
@@ -113,6 +121,7 @@ SidebarRequestRow.propTypes = {
*/
const dragSource = {
beginDrag(props) {
trackEvent('Request', 'Drag', 'Begin');
return {
request: props.request
};

View File

@@ -25,8 +25,9 @@ class ResponseError extends Component {
)
} else {
msg = (
<Link button={true} className="btn btn--super-compact btn--outlined"
href="http://docs.insomnia.rest">
<Link button={true}
className="btn btn--super-compact btn--outlined"
href="https://insomnia.rest/documentation/">
Documentation
</Link>
)
@@ -45,7 +46,7 @@ class ResponseError extends Component {
{msg}
&nbsp;&nbsp;
<Link button={true} className="btn btn--super-compact btn--outlined margin-top-sm"
href="mailto:support@insomnia.rest">
href="https://insomnia.rest/documentation/support-and-feedback/">
Contact Support
</Link>
</div>

View File

@@ -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 () {

View File

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

View File

@@ -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",