diff --git a/app/common/__tests__/database.test.js b/app/common/__tests__/database.test.js index 72afa0b0d2..8b06b9441f 100644 --- a/app/common/__tests__/database.test.js +++ b/app/common/__tests__/database.test.js @@ -96,7 +96,7 @@ describe('requestCreate()', () => { }; const r = await models.request.create(patch); - expect(Object.keys(r).length).toBe(13); + expect(Object.keys(r).length).toBe(14); expect(r._id).toMatch(/^req_[a-zA-Z0-9]{32}$/); expect(r.created).toBeGreaterThanOrEqual(now); @@ -105,7 +105,7 @@ describe('requestCreate()', () => { expect(r.name).toBe('My Request'); expect(r.url).toBe(''); expect(r.method).toBe('GET'); - expect(r.body).toBe(''); + expect(r.body).toEqual({}); expect(r.parameters).toEqual([]); expect(r.headers).toEqual([]); expect(r.authentication).toEqual({}); diff --git a/app/common/__tests__/har.test.js b/app/common/__tests__/har.test.js index 684789102f..8be6efec21 100644 --- a/app/common/__tests__/har.test.js +++ b/app/common/__tests__/har.test.js @@ -29,7 +29,9 @@ describe('exportHarWithRequest()', () => { headers: [{name: 'Content-Type', value: 'application/json'}], parameters: [{name: 'foo bar', value: 'hello&world'}], method: 'POST', - body: 'foo bar', + body: { + text: 'foo bar' + }, url: 'http://google.com', authentication: { username: 'user', @@ -62,7 +64,9 @@ describe('exportHarWithRequest()', () => { headersSize: -1, httpVersion: 'HTTP/1.1', method: 'POST', - postData: {text: 'foo bar'}, + postData: { + text: 'foo bar' + }, queryString: [{name: 'foo bar', value: 'hello&world'}], url: 'http://google.com/' }); diff --git a/app/common/__tests__/misc.test.js b/app/common/__tests__/misc.test.js index 2c94562fd2..a6fd2fd1b1 100644 --- a/app/common/__tests__/misc.test.js +++ b/app/common/__tests__/misc.test.js @@ -208,23 +208,3 @@ describe('debounce()', () => { expect(resultList).toEqual([['foo', 'bar3']]); }) }); - -describe('copyObjectAndUpdate()', () => { - it('handles assignment', () => { - const actual = misc.copyObjectAndUpdate( - {foo: 'hi', bar: {baz: 'qux'}}, - {foo: 'hi again', a: 'b'}, - {a: 'c', foo: 'final foo'}, - ); - expect(actual).toEqual({ - foo: 'final foo', bar: {baz: 'qux'} - }); - }); - - it('makes a copy of the object', () => { - const obj = {foo: 'bar'}; - const newObj = misc.copyObjectAndUpdate(obj, {hi: 'there'}); - expect(newObj).toBe(newObj); - expect(newObj).not.toBe(obj); - }); -}); diff --git a/app/common/__tests__/network.test.js b/app/common/__tests__/network.test.js index 83205a8766..7dd01eb34f 100644 --- a/app/common/__tests__/network.test.js +++ b/app/common/__tests__/network.test.js @@ -3,6 +3,7 @@ import * as db from '../database'; import nock from 'nock'; import {getRenderedRequest} from '../render'; import * as models from '../../models'; +import {CONTENT_TYPE_FORM_URLENCODED} from '../constants'; describe('buildRequestConfig()', () => { beforeEach(() => db.init(models.types(), {inMemoryOnly: true}, true)); @@ -23,7 +24,7 @@ describe('buildRequestConfig()', () => { forever: true, gzip: true, headers: {host: ''}, - maxRedirects: 20, + maxRedirects: 50, method: 'GET', proxy: null, rejectUnauthorized: true, @@ -37,12 +38,28 @@ describe('buildRequestConfig()', () => { const workspace = await models.workspace.create(); const request = Object.assign(models.request.init(), { parentId: workspace._id, - headers: [{host: '', name: 'Content-Type', value: 'application/json'}], - parameters: [{name: 'foo bar', value: 'hello&world'}], + headers: [ + {name: 'Content-Type', value: 'application/json', disabled: false}, + {name: 'hi', value: 'there', disabled: true}, + {name: 'x-hello', value: 'world'}, + ], + parameters: [ + {name: 'foo bar', value: 'hello&world', disabled: false}, + {name: 'b', value: 'bb&world', disabled: true}, + {name: 'a', value: 'aa'}, + ], method: 'POST', - body: 'foo=bar', + body: { + mimeType: CONTENT_TYPE_FORM_URLENCODED, + params: [ + {name: 'X', value: 'XX', disabled: false}, + {name: 'Y', value: 'YY', disabled: true}, + {name: 'Z', value: 'ZZ'}, + ] + }, url: 'http://foo.com:3332/★/hi@gmail.com/foo%20bar?bar=baz', authentication: { + disabled: false, username: 'user', password: 'pass' } @@ -51,7 +68,7 @@ describe('buildRequestConfig()', () => { const renderedRequest = await getRenderedRequest(request); const config = networkUtils._buildRequestConfig(renderedRequest); expect(config).toEqual({ - body: 'foo=bar', + body: 'X=XX&Z=ZZ', encoding: null, followAllRedirects: true, followRedirect: true, @@ -60,15 +77,16 @@ describe('buildRequestConfig()', () => { headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic dXNlcjpwYXNz', - 'host': 'foo.com:3332' + 'host': 'foo.com:3332', + 'x-hello': 'world' }, - maxRedirects: 20, + maxRedirects: 50, method: 'POST', proxy: null, rejectUnauthorized: true, time: true, timeout: 0, - url: 'http://foo.com:3332/%E2%98%85/hi@gmail.com/foo%20bar?bar=baz&foo%20bar=hello%26world' + url: 'http://foo.com:3332/%E2%98%85/hi@gmail.com/foo%20bar?bar=baz&foo%20bar=hello%26world&a=aa' }) }) }); @@ -121,7 +139,10 @@ describe('actuallySend()', () => { headers: [{name: 'Content-Type', value: 'application/json'}], parameters: [{name: 'foo bar', value: 'hello&world'}], method: 'POST', - body: 'foo=bar', + body: { + mimeType: CONTENT_TYPE_FORM_URLENCODED, + text: 'foo=bar' + }, url: 'http://localhost', authentication: { username: 'user', diff --git a/app/common/constants.js b/app/common/constants.js index 2b44cdd147..59d5a8aee7 100644 --- a/app/common/constants.js +++ b/app/common/constants.js @@ -115,17 +115,22 @@ export const CONTENT_TYPE_JSON = 'application/json'; export const CONTENT_TYPE_XML = 'application/xml'; export const CONTENT_TYPE_TEXT = 'text/plain'; export const CONTENT_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded'; +export const CONTENT_TYPE_FORM_DATA = 'multipart/form-data'; export const CONTENT_TYPE_OTHER = ''; -const contentTypeMap = { +export const contentTypesMap = { [CONTENT_TYPE_JSON]: 'JSON', [CONTENT_TYPE_XML]: 'XML', - [CONTENT_TYPE_FORM_URLENCODED]: 'Form Encoded', + // [CONTENT_TYPE_FORM_DATA]: 'Form Data', + [CONTENT_TYPE_FORM_URLENCODED]: 'Url Encoded', [CONTENT_TYPE_TEXT]: 'Plain Text', - [CONTENT_TYPE_OTHER]: 'Other' + [CONTENT_TYPE_OTHER]: 'Other', }; -export const CONTENT_TYPES = Object.keys(contentTypeMap); +export const BODY_TYPE_RAW = 'raw'; +export const BODY_TYPE_FILE = 'file'; +export const BODY_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded'; +export const BODY_TYPE_FORM = 'multipart/form-data'; /** * Get the friendly name for a given content type @@ -134,10 +139,14 @@ export const CONTENT_TYPES = Object.keys(contentTypeMap); * @returns {*|string} */ export function getContentTypeName (contentType) { - return contentTypeMap[contentType] || contentTypeMap[CONTENT_TYPE_OTHER]; + return contentTypesMap[contentType] || contentTypesMap[CONTENT_TYPE_OTHER]; } export function getContentTypeFromHeaders (headers) { + if (!Array.isArray(headers)) { + return null; + } + const header = headers.find(({name}) => name.toLowerCase() === 'content-type'); return header ? header.value : null; } diff --git a/app/common/database.js b/app/common/database.js index 1a24d55be2..e6e34a8a46 100644 --- a/app/common/database.js +++ b/app/common/database.js @@ -4,7 +4,6 @@ import fsPath from 'path'; import {DB_PERSIST_INTERVAL} from './constants'; import {generateId} from './misc'; import {getModel, initModel} from '../models'; -import * as misc from './misc'; export const CHANGE_INSERT = 'insert'; export const CHANGE_UPDATE = 'update'; @@ -128,7 +127,7 @@ export function find (type, query = {}) { } const docs = rawDocs.map(rawDoc => { - return Object.assign(initModel(type), rawDoc); + return initModel(type, rawDoc); }); resolve(docs); @@ -152,8 +151,7 @@ export function getWhere (type, query) { return resolve(null); } - const doc = Object.assign(initModel(type), rawDocs[0]); - resolve(doc); + resolve(initModel(type, rawDocs[0])); }) }) } @@ -245,8 +243,8 @@ export function removeBulkSilently (type, query) { // ~~~~~~~~~~~~~~~~~~~ // export function docUpdate (originalDoc, patch = {}) { - const doc = misc.copyObjectAndUpdate( - initModel(originalDoc.type), + const doc = initModel( + originalDoc.type, originalDoc, patch, {modified: Date.now()}, @@ -262,8 +260,8 @@ export function docCreate (type, patch = {}) { throw new Error(`No ID prefix for ${type}`) } - const doc = misc.copyObjectAndUpdate( - initModel(type), + const doc = initModel( + type, {_id: generateId(idPrefix)}, patch, diff --git a/app/common/har.js b/app/common/har.js index d5160fced5..00ef839a2f 100644 --- a/app/common/har.js +++ b/app/common/har.js @@ -1,21 +1,26 @@ import * as models from '../models'; -import {getRenderedRequest} from './render'; +import {getRenderedRequest} from './render'; import {jarFromCookies} from './cookies'; import * as util from './misc'; +import * as misc from './misc'; export function exportHarWithRequest (renderedRequest, addContentLength = false) { if (addContentLength) { - const hasContentLengthHeader = !!renderedRequest.headers.find( - h => h.name.toLowerCase() === 'content-length' - ); + const hasContentLengthHeader = misc.filterHeaders( + renderedRequest.headers, + 'content-length' + ).length > 0; if (!hasContentLengthHeader) { const name = 'content-length'; - const value = Buffer.byteLength(renderedRequest.body).toString(); + const value = Buffer.byteLength(body).toString(); renderedRequest.headers.push({name, value}) } } + // Luckily, Insomnia uses the same body format as HAR :) + const postData = renderedRequest.body; + return { method: renderedRequest.method, url: util.prepareUrlForSending(renderedRequest.url), @@ -23,7 +28,7 @@ export function exportHarWithRequest (renderedRequest, addContentLength = false) cookies: getCookies(renderedRequest), headers: renderedRequest.headers, queryString: renderedRequest.parameters, - postData: {text: renderedRequest.body}, + postData: postData, headersSize: -1, bodySize: -1 }; diff --git a/app/common/import.js b/app/common/import.js index de9c372074..1dafb01b5f 100644 --- a/app/common/import.js +++ b/app/common/import.js @@ -41,6 +41,14 @@ export async function importRaw (workspace, rawContent, generateNewIds = false) // Also always replace __WORKSPACE_ID__ with the current workspace if we see it generatedIds['__WORKSPACE_ID__'] = workspace._id; + // Import everything backwards so they get inserted in the correct order + data.resources.reverse(); + + const importedDocs = {}; + for (const model of models.all()) { + importedDocs[model.type] = []; + } + for (const resource of data.resources) { // Buffer DB changes // NOTE: Doing it inside here so it's more "scalable" @@ -67,15 +75,16 @@ export async function importRaw (workspace, rawContent, generateNewIds = false) } const doc = await model.getById(resource._id); + const newDoc = doc ? + await model.update(doc, resource) : + await model.create(resource); - if (doc) { - await model.update(doc, resource) - } else { - await model.create(resource) - } + importedDocs[newDoc.type].push(newDoc); } db.flushChanges(); + + return importedDocs; } export async function exportJSON (parentDoc = null) { diff --git a/app/common/misc.js b/app/common/misc.js index bde4f3d1a5..6c4236fdce 100644 --- a/app/common/misc.js +++ b/app/common/misc.js @@ -38,6 +38,11 @@ export function getContentTypeHeader (headers) { return matches.length ? matches[0] : null; } +export function getContentLengthHeader (headers) { + const matches = filterHeaders(headers, 'content-length'); + return matches.length ? matches[0] : null; +} + export function setDefaultProtocol (url, defaultProto = 'http:') { // Default the proto if it doesn't exist if (url.indexOf('://') === -1) { @@ -149,17 +154,3 @@ export function debounce (callback, millis = DEBOUNCE_MILLIS) { callback.apply(null, results['__key__']) }, millis).bind(null, '__key__'); } - -/** Same as Object.assign but only add fields that exist on target */ -export function copyObjectAndUpdate (target, ...sources) { - // Make a new object so we don't mutate the input - const newObject = Object.assign({}, target, ...sources); - - for (const key of Object.keys(newObject)) { - if (!target.hasOwnProperty(key)) { - delete newObject[key]; - } - } - - return newObject; -} diff --git a/app/common/network.js b/app/common/network.js index b93bb8323c..d2ab69d3df 100644 --- a/app/common/network.js +++ b/app/common/network.js @@ -2,13 +2,13 @@ import networkRequest from 'request'; import {parse as urlParse} from 'url'; 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} from './constants'; -import {jarFromCookies, cookiesFromJar} from './cookies'; +import {DEBOUNCE_MILLIS, STATUS_CODE_PEBKAC, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED} from './constants'; +import {jarFromCookies, cookiesFromJar, cookieHeaderValueForUri} from './cookies'; import {setDefaultProtocol} from './misc'; import {getRenderedRequest} from './render'; import {swapHost} from './dns'; -import {cookieHeaderValueForUri} from './cookies'; let cancelRequestFunction = null; @@ -20,14 +20,10 @@ export function cancelCurrentRequest () { export function _buildRequestConfig (renderedRequest, patch = {}) { const config = { - method: renderedRequest.method, - body: renderedRequest.body, - headers: {}, - // Setup redirect rules followAllRedirects: true, followRedirect: true, - maxRedirects: 20, + maxRedirects: 50, // Arbitrary (large) number timeout: 0, // Unzip gzipped responses @@ -49,19 +45,34 @@ export function _buildRequestConfig (renderedRequest, patch = {}) { encoding: null, }; + // Set the body + 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 + } else { + config.body = renderedRequest.body.text || ''; + } + + // Set the method + config.method = renderedRequest.method; + + // Set the headers + const headers = {}; + for (let i = 0; i < renderedRequest.headers.length; i++) { + let header = renderedRequest.headers[i]; + if (header.name) { + headers[header.name] = header.value; + } + } + config.headers = headers; + // Set the URL, including the query parameters const qs = querystring.buildFromParams(renderedRequest.parameters); const url = querystring.joinURL(renderedRequest.url, qs); config.url = util.prepareUrlForSending(url); config.headers.host = urlParse(config.url).host; - for (let i = 0; i < renderedRequest.headers.length; i++) { - let header = renderedRequest.headers[i]; - if (header.name) { - config.headers[header.name] = header.value; - } - } - return Object.assign(config, patch); } diff --git a/app/common/querystring.js b/app/common/querystring.js index ccadb3ede9..99b11e6dbc 100644 --- a/app/common/querystring.js +++ b/app/common/querystring.js @@ -60,6 +60,10 @@ export function buildFromParams (parameters, strict = true) { * @param strict allow empty names and values */ export function deconstructToParams (qs, strict = true) { + if (qs === '') { + return []; + } + const stringPairs = qs.split('&'); const pairs = []; diff --git a/app/common/render.js b/app/common/render.js index 763add0821..786d837831 100644 --- a/app/common/render.js +++ b/app/common/render.js @@ -129,6 +129,22 @@ export async function getRenderedRequest (request, environmentId) { // Render all request properties const renderedRequest = recursiveRender(request, renderContext); + // Remove disabled params + renderedRequest.parameters = renderedRequest.parameters.filter(p => !p.disabled); + + // Remove disabled headers + renderedRequest.headers = renderedRequest.headers.filter(p => !p.disabled); + + // Remove disabled body params + if (renderedRequest.body && Array.isArray(renderedRequest.body.params)) { + renderedRequest.body.params = renderedRequest.body.params.filter(p => !p.disabled); + } + + // Remove disabled authentication + if (renderedRequest.authentication && renderedRequest.authentication.disabled) { + renderedRequest.authentication = {} + } + // Default the proto if it doesn't exist renderedRequest.url = setDefaultProtocol(renderedRequest.url); diff --git a/app/main.development.js b/app/main.development.js index 5191fed033..92bb277014 100644 --- a/app/main.development.js +++ b/app/main.development.js @@ -8,6 +8,7 @@ import path from 'path'; import electron from 'electron'; import * as packageJSON from './package.json'; import LocalStorage from './common/LocalStorage'; +import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer'; // Some useful helpers const IS_DEV = process.env.INSOMNIA_ENV === 'development'; @@ -476,6 +477,10 @@ function createWindow () { ]; if (IS_DEV) { + installExtension(REACT_DEVELOPER_TOOLS) + .then((name) => console.log(`Added Extension: ${name}`)) + .catch((err) => console.log('An error occurred: ', err)); + template.push({ label: 'Developer', position: 'before=help', diff --git a/app/models/__tests__/request.test.js b/app/models/__tests__/request.test.js index 8ee18c3c3f..9de461bf28 100644 --- a/app/models/__tests__/request.test.js +++ b/app/models/__tests__/request.test.js @@ -1,15 +1,18 @@ import * as db from '../../common/database'; -import * as models from '../../models'; +import * as requestModel from '../../models/request'; +import {types as allModelTypes} from '../../models'; describe('init()', () => { beforeEach(() => { - return db.init(models.types(), {inMemoryOnly: true}, true); + return db.init(allModelTypes(), {inMemoryOnly: true}, true); }); + it('contains all required fields', async () => { Date.now = jest.genMockFunction().mockReturnValue(1478795580200); - expect(models.request.init()).toEqual({ + expect(requestModel.init()).toEqual({ + _schema: 1, authentication: {}, - body: '', + body: {}, headers: [], metaSortKey: -1478795580200, method: 'GET', @@ -22,21 +25,22 @@ describe('init()', () => { describe('create()', async () => { beforeEach(() => { - return db.init(models.types(), {inMemoryOnly: true}, true); + return db.init(allModelTypes(), {inMemoryOnly: true}, true); }); it('creates a valid request', async () => { Date.now = jest.genMockFunction().mockReturnValue(1478795580200); - const request = await models.request.create({name: 'Test Request', parentId: 'fld_124'}); + const request = await requestModel.create({name: 'Test Request', parentId: 'fld_124'}); const expected = { _id: 'req_dd2ccc1a2745477a881a9e8ef9d42403', + _schema: 1, created: 1478795580200, modified: 1478795580200, parentId: 'fld_124', type: 'Request', authentication: {}, - body: '', + body: {}, headers: [], metaSortKey: -1478795580200, method: 'GET', @@ -46,30 +50,30 @@ describe('create()', async () => { }; expect(request).toEqual(expected); - expect(await models.request.getById(expected._id)).toEqual(expected); + expect(await requestModel.getById(expected._id)).toEqual(expected); }); it('fails when missing parentId', async () => { Date.now = jest.genMockFunction().mockReturnValue(1478795580200); - expect(() => models.request.create({name: 'Test Request'})).toThrow('New Requests missing `parentId`') + expect(() => requestModel.create({name: 'Test Request'})).toThrow('New Requests missing `parentId`') }); }); -describe('updateContentType()', async () => { +describe('updateMimeType()', async () => { beforeEach(() => { - return db.init(models.types(), {inMemoryOnly: true}, true); + return db.init(allModelTypes(), {inMemoryOnly: true}, true); }); it('adds header when does not exist', async () => { - const request = await models.request.create({name: 'My Request', parentId: 'fld_1'}); + const request = await requestModel.create({name: 'My Request', parentId: 'fld_1'}); expect(request).not.toBeNull(); - const newRequest = await models.request.updateContentType(request, 'text/html'); + const newRequest = await requestModel.updateMimeType(request, 'text/html'); expect(newRequest.headers).toEqual([{name: 'Content-Type', value: 'text/html'}]); }); it('replaces header when exists', async () => { - const request = await models.request.create({ + const request = await requestModel.create({ name: 'My Request', parentId: 'fld_1', headers: [ @@ -81,7 +85,7 @@ describe('updateContentType()', async () => { }); expect(request).not.toBeNull(); - const newRequest = await models.request.updateContentType(request, 'text/html'); + const newRequest = await requestModel.updateMimeType(request, 'text/html'); expect(newRequest.headers).toEqual([ {name: 'content-tYPE', value: 'text/html'}, {name: 'foo', value: 'bar'}, @@ -91,27 +95,155 @@ describe('updateContentType()', async () => { }); it('replaces header when exists', async () => { - const request = await models.request.create({ + const request = await requestModel.create({ name: 'My Request', parentId: 'fld_1', headers: [{name: 'content-tYPE', value: 'application/json'}] }); expect(request).not.toBeNull(); - const newRequest = await models.request.updateContentType(request, 'text/html'); + const newRequest = await requestModel.updateMimeType(request, 'text/html'); expect(newRequest.headers).toEqual([{name: 'content-tYPE', value: 'text/html'}]); }); it('removes content-type', async () => { - const request = await models.request.create({ + const request = await requestModel.create({ name: 'My Request', parentId: 'fld_1', headers: [{name: 'content-tYPE', value: 'application/json'}] }); expect(request).not.toBeNull(); - const newRequest = await models.request.updateContentType(request, null); + const newRequest = await requestModel.updateMimeType(request, null); expect(newRequest.headers).toEqual([]); }); }); +describe('migrate()', () => { + it('migrates basic case', () => { + const original = { + headers: [], + body: 'hello world!' + }; + + const expected = { + _schema: 1, + headers: [], + body: {text: 'hello world!'} + }; + + expect(requestModel.migrate(original)).toEqual(expected); + }); + + it('migrates form-urlencoded', () => { + const original = { + headers: [{name: 'content-type', value: 'application/x-www-form-urlencoded'}], + body: 'foo=bar&baz={{ hello }}' + }; + + const expected = { + _schema: 1, + headers: [{name: 'content-type', value: 'application/x-www-form-urlencoded'}], + body: { + mimeType: 'application/x-www-form-urlencoded', + params: [ + {name: 'foo', value: 'bar'}, + {name: 'baz', value: '{{ hello }}'} + ] + } + }; + + expect(requestModel.migrate(original)).toEqual(expected); + }); + + it('migrates form-urlencoded with charset', () => { + const original = { + headers: [{name: 'content-type', value: 'application/x-www-form-urlencoded; charset=utf-8'}], + body: 'foo=bar&baz={{ hello }}' + }; + + const expected = { + _schema: 1, + headers: [{name: 'content-type', value: 'application/x-www-form-urlencoded; charset=utf-8'}], + body: { + mimeType: 'application/x-www-form-urlencoded', + params: [ + {name: 'foo', value: 'bar'}, + {name: 'baz', value: '{{ hello }}'} + ] + } + }; + + expect(requestModel.migrate(original)).toEqual(expected); + }); + + it('migrates form-urlencoded malformed', () => { + const original = { + headers: [{name: 'content-type', value: 'application/x-www-form-urlencoded'}], + body: '{"foo": "bar"}' + }; + + const expected = { + _schema: 1, + headers: [{name: 'content-type', value: 'application/x-www-form-urlencoded'}], + body: { + mimeType: 'application/x-www-form-urlencoded', + params: [ + {name: '{"foo": "bar"}', value: ''} + ] + } + }; + + expect(requestModel.migrate(original)).toEqual(expected); + }); + + it('migrates mime-type', () => { + const contentToMimeMap = { + 'application/json; charset=utf-8': 'application/json', + 'text/plain': 'text/plain', + 'malformed': 'malformed' + }; + + for (const contentType of Object.keys(contentToMimeMap)) { + const original = { + headers: [{name: 'content-type', value: contentType}], + body: '' + }; + + const expected = { + _schema: 1, + headers: [{name: 'content-type', value: contentType}], + body: {mimeType: contentToMimeMap[contentType], text: ''} + }; + + expect(requestModel.migrate(original)).toEqual(expected); + } + }); + + it('skips migrate for schema 1', () => { + const original = { + _schema: 1, + body: {mimeType: 'text/plain', text: 'foo'} + }; + + expect(requestModel.migrate(original)).toBe(original); + }); + + it('migrates with no schema and schema < 1', () => { + const withoutSchema = {body: 'foo bar!'}; + const withSchema = {_schema: 0, body: 'foo bar!'}; + const withWeirdSchema = {_schema: -4, body: 'foo bar!'}; + + const expected = { + _schema: 1, + body: { + text: 'foo bar!' + } + }; + + expect(requestModel.migrate(withSchema)).toEqual(expected); + expect(requestModel.migrate(withoutSchema)).toEqual(expected); + expect(requestModel.migrate(withWeirdSchema)).toEqual(expected); + }); +}); + diff --git a/app/models/cookieJar.js b/app/models/cookieJar.js index 56e152ae62..62bd1eaae7 100644 --- a/app/models/cookieJar.js +++ b/app/models/cookieJar.js @@ -1,5 +1,6 @@ import * as db from '../common/database'; +export const name = 'Cookie Jar'; export const type = 'CookieJar'; export const prefix = 'jar'; export function init () { @@ -9,6 +10,10 @@ export function init () { } } +export function migrate (doc) { + return doc; +} + export function create (patch = {}) { return db.docCreate(type, patch); } diff --git a/app/models/environment.js b/app/models/environment.js index a3fb96f92d..856a56e960 100644 --- a/app/models/environment.js +++ b/app/models/environment.js @@ -1,5 +1,6 @@ import * as db from '../common/database'; +export const name = 'Environment'; export const type = 'Environment'; export const prefix = 'env'; export function init () { @@ -9,6 +10,10 @@ export function init () { } } +export function migrate (doc) { + return doc; +} + export function create (patch = {}) { if (!patch.parentId) { throw new Error('New Environment missing `parentId`', patch); diff --git a/app/models/index.js b/app/models/index.js index 37d8a64f98..48be3e7d15 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -40,16 +40,46 @@ export function getModel (type) { return _models[type] || null; } -export function initModel (type) { - const baseDefaults = { +export function getModelName (type, count = 1) { + const model = getModel(type); + if (!model) { + return 'Unknown'; + } else if (count === 1) { + return model.name; + } else if (!model.name.match(/s$/)) { + // Add an 's' if it doesn't already end in one + return `${model.name}s`; + } else { + return model.name + } +} + +export function initModel (type, ...sources) { + const model = getModel(type); + + // Define global default fields + const objectDefaults = Object.assign({ type: type, _id: null, + _schema: 0, parentId: null, modified: Date.now(), created: Date.now(), - }; + }, model.init()); - const modelDefaults = getModel(type).init(); + // Make a new object + const fullObject = Object.assign({}, objectDefaults, ...sources); - return Object.assign(baseDefaults, modelDefaults); + // Migrate the model + // NOTE: Do migration before pruning because we might need to look at those fields + const migratedObject = model.migrate(fullObject); + + // Prune extra keys from doc + for (const key of Object.keys(migratedObject)) { + if (!objectDefaults.hasOwnProperty(key)) { + delete migratedObject[key]; + } + } + + return migratedObject; } diff --git a/app/models/request.js b/app/models/request.js index bdd937a004..a5039f821a 100644 --- a/app/models/request.js +++ b/app/models/request.js @@ -1,16 +1,20 @@ -import {METHOD_GET} from '../common/constants'; +import {METHOD_GET, getContentTypeFromHeaders, CONTENT_TYPE_FORM_URLENCODED, CONTENT_TYPE_FORM_DATA} from '../common/constants'; import * as db from '../common/database'; import {getContentTypeHeader} from '../common/misc'; +import {deconstructToParams} from '../common/querystring'; +import {CONTENT_TYPE_JSON} from '../common/constants'; +export const name = 'Request'; export const type = 'Request'; export const prefix = 'req'; export function init () { return { + _schema: 1, url: '', name: 'New Request', method: METHOD_GET, - body: '', + body: {}, parameters: [], headers: [], authentication: {}, @@ -18,6 +22,40 @@ export function init () { }; } +export function newBodyRaw (rawBody, contentType) { + if (!contentType) { + return {text: rawBody}; + } + + const mimeType = contentType.split(';')[0]; + return {mimeType, text: rawBody}; +} + +export function newBodyFormUrlEncoded (parameters) { + return { + mimeType: CONTENT_TYPE_FORM_URLENCODED, + params: parameters + } +} + +export function newBodyForm (parameters) { + return { + mimeType: CONTENT_TYPE_FORM_DATA, + params: parameters + } +} + +export function migrate (doc) { + const schema = doc._schema || 0; + + if (schema <= 0) { + doc = migrateTo1(doc); + doc._schema = 1; + } + + return doc; +} + export function create (patch = {}) { if (!patch.parentId) { throw new Error('New Requests missing `parentId`', patch); @@ -38,17 +76,31 @@ export function update (request, patch) { return db.docUpdate(request, patch); } -export function updateContentType (request, contentType) { +export function updateMimeType (request, mimeType) { let headers = [...request.headers]; const contentTypeHeader = getContentTypeHeader(headers); - if (!contentType) { + // 1. Update Content-Type header + + if (!mimeType) { // Remove the contentType header if we are un-setting it headers = headers.filter(h => h !== contentTypeHeader); } else if (contentTypeHeader) { - contentTypeHeader.value = contentType; + contentTypeHeader.value = mimeType; } else { - headers.push({name: 'Content-Type', value: contentType}) + headers.push({name: 'Content-Type', value: 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 || []); + } 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 || ''); + } else { + request.body = newBodyRaw(request.body.text || '', mimeType); } return update(request, {headers}); @@ -67,3 +119,24 @@ export function remove (request) { export function all () { return db.all(type); } + +// ~~~~~~~~~~ // +// Migrations // +// ~~~~~~~~~~ // + +function migrateTo1 (request) { + + // Second, convert all existing urlencoded bodies to new format + const contentType = getContentTypeFromHeaders(request.headers) || ''; + const wasFormUrlEncoded = !!contentType.match(/^application\/x-www-form-urlencoded/i); + + if (wasFormUrlEncoded) { + // Convert old-style form-encoded request bodies to new style + const params = deconstructToParams(request.body, false); + request.body = newBodyFormUrlEncoded(params); + } else { + request.body = newBodyRaw(request.body, contentType); + } + + return request; +} diff --git a/app/models/requestGroup.js b/app/models/requestGroup.js index 858bf6f6db..680023c1da 100644 --- a/app/models/requestGroup.js +++ b/app/models/requestGroup.js @@ -1,7 +1,9 @@ import * as db from '../common/database'; +export const name = 'Folder'; export const type = 'RequestGroup'; export const prefix = 'fld'; + export function init () { return { name: 'New Folder', @@ -10,6 +12,10 @@ export function init () { } } +export function migrate (doc) { + return doc; +} + export function create (patch = {}) { if (!patch.parentId) { throw new Error('New Requests missing `parentId`', patch); diff --git a/app/models/response.js b/app/models/response.js index 18b6635392..4b2607e4e0 100644 --- a/app/models/response.js +++ b/app/models/response.js @@ -1,7 +1,9 @@ import * as db from '../common/database'; +export const name = 'Response'; export const type = 'Response'; export const prefix = 'res'; + export function init () { return { statusCode: 0, @@ -18,6 +20,10 @@ export function init () { } } +export function migrate (doc) { + return doc; +} + export function create (patch = {}) { if (!patch.parentId) { throw new Error('New Response missing `parentId`'); diff --git a/app/models/settings.js b/app/models/settings.js index 6e17a3f2f5..a18d339893 100644 --- a/app/models/settings.js +++ b/app/models/settings.js @@ -1,7 +1,9 @@ import * as db from '../common/database'; +export const name = 'Settings'; export const type = 'Settings'; export const prefix = 'set'; + export function init () { return { showPasswords: true, @@ -17,6 +19,10 @@ export function init () { }; } +export function migrate (doc) { + return doc; +} + export async function all () { const settings = await db.all(type); if (settings.length === 0) { diff --git a/app/models/stats.js b/app/models/stats.js index 8e04992255..bd2d2216b7 100644 --- a/app/models/stats.js +++ b/app/models/stats.js @@ -1,7 +1,9 @@ import * as db from '../common/database'; +export const name = 'Stats'; export const type = 'Stats'; export const prefix = 'sta'; + export function init () { return { lastLaunch: Date.now(), @@ -10,6 +12,10 @@ export function init () { }; } +export function migrate (doc) { + return doc; +} + export function create (patch = {}) { return db.docCreate(type, patch); } diff --git a/app/models/workspace.js b/app/models/workspace.js index 7e7a52c4b1..623d8bdfa6 100644 --- a/app/models/workspace.js +++ b/app/models/workspace.js @@ -1,13 +1,19 @@ import * as db from '../common/database'; +export const name = 'Workspace'; export const type = 'Workspace'; export const prefix = 'wrk'; + export function init () { return { name: 'New Workspace', }; } +export function migrate (doc) { + return doc; +} + export function getById (id) { return db.get(type, id); } diff --git a/app/sync/index.js b/app/sync/index.js index eb179e739c..fe89fbba34 100644 --- a/app/sync/index.js +++ b/app/sync/index.js @@ -625,7 +625,7 @@ async function _getOrCreateAllActiveResources (resourceGroupId = null) { try { activeResourceMap[doc._id] = await _createResourceForDoc(doc); } catch (e) { - logger.error(`Failed to create resource for ${doc._id}`, doc); + logger.error(`Failed to create resource for ${doc._id}`, e, {doc}); } } } diff --git a/app/ui/components/RenderedQueryString.js b/app/ui/components/RenderedQueryString.js index 2ff1935df0..96e30ef047 100644 --- a/app/ui/components/RenderedQueryString.js +++ b/app/ui/components/RenderedQueryString.js @@ -13,14 +13,14 @@ class RenderedQueryString extends Component { } _update (props, delay = false) { - clearTimeout(this._askTimeout); - this._askTimeout = setTimeout(async () => { + clearTimeout(this._triggerTimeout); + this._triggerTimeout = setTimeout(async () => { const {request, environmentId} = props; const {url, parameters} = await getRenderedRequest(request, environmentId); const qs = querystring.buildFromParams(parameters); const fullUrl = querystring.joinURL(url, qs); this.setState({string: util.prepareUrlForSending(fullUrl)}); - }, delay ? 300 : 0); + }, delay ? 200 : 0); } componentDidMount () { @@ -28,7 +28,7 @@ class RenderedQueryString extends Component { } componentWillUnmount () { - clearTimeout(this._askTimeout); + clearTimeout(this._triggerTimeout); } componentWillReceiveProps (nextProps) { diff --git a/app/ui/components/RequestPane.js b/app/ui/components/RequestPane.js index 27456e2ba8..99af6b94f5 100644 --- a/app/ui/components/RequestPane.js +++ b/app/ui/components/RequestPane.js @@ -4,7 +4,7 @@ import KeyValueEditor from './base/KeyValueEditor'; import RequestHeadersEditor from './editors/RequestHeadersEditor'; import ContentTypeDropdown from './dropdowns/ContentTypeDropdown'; import RenderedQueryString from './RenderedQueryString'; -import BodyEditor from './editors/BodyEditor'; +import BodyEditor from './editors/body/BodyEditor'; import AuthEditor from './editors/AuthEditor'; import RequestUrlBar from './RequestUrlBar.js'; import {MOD_SYM, getContentTypeName, getContentTypeFromHeaders} from '../../common/constants'; @@ -28,7 +28,7 @@ class RequestPane extends Component { updateRequestParameters, updateRequestAuthentication, updateRequestHeaders, - updateRequestContentType, + updateRequestMimeType, updateSettingsUseBulkHeaderEditor } = this.props; @@ -102,7 +102,7 @@ class RequestPane extends Component { {getContentTypeName(getContentTypeFromHeaders(request.headers))} + updateRequestMimeType={updateRequestMimeType}/> + @@ -257,7 +278,7 @@ class KeyValueEditor extends Component { KeyValueEditor.propTypes = { onChange: PropTypes.func.isRequired, - pairs: PropTypes.array.isRequired, + pairs: PropTypes.arrayOf(PropTypes.object).isRequired, // Optional maxPairs: PropTypes.number, diff --git a/app/ui/components/base/PromptButton.js b/app/ui/components/base/PromptButton.js index 98c5874988..fd8b18ecd3 100644 --- a/app/ui/components/base/PromptButton.js +++ b/app/ui/components/base/PromptButton.js @@ -12,7 +12,7 @@ class PromptButton extends Component { _confirm (e) { // Clear existing timeouts - clearTimeout(this._askTimeout); + clearTimeout(this._triggerTimeout); // Fire the click handler this.props.onClick(e); @@ -23,7 +23,7 @@ class PromptButton extends Component { }, 100); // Set a timeout to hide the confirmation - this._askTimeout = setTimeout(() => { + this._triggerTimeout = setTimeout(() => { this.setState({state: STATE_DEFAULT}); }, 2000); } @@ -37,7 +37,7 @@ class PromptButton extends Component { this.setState({state: STATE_ASK}); // Set a timeout to hide the confirmation - this._askTimeout = setTimeout(() => { + this._triggerTimeout = setTimeout(() => { this.setState({state: STATE_DEFAULT}); }, 2000); } @@ -54,7 +54,7 @@ class PromptButton extends Component { } componentWillUnmount () { - clearTimeout(this._askTimeout); + clearTimeout(this._triggerTimeout); clearTimeout(this._doneTimeout); } @@ -90,7 +90,7 @@ class PromptButton extends Component { PromptButton.propTypes = { addIcon: PropTypes.bool, - confirmMessage: PropTypes.string + confirmMessage: PropTypes.any, }; export default PromptButton; diff --git a/app/ui/components/dropdowns/ContentTypeDropdown.js b/app/ui/components/dropdowns/ContentTypeDropdown.js index c7320419ed..ca0c66716e 100644 --- a/app/ui/components/dropdowns/ContentTypeDropdown.js +++ b/app/ui/components/dropdowns/ContentTypeDropdown.js @@ -1,16 +1,16 @@ import React, {PropTypes} from 'react'; import {Dropdown, DropdownButton, DropdownItem} from '../base/dropdown'; -import {CONTENT_TYPES, getContentTypeName} from '../../../common/constants'; +import {contentTypesMap} from '../../../common/constants'; -const ContentTypeDropdown = ({updateRequestContentType}) => { +const ContentTypeDropdown = ({updateRequestMimeType}) => { return ( - {CONTENT_TYPES.map(contentType => ( - updateRequestContentType(contentType)}> - {getContentTypeName(contentType)} + {Object.keys(contentTypesMap).map(mimeType => ( + updateRequestMimeType(mimeType)}> + {contentTypesMap[mimeType]} ))} @@ -18,7 +18,7 @@ const ContentTypeDropdown = ({updateRequestContentType}) => { }; ContentTypeDropdown.propTypes = { - updateRequestContentType: PropTypes.func.isRequired + updateRequestMimeType: PropTypes.func.isRequired }; export default ContentTypeDropdown; diff --git a/app/ui/components/editors/AuthEditor.js b/app/ui/components/editors/AuthEditor.js index 5af5c46e02..fc03eabdfc 100644 --- a/app/ui/components/editors/AuthEditor.js +++ b/app/ui/components/editors/AuthEditor.js @@ -5,7 +5,8 @@ const AuthEditor = ({request, showPasswords, onChange, ...other}) => { const auth = request.authentication; const pairs = [{ name: auth.username || '', - value: auth.password || '' + value: auth.password || '', + disabled: auth.disabled || false, }]; return ( @@ -17,7 +18,8 @@ const AuthEditor = ({request, showPasswords, onChange, ...other}) => { valueInputType={showPasswords ? 'text' : 'password'} onChange={pairs => onChange({ username: pairs.length ? pairs[0].name : '', - password: pairs.length ? pairs[0].value : '' + password: pairs.length ? pairs[0].value : '', + disabled: pairs.length ? pairs[0].disabled : false, })} {...other} /> diff --git a/app/ui/components/editors/BodyEditor.js b/app/ui/components/editors/BodyEditor.js deleted file mode 100644 index b214084509..0000000000 --- a/app/ui/components/editors/BodyEditor.js +++ /dev/null @@ -1,107 +0,0 @@ -import React, {PropTypes, Component} from 'react'; -import Editor from '../base/Editor'; -import KeyValueEditor from '../base/KeyValueEditor'; -import {CONTENT_TYPE_FORM_URLENCODED, getContentTypeFromHeaders} from '../../../common/constants'; -import * as querystring from '../../../common/querystring'; - -class BodyEditor extends Component { - static _getBodyFromPairs (pairs) { - // HACK: Form data needs to be encoded, but we shouldn't encode templating - // Lets try out best to keep {% ... %} and {{ ... }} untouched - - - // 1. Replace all template tags with urlencode-friendly tags - - const encodingMap = {}; - const re = /(\{\{\s*[^{}]+\s*}})|(\{%\s*[^{}]+\s*%})/g; - const next = (s) => { - const results = re.exec(s); - if (results) { - const key = `XYZ${Object.keys(encodingMap).length}ZYX`; - const word = results[0]; - const index = results['index']; - encodingMap[key] = word; - const newS = `${s.slice(0, index)}${key}${s.slice(index + word.length)}`; - return next(newS); - } - - return s; - }; - const encodedPairs = JSON.parse(next(JSON.stringify(pairs))); - - // 2. Generate the body - - const params = []; - for (let {name, value} of encodedPairs) { - params.push({name, value}); - } - - let body = querystring.buildFromParams(params, false); - - // 3. Put all the template tags back - - for (const key in encodingMap) { - body = body.replace(key, encodingMap[key]); - } - - return body; - } - - static _getPairsFromBody (body) { - if (body === '') { - return []; - } else { - return querystring.deconstructToParams(body, false); - } - } - - render () { - const { - fontSize, - lineWrapping, - request, - onChange, - className - } = this.props; - - const contentType = getContentTypeFromHeaders(request.headers); - - if (contentType === CONTENT_TYPE_FORM_URLENCODED) { - return ( -
-
- onChange(BodyEditor._getBodyFromPairs(pairs))} - pairs={BodyEditor._getPairsFromBody(request.body)} - /> -
-
- ) - } else { - return ( - - ) - } - } -} - -BodyEditor.propTypes = { - // Functions - onChange: PropTypes.func.isRequired, - request: PropTypes.object.isRequired, - - // Optional - fontSize: PropTypes.number, - lineWrapping: PropTypes.bool -}; - -export default BodyEditor; diff --git a/app/ui/components/editors/RequestHeadersEditor.js b/app/ui/components/editors/RequestHeadersEditor.js index 69735b1e0b..8019b530ac 100644 --- a/app/ui/components/editors/RequestHeadersEditor.js +++ b/app/ui/components/editors/RequestHeadersEditor.js @@ -40,7 +40,7 @@ class RequestHeadersEditor extends Component { let headersString = ''; for (const header of headers) { - if (!header.name || !header.value) { + if (header.disabled || !header.name || !header.value) { // Not a valid header continue; } diff --git a/app/ui/components/editors/body/BodyEditor.js b/app/ui/components/editors/body/BodyEditor.js new file mode 100644 index 0000000000..6bd318e606 --- /dev/null +++ b/app/ui/components/editors/body/BodyEditor.js @@ -0,0 +1,89 @@ +import React, {PropTypes, Component} from 'react'; +import RawEditor from './RawEditor'; +import UrlEncodedEditor from './UrlEncodedEditor'; +import FormEditor from './FormEditor'; +import {getContentTypeFromHeaders, BODY_TYPE_FORM_URLENCODED, BODY_TYPE_FORM, BODY_TYPE_FILE} from '../../../../common/constants'; +import {newBodyRaw, newBodyFormUrlEncoded, newBodyForm} from '../../../../models/request'; +import {CONTENT_TYPE_FORM_URLENCODED} from '../../../../common/constants'; +import {CONTENT_TYPE_FORM_DATA} from '../../../../common/constants'; + +class BodyEditor extends Component { + constructor (props) { + super(props); + this._boundHandleRawChange = this._handleRawChange.bind(this); + this._boundHandleFormUrlEncodedChange = this._handleFormUrlEncodedChange.bind(this); + this._boundHandleFormChange = this._handleFormChange.bind(this); + } + + _handleRawChange (rawValue) { + const {onChange, request} = this.props; + + const contentType = getContentTypeFromHeaders(request.headers); + const newBody = newBodyRaw(rawValue, contentType); + + onChange(newBody); + } + + _handleFormUrlEncodedChange (parameters) { + const {onChange} = this.props; + const newBody = newBodyFormUrlEncoded(parameters); + onChange(newBody); + } + + _handleFormChange (parameters) { + const {onChange} = this.props; + const newBody = newBodyForm(parameters); + onChange(newBody); + } + + render () { + const {fontSize, lineWrapping, request} = this.props; + const bodyType = request.body.mimeType; + const fileName = request.body.fileName; + + if (bodyType === CONTENT_TYPE_FORM_URLENCODED) { + return ( + + ) + } else if (bodyType === CONTENT_TYPE_FORM_DATA) { + return ( + + ) + } else if (bodyType === BODY_TYPE_FILE) { + // TODO + return null + } else { + const contentType = getContentTypeFromHeaders(request.headers); + return ( + + ) + } + } +} + +BodyEditor.propTypes = { + // Required + onChange: PropTypes.func.isRequired, + request: PropTypes.object.isRequired, + + // Optional + fontSize: PropTypes.number, + lineWrapping: PropTypes.bool +}; + +export default BodyEditor; diff --git a/app/ui/components/editors/body/FileEditor.js b/app/ui/components/editors/body/FileEditor.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/ui/components/editors/body/FormEditor.js b/app/ui/components/editors/body/FormEditor.js new file mode 100644 index 0000000000..b77ab21ee9 --- /dev/null +++ b/app/ui/components/editors/body/FormEditor.js @@ -0,0 +1,24 @@ +import React, {PropTypes, Component} from 'react'; +import KeyValueEditor from '../../base/KeyValueEditor'; + +class FormEditor extends Component { + render () { + const {parameters, onChange} = this.props; + + return ( +
+
+ +
+
+ ) + } +} + +FormEditor.propTypes = { + // Required + onChange: PropTypes.func.isRequired, + parameters: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default FormEditor; diff --git a/app/ui/components/editors/body/RawEditor.js b/app/ui/components/editors/body/RawEditor.js new file mode 100644 index 0000000000..e0cba7f853 --- /dev/null +++ b/app/ui/components/editors/body/RawEditor.js @@ -0,0 +1,41 @@ +import React, {PropTypes, Component} from 'react'; +import Editor from '../../base/Editor'; + +class RawEditor extends Component { + render () { + const { + contentType, + content, + fontSize, + lineWrapping, + onChange, + className + } = this.props; + + return ( + + ) + } +} + +RawEditor.propTypes = { + // Required + onChange: PropTypes.func.isRequired, + content: PropTypes.string.isRequired, + contentType: PropTypes.string.isRequired, + + // Optional + fontSize: PropTypes.number, + lineWrapping: PropTypes.bool +}; + +export default RawEditor; diff --git a/app/ui/components/editors/body/UrlEncodedEditor.js b/app/ui/components/editors/body/UrlEncodedEditor.js new file mode 100644 index 0000000000..0e938c89bd --- /dev/null +++ b/app/ui/components/editors/body/UrlEncodedEditor.js @@ -0,0 +1,24 @@ +import React, {PropTypes, Component} from 'react'; +import KeyValueEditor from '../../base/KeyValueEditor'; + +class UrlEncodedEditor extends Component { + render () { + const {parameters, onChange} = this.props; + + return ( +
+
+ +
+
+ ) + } +} + +UrlEncodedEditor.propTypes = { + // Required + onChange: PropTypes.func.isRequired, + parameters: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default UrlEncodedEditor; diff --git a/app/ui/components/modals/AlertModal.js b/app/ui/components/modals/AlertModal.js index 841b2758d0..024050ae98 100644 --- a/app/ui/components/modals/AlertModal.js +++ b/app/ui/components/modals/AlertModal.js @@ -10,8 +10,10 @@ class AlertModal extends Component { this.state = {}; } - show ({title, message}) { + show (options = {}) { this.modal.show(); + + const {title, message} = options; this.setState({title, message}); } diff --git a/app/ui/components/sidebar/SidebarFilter.js b/app/ui/components/sidebar/SidebarFilter.js index 2b8fcb8a92..19f403ab63 100644 --- a/app/ui/components/sidebar/SidebarFilter.js +++ b/app/ui/components/sidebar/SidebarFilter.js @@ -5,8 +5,8 @@ import {DEBOUNCE_MILLIS} from '../../../common/constants'; class SidebarFilter extends Component { _onChange (value) { - clearTimeout(this._askTimeout); - this._askTimeout = setTimeout(() => { + clearTimeout(this._triggerTimeout); + this._triggerTimeout = setTimeout(() => { this.props.onChange(value); }, DEBOUNCE_MILLIS); } diff --git a/app/ui/css/components/forms.less b/app/ui/css/components/forms.less index 1c7fd913e0..17eb3bd03d 100644 --- a/app/ui/css/components/forms.less +++ b/app/ui/css/components/forms.less @@ -16,6 +16,7 @@ border: 1px solid @warning !important; } + &.form-control--padded, &.form-control--outlined, &.form-control--underlined { height: auto; @@ -34,6 +35,13 @@ } } + &.form-control--padded { + textarea, + input { + border: 0; + } + } + &.form-control--underlined { textarea, input { diff --git a/app/ui/css/components/keyvalueeditor.less b/app/ui/css/components/keyvalueeditor.less index bacc9c15ec..cc799e8b91 100644 --- a/app/ui/css/components/keyvalueeditor.less +++ b/app/ui/css/components/keyvalueeditor.less @@ -4,37 +4,44 @@ .key-value-editor { padding: @padding-md 0; - li { + .key-value-editor__label { + padding: 0 @padding-md; + opacity: 0.5; + font-size: @font-size-sm; + } - .key-value-editor__label { - padding: 0 @padding-md; - opacity: 0.5; - font-size: @font-size-sm; + .key-value-editor__row { + display: grid; + grid-template-columns: minmax(0, 0.5fr) minmax(0, 0.5fr) auto auto; + grid-template-rows: auto; + + &.key-value-editor__row--disabled input { + text-decoration: line-through; + color: @hl-xxl; } - .key-value-editor__row { - display: grid; - grid-template-columns: minmax(0, 0.5fr) minmax(0, 0.5fr) auto; - grid-template-rows: auto; + & > * { + padding: 0 @padding-sm; - & > * { - padding: 0 @padding-sm; - - &:first-child { - padding-left: @padding-md; - } - - &:last-child { - padding-right: @padding-md; - } + &:first-child { + padding-left: @padding-md; } - & > button { - color: @hl; + &:last-child { + padding-right: @padding-md; + } + } - &:hover { - color: inherit; - } + & > button { + color: @hl; + + &:hover, + &:focus { + background: transparent; + } + + &:hover { + color: inherit; } } } diff --git a/app/ui/redux/modules/global.js b/app/ui/redux/modules/global.js index edf6d18797..171bcf1b52 100644 --- a/app/ui/redux/modules/global.js +++ b/app/ui/redux/modules/global.js @@ -139,7 +139,7 @@ export function importFile (workspaceId) { }] }; - electron.remote.dialog.showOpenDialog(options, paths => { + electron.remote.dialog.showOpenDialog(options, async paths => { if (!paths) { // It was cancelled, so let's bail out dispatch(loadStop()); @@ -148,20 +148,32 @@ export function importFile (workspaceId) { } // Let's import all the paths! - paths.map(path => { - fs.readFile(path, 'utf8', async (err, data) => { + for (const path of paths) { + try { + const data = fs.readFileSync(path, 'utf8'); dispatch(loadStop()); - if (err) { - trackEvent('Import', 'Failure'); - console.warn('Import Failed', err); - return; - } + const summary = await importRaw(workspace, data); - importRaw(workspace, data); + let statements = Object.keys(summary).map(type => { + const count = summary[type].length; + const name = models.getModelName(type, count); + return count === 0 ? null :`${count} ${name}`; + }).filter(s => s !== null); + + let message; + if (statements.length === 0) { + message = 'Nothing was found to import.'; + } else { + message = `You imported ${statements.join(', ')}!`; + } + showModal(AlertModal, {title: 'Import Succeeded', message}); trackEvent('Import', 'Success'); - }); - }) + } catch (e) { + showModal(AlertModal, {title: 'Import Failed', message: e + ''}); + trackEvent('Import', 'Failure', e); + } + } }); } } diff --git a/package.json b/package.json index c43b2946ef..44270039ec 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "scripts": { "test:noisy": "jest", "test:coverage": "jest --coverage --silent && open ./coverage/lcov-report/index.html", + "test:watch": "jest --silent --watch", "test": "jest --silent", "start-hot": "cross-env HOT=1 INSOMNIA_ENV=development electron -r babel-register ./app/main.development.js", "hot-server": "babel-node ./webpack/server.js", @@ -133,6 +134,7 @@ "css-loader": "^0.23.1", "electron": "^1.4.7", "electron-builder": "^8.6.0", + "electron-devtools-installer": "^2.0.1", "express": "^4.14.0", "file-loader": "^0.9.0", "jest": "^17.0.0",