diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 056c9f1e7..000000000 --- a/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -build -dist -coverage diff --git a/app/templating/__tests__/utils.test.js b/app/templating/__tests__/utils.test.js index 4547aa4d0..87ec5e1dc 100644 --- a/app/templating/__tests__/utils.test.js +++ b/app/templating/__tests__/utils.test.js @@ -37,8 +37,117 @@ describe('getKeys()', () => { }; const keys = utils.getKeys(obj); - expect(keys).toEqual([ - {name: 'foo', value: 'bar'} - ]); + expect(keys).toEqual([{name: 'foo', value: 'bar'}]); + }); +}); + +describe('tokenizeTag()', () => { + it('tokenizes complex tag', () => { + const actual = utils.tokenizeTag( + `{% name bar, "baz \\"qux\\"" , 1 + 5 | default("foo") %}` + ); + + const expected = { + name: 'name', + args: [ + {type: 'variable', value: 'bar'}, + {type: 'string', value: 'baz "qux"', quotedBy: '"'}, + {type: 'expression', value: '1 + 5 | default("foo")'} + ] + }; + + expect(actual).toEqual(expected); + }); + + it('handles whitespace', () => { + const minimal = utils.tokenizeTag(`{%name'foo',bar%}`); + const generous = utils.tokenizeTag(`{%name \t'foo' , bar\t\n%}`); + + const expected = { + name: 'name', + args: [ + {type: 'string', value: 'foo', quotedBy: "'"}, + {type: 'variable', value: 'bar'} + ] + }; + + expect(minimal).toEqual(expected); + expect(generous).toEqual(expected); + }); + + it('handles type string', () => { + const actual = utils.tokenizeTag(`{% name 'foo' %}`); + + const expected = { + name: 'name', + args: [{type: 'string', value: 'foo', quotedBy: "'"}] + }; + + expect(actual).toEqual(expected); + }); + + it('handles type number', () => { + const actual = utils.tokenizeTag(`{% name 1.222, 123 %}`); + + const expected = { + name: 'name', + args: [ + {type: 'number', value: '1.222'}, + {type: 'number', value: '123'} + ] + }; + + expect(actual).toEqual(expected); + }); + + it('handles type boolean', () => { + const actual = utils.tokenizeTag(`{% name true, false %}`); + + const expected = { + name: 'name', + args: [ + {type: 'boolean', value: 'true'}, + {type: 'boolean', value: 'false'} + ] + }; + + expect(actual).toEqual(expected); + }); + + it('handles type expression', () => { + const actual = utils.tokenizeTag(`{% name 5 * 10 + 'hello' | default(2 - 3) %}`); + + const expected = { + name: 'name', + args: [{type: 'expression', value: `5 * 10 + 'hello' | default(2 - 3)`}] + }; + + expect(actual).toEqual(expected); + }); + + /** + * NOTE: This is actually invalid Nunjucks but we handle it anyway + * because it's better (and easier to implement) than failing. + */ + it('handles no commas', () => { + const actual = utils.tokenizeTag(`{% name foo bar baz %}`); + + const expected = { + name: 'name', + args: [{type: 'expression', value: 'foo bar baz'}] + }; + + expect(actual).toEqual(expected); + }); +}); + +describe('unTokenizeTag()', () => { + it('untokenizes a tag', () => { + const tagStr = `{% name bar, "baz \\"qux\\"" , 1 + 5, 'hi' %}`; + + const tagData = utils.tokenizeTag(tagStr); + const result = utils.unTokenizeTag(tagData); + + expect(result).toEqual(`{% name bar, "baz \\"qux\\"", 1 + 5, 'hi' %}`); }); }); diff --git a/app/templating/extensions/__tests__/response-extension.test.js b/app/templating/extensions/__tests__/response-extension.test.js index 618af28ea..f4237b294 100644 --- a/app/templating/extensions/__tests__/response-extension.test.js +++ b/app/templating/extensions/__tests__/response-extension.test.js @@ -25,6 +25,17 @@ describe('ResponseExtension General', async () => { expect(err.message).toContain('Could not find request req_test'); } }); + + it('fails on empty filter', async () => { + await models.response.create({parentId: 'req_test', body: '{"foo": "bar"}'}); + + try { + await templating.render(`{% response "body", "req_test", "" %}`); + fail('Should have failed'); + } catch (err) { + expect(err.message).toContain('No body filter specified'); + } + }); }); describe('ResponseExtension JSONPath', async () => { @@ -32,7 +43,11 @@ describe('ResponseExtension JSONPath', async () => { it('renders basic response "body", query', async () => { const request = await models.request.create({parentId: 'foo'}); - await models.response.create({parentId: request._id, body: '{"foo": "bar"}'}); + await models.response.create({ + parentId: request._id, + statusCode: 200, + body: '{"foo": "bar"}' + }); const result = await templating.render(`{% response "body", "${request._id}", "$.foo" %}`); @@ -41,7 +56,11 @@ describe('ResponseExtension JSONPath', async () => { it('fails on invalid JSON', async () => { const request = await models.request.create({parentId: 'foo'}); - await models.response.create({parentId: request._id, body: '{"foo": "bar"'}); + await models.response.create({ + parentId: request._id, + body: '{"foo": "bar"', + statusCode: 200 + }); try { await templating.render(`{% response "body", "${request._id}", "$.foo" %}`); @@ -53,7 +72,11 @@ describe('ResponseExtension JSONPath', async () => { it('fails on invalid query', async () => { const request = await models.request.create({parentId: 'foo'}); - await models.response.create({parentId: request._id, body: '{"foo": "bar"}'}); + await models.response.create({ + parentId: request._id, + statusCode: 200, + body: '{"foo": "bar"}' + }); try { await templating.render(`{% response "body", "${request._id}", "$$" %}`); @@ -65,7 +88,11 @@ describe('ResponseExtension JSONPath', async () => { it('fails on no results', async () => { const request = await models.request.create({parentId: 'foo'}); - await models.response.create({parentId: request._id, body: '{"foo": "bar"}'}); + await models.response.create({ + parentId: request._id, + statusCode: 200, + body: '{"foo": "bar"}' + }); try { await templating.render(`{% response "body", "${request._id}", "$.missing" %}`); @@ -77,7 +104,11 @@ describe('ResponseExtension JSONPath', async () => { it('fails on more than 1 result', async () => { const request = await models.request.create({parentId: 'foo'}); - await models.response.create({parentId: request._id, body: '{"array": [1,2,3]}'}); + await models.response.create({ + parentId: request._id, + statusCode: 200, + body: '{"array": [1,2,3]}' + }); try { await templating.render(`{% response "body", "${request._id}", "$.array.*" %}`); @@ -95,6 +126,7 @@ describe('ResponseExtension XPath', async () => { const request = await models.request.create({parentId: 'foo'}); await models.response.create({ parentId: request._id, + statusCode: 200, body: 'Hello World!' }); @@ -105,7 +137,11 @@ describe('ResponseExtension XPath', async () => { it('no results on invalid XML', async () => { const request = await models.request.create({parentId: 'foo'}); - await models.response.create({parentId: request._id, body: ''}); + await models.response.create({ + parentId: request._id, + statusCode: 200, + body: '' + }); try { await templating.render(`{% response "body", "${request._id}", "/foo" %}`); @@ -119,6 +155,7 @@ describe('ResponseExtension XPath', async () => { const request = await models.request.create({parentId: 'foo'}); await models.response.create({ parentId: request._id, + statusCode: 200, body: 'Hello World!' }); @@ -134,6 +171,7 @@ describe('ResponseExtension XPath', async () => { const request = await models.request.create({parentId: 'foo'}); await models.response.create({ parentId: request._id, + statusCode: 200, body: 'Hello World!' }); @@ -149,6 +187,7 @@ describe('ResponseExtension XPath', async () => { const request = await models.request.create({parentId: 'foo'}); await models.response.create({ parentId: request._id, + statusCode: 200, body: 'Hello World!And again!' }); @@ -160,3 +199,65 @@ describe('ResponseExtension XPath', async () => { } }); }); + +describe('ResponseExtension Header', async () => { + beforeEach(() => db.init(models.types(), {inMemoryOnly: true}, true)); + + it('renders basic response "header"', async () => { + const request = await models.request.create({parentId: 'foo'}); + await models.response.create({ + parentId: request._id, + statusCode: 200, + headers: [ + {name: 'Content-Type', value: 'application/json'}, + {name: 'Content-Length', value: '20'} + ] + }); + + const id = request._id; + + expect(await templating.render(`{% response "header", "${id}", "content-type" %}`)) + .toBe('application/json'); + expect(await templating.render(`{% response "header", "${id}", "Content-Type" %}`)) + .toBe('application/json'); + expect(await templating.render(`{% response "header", "${id}", "CONTENT-type" %}`)) + .toBe('application/json'); + expect(await templating.render(`{% response "header", "${id}", " CONTENT-type " %}`)) + .toBe('application/json'); + }); + + it('no results on missing header', async () => { + const request = await models.request.create({parentId: 'foo'}); + await models.response.create({ + parentId: request._id, + statusCode: 200, + headers: [ + {name: 'Content-Type', value: 'application/json'}, + {name: 'Content-Length', value: '20'} + ] + }); + + try { + await templating.render(`{% response "header", "${request._id}", "dne" %}`); + fail('should have failed'); + } catch (err) { + expect(err.message).toContain('No match for header: dne'); + } + }); +}); + +describe('ResponseExtension Raw', async () => { + beforeEach(() => db.init(models.types(), {inMemoryOnly: true}, true)); + + it('renders basic response "header"', async () => { + const request = await models.request.create({parentId: 'foo'}); + await models.response.create({ + parentId: request._id, + statusCode: 200, + body: 'Hello World!' + }); + + const result = await templating.render(`{% response "raw", "${request._id}", "" %}`); + expect(result).toBe('Hello World!'); + }); +}); diff --git a/app/templating/extensions/base-64-extension.js b/app/templating/extensions/base-64-extension.js index 0ecdcf911..4d98e7edc 100644 --- a/app/templating/extensions/base-64-extension.js +++ b/app/templating/extensions/base-64-extension.js @@ -1,9 +1,40 @@ import BaseExtension from './base/base-extension'; export default class Base64Extension extends BaseExtension { - constructor () { - super(); - this.tags = ['base64']; + getName () { + return 'Base64'; + } + + getTag () { + return 'base64'; + } + + getDefaultFill () { + return "base64 'encode', ''"; + } + + getDescription () { + return 'encode or decode values'; + } + + getArguments () { + return [ + { + key: 'action', + label: 'Action', + type: 'enum', + options: [ + {name: 'Encode', value: 'encode'}, + {name: 'Decode', value: 'decode'} + ] + }, + { + key: 'value', + label: 'Value', + type: 'string', + placeholder: 'My text' + } + ]; } run (context, op, text) { diff --git a/app/templating/extensions/base/base-extension.js b/app/templating/extensions/base/base-extension.js index 4eb22592d..7b04d05ae 100644 --- a/app/templating/extensions/base/base-extension.js +++ b/app/templating/extensions/base/base-extension.js @@ -2,8 +2,27 @@ const EMPTY_ARG = '__EMPTY_NUNJUCKS_ARG__'; export default class BaseExtension { constructor () { - // TODO: Subclass should set this - this.tags = []; + this.tags = [this.getTag()]; + } + + getName () { + throw new Error(`${this.constructor.name} did not implement getName()`); + } + + getTag () { + throw new Error(`${this.constructor.name} did not implement getTag()`); + } + + getDefaultFill () { + throw new Error(`${this.constructor.name} did not implement getDefaultFill()`); + } + + getDescription () { + throw new Error(`${this.constructor.name} did not implement getDescription()`); + } + + getArguments () { + throw new Error(`${this.constructor.name} did not implement getArguments()`); } parse (parser, nodes, lexer) { diff --git a/app/templating/extensions/now-extension.js b/app/templating/extensions/now-extension.js index 431456793..3bdfcd3de 100644 --- a/app/templating/extensions/now-extension.js +++ b/app/templating/extensions/now-extension.js @@ -1,9 +1,33 @@ import BaseExtension from './base/base-extension'; export default class NowExtension extends BaseExtension { - constructor () { - super(); - this.tags = ['now']; + getName () { + return 'Now'; + } + + getTag () { + return 'now'; + } + + getDefaultFill () { + return "now 'iso-8601'"; + } + + getDescription () { + return 'get the current time'; + } + + getArguments () { + return [{ + key: 'format', + label: 'Timestamp Format', + type: 'enum', + options: [ + {name: 'Milliseconds', value: 'millis'}, + {name: 'Unix Timestamp', value: 'unix'}, + {name: 'ISO-8601 Format', value: 'iso-8601'} + ] + }]; } run (context, dateType = 'iso-8601') { diff --git a/app/templating/extensions/response-extension.js b/app/templating/extensions/response-extension.js index f31776d1f..edf1eb796 100644 --- a/app/templating/extensions/response-extension.js +++ b/app/templating/extensions/response-extension.js @@ -6,16 +6,67 @@ import * as models from '../../models'; import BaseExtension from './base/base-extension'; export default class ResponseExtension extends BaseExtension { - constructor () { - super(); - this.tags = ['response']; + getName () { + return 'Response Value'; } - async run (context, field, id, query) { - if (field !== 'body') { + getTag () { + return 'response'; + } + + getDescription () { + return 'reference values from other requests'; + } + + getDefaultFill () { + return "response 'body', '', ''"; + } + + getArguments () { + return [ + { + key: 'field', + label: 'Attribute', + type: 'enum', + options: [ + {name: 'Body – attribute of response body', value: 'body'}, + {name: 'Raw Body – entire response body', value: 'raw'}, + {name: 'Header – value of response header', value: 'header'} + ] + }, + { + key: 'request', + label: 'Request', + type: 'model', + model: 'Request' + }, + { + key: 'filter', + type: 'string', + hide: args => args[0].value === 'raw', + label: args => { + switch (args[0].value) { + case 'body': + return 'Filter (JSONPath or XPath)'; + case 'header': + return 'Header Name'; + default : + return 'Filter'; + } + } + } + ]; + } + + async run (context, field, id, filter) { + if (!['body', 'header', 'raw'].includes(field)) { throw new Error(`Invalid response field ${field}`); } + if (field !== 'raw' && !filter) { + throw new Error(`No ${field} filter specified`); + } + const request = await models.request.getById(id); if (!request) { throw new Error(`Could not find request ${id}`); @@ -24,18 +75,33 @@ export default class ResponseExtension extends BaseExtension { const response = await models.response.getLatestForRequest(id); if (!response) { - throw new Error(`No responses for request ${id}`); + throw new Error('No responses for request'); } - const bodyBuffer = new Buffer(response.body, response.encoding); - const bodyStr = bodyBuffer.toString(); + if (!response.statusCode || response.statusCode < 100) { + throw new Error('No responses for request'); + } - if (query.indexOf('$') === 0) { - return this.matchJSONPath(bodyStr, query); - } else if (query.indexOf('/') === 0) { - return this.matchXPath(bodyStr, query); + const sanitizedFilter = filter.trim(); + + if (field === 'header') { + return this.matchHeader(response.headers, sanitizedFilter); + } else if (field === 'raw') { + const bodyBuffer = new Buffer(response.body, response.encoding); + return bodyBuffer.toString(); + } else if (field === 'body') { + const bodyBuffer = new Buffer(response.body, response.encoding); + const bodyStr = bodyBuffer.toString(); + + if (sanitizedFilter.indexOf('$') === 0) { + return this.matchJSONPath(bodyStr, sanitizedFilter); + } else if (sanitizedFilter.indexOf('/') === 0) { + return this.matchXPath(bodyStr, sanitizedFilter); + } else { + throw new Error(`Invalid format for response query: ${sanitizedFilter}`); + } } else { - throw new Error(`Invalid format for response query: ${query}`); + throw new Error(`Unknown field ${field}`); } } @@ -84,4 +150,16 @@ export default class ResponseExtension extends BaseExtension { return results[0].childNodes.toString(); } + + matchHeader (headers, name) { + const header = headers.find( + h => h.name.toLowerCase() === name.toLowerCase() + ); + + if (!header) { + throw new Error(`No match for header: ${name}`); + } + + return header.value; + } } diff --git a/app/templating/extensions/timestamp-extension.js b/app/templating/extensions/timestamp-extension.js index 40ed12488..bd4595688 100644 --- a/app/templating/extensions/timestamp-extension.js +++ b/app/templating/extensions/timestamp-extension.js @@ -3,7 +3,27 @@ import BaseExtension from './base/base-extension'; export default class TimestampExtension extends BaseExtension { constructor () { super(); - this.tags = ['timestamp']; + this.deprecated = true; + } + + getName () { + return 'Timestamp'; + } + + getDefaultFill () { + return 'timestamp'; + } + + getTag () { + return 'timestamp'; + } + + getDescription () { + return 'generate timestamp in milliseconds'; + } + + getArguments () { + return []; } run (context) { diff --git a/app/templating/extensions/uuid-extension.js b/app/templating/extensions/uuid-extension.js index 62866653c..9cc3287d8 100644 --- a/app/templating/extensions/uuid-extension.js +++ b/app/templating/extensions/uuid-extension.js @@ -2,9 +2,32 @@ import uuid from 'uuid'; import BaseExtension from './base/base-extension'; export default class UuidExtension extends BaseExtension { - constructor () { - super(); - this.tags = ['uuid']; + getName () { + return 'UUID'; + } + + getTag () { + return 'uuid'; + } + + getDefaultFill () { + return "uuid 'v4'"; + } + + getDescription () { + return 'generate v1 or v4 UUIDs'; + } + + getArguments () { + return [{ + key: 'version', + label: 'Version', + type: 'enum', + options: [ + {value: 'v4', name: 'Version 4'}, + {value: 'v1', name: 'Version 1'} + ] + }]; } run (context, uuidType = 'v4') { diff --git a/app/templating/index.js b/app/templating/index.js index ec823f956..2b5098698 100644 --- a/app/templating/index.js +++ b/app/templating/index.js @@ -50,6 +50,22 @@ export function render (text, config = {}) { }); } +export function getTagDefinitions () { + const env = getNunjucks(); + + return Object.keys(env.extensions) + .map(k => env.extensions[k]) + .filter(ext => !ext.deprecated) + .map(ext => ({ + name: ext.getTag(), + displayName: ext.getName(), + defaultFill: ext.getDefaultFill(), + description: ext.getDescription(), + args: ext.getArguments() + })) + .sort((a, b) => a.name > b.name ? 1 : -1); +} + function getNunjucks (variablesOnly) { if (variablesOnly && nunjucksVariablesOnly) { return nunjucksVariablesOnly; diff --git a/app/templating/utils.js b/app/templating/utils.js index b681a1a6e..4dd0a1ff0 100644 --- a/app/templating/utils.js +++ b/app/templating/utils.js @@ -1,3 +1,9 @@ +/** + * Get list of paths to all primitive types in nested object + * @param {object} obj - object to analyse + * @param {String} [prefix] - base path to prefix to all paths + * @returns {Array} - list of paths + */ export function getKeys (obj, prefix = '') { let allKeys = []; @@ -18,3 +24,122 @@ export function getKeys (obj, prefix = '') { return allKeys; } + +/** + * Parse a Nunjucks tag string into a usable abject + * @param {string} tagStr - the template string for the tag + * @return {object} parsed tag data + */ +export function tokenizeTag (tagStr) { + // ~~~~~~~~ // + // Sanitize // + // ~~~~~~~~ // + + const withoutEnds = tagStr + .trim() + .replace(/^{%/, '') + .replace(/%}$/, '') + .trim(); + + const nameMatch = withoutEnds.match(/^[a-zA-Z_$][0-9a-zA-Z_$]*/); + const name = nameMatch ? nameMatch[0] : withoutEnds; + const argsStr = withoutEnds.slice(name.length); + + // ~~~~~~~~~~~~~ // + // Tokenize Args // + // ~~~~~~~~~~~~~ // + + const args = []; + let quotedBy = null; + let currentArg = null; + for (let i = 0; i < argsStr.length; i++) { + const c = argsStr.charAt(i); + + // Do nothing if we're still on a space o comma + if (currentArg === null && c.match(/[\s,]/)) { + continue; + } + + // Start a new single-quoted string + if (currentArg === null && c === "'") { + currentArg = ''; + quotedBy = "'"; + continue; + } + + // Start a new double-quoted string + if (currentArg === null && c === '"') { + currentArg = ''; + quotedBy = '"'; + continue; + } + + // Start a new unquoted string + if (currentArg === null) { + currentArg = c; + quotedBy = null; + continue; + } + + const endQuoted = quotedBy && c === quotedBy; + const endUnquoted = !quotedBy && c === ','; + const finalChar = i === argsStr.length - 1; + const argCompleted = endQuoted || endUnquoted; + + // Append current char to argument + if (!argCompleted && currentArg !== null) { + if (c === '\\') { + // Handle backslashes + i += 1; + currentArg += argsStr.charAt(i); + } else { + currentArg += c; + } + } + + // End current argument + if (currentArg !== null && (argCompleted || finalChar)) { + let arg; + if (quotedBy) { + arg = {type: 'string', value: currentArg, quotedBy}; + } else if (['true', 'false'].includes(currentArg)) { + arg = {type: 'boolean', value: currentArg}; + } else if (currentArg.match(/^\d*\.?\d*$/)) { + arg = {type: 'number', value: currentArg}; + } else if (currentArg.match(/^[a-zA-Z_$][0-9a-zA-Z_$]*$/)) { + arg = {type: 'variable', value: currentArg}; + } else { + arg = {type: 'expression', value: currentArg}; + } + + args.push(arg); + + currentArg = null; + quotedBy = null; + } + } + + return {name, args}; +} + +/** + * Convert a tokenized tag back into a Nunjucks string + * @param {object} tagData - tag data to serialize + * @return {string} tag as a Nunjucks string + */ +export function unTokenizeTag (tagData) { + const args = []; + for (const arg of tagData.args) { + if (arg.type === 'string') { + const q = arg.quotedBy || "'"; + const re = new RegExp(`([^\\\\])${q}`, 'g'); + const str = arg.value.replace(re, `$1\\${q}`); + args.push(`${q}${str}${q}`); + } else { + args.push(arg.value); + } + } + + const argsStr = args.join(', '); + return `{% ${tagData.name} ${argsStr} %}`; +} diff --git a/app/ui/components/codemirror/base-imports.js b/app/ui/components/codemirror/base-imports.js index cf2104261..bf07cb17c 100644 --- a/app/ui/components/codemirror/base-imports.js +++ b/app/ui/components/codemirror/base-imports.js @@ -39,7 +39,7 @@ import 'codemirror/keymap/emacs'; import 'codemirror/keymap/sublime'; import './modes/nunjucks'; import './modes/curl'; -import './extensions/environments-autocomplete'; +import './extensions/autocomplete'; import './extensions/clickable'; import './extensions/nunjucks-tags'; diff --git a/app/ui/components/codemirror/code-editor.js b/app/ui/components/codemirror/code-editor.js index c104af7c8..8a598f114 100644 --- a/app/ui/components/codemirror/code-editor.js +++ b/app/ui/components/codemirror/code-editor.js @@ -13,6 +13,7 @@ import {trackEvent} from '../../../analytics/index'; import {prettifyJson} from '../../../common/prettify'; import {DEBOUNCE_MILLIS} from '../../../common/constants'; import './base-imports'; +import {getTagDefinitions} from '../../../templating/index'; const TAB_KEY = 9; const TAB_SIZE = 4; @@ -400,15 +401,7 @@ class CodeEditor extends PureComponent { }; // Only allow tags if we have variables too - getTags = () => ([ - `uuid 'v4'`, - `uuid 'v1'`, - `now 'ISO-8601'`, - `now 'unix'`, - `now 'millis'`, - `base64 'encode', 'my string'`, - `base64 'decode', 'bXkgc3RyaW5n'` - ]); + getTags = getTagDefinitions; } options.environmentAutocomplete = { getVariables, diff --git a/app/ui/components/codemirror/extensions/environments-autocomplete.js b/app/ui/components/codemirror/extensions/autocomplete.js similarity index 98% rename from app/ui/components/codemirror/extensions/environments-autocomplete.js rename to app/ui/components/codemirror/extensions/autocomplete.js index e1b79fb18..1196ef121 100644 --- a/app/ui/components/codemirror/extensions/environments-autocomplete.js +++ b/app/ui/components/codemirror/extensions/autocomplete.js @@ -294,6 +294,8 @@ function matchSegments (listOfThings, segment, type, limit = -1) { for (const t of listOfThings) { const name = typeof t === 'string' ? t : t.name; const value = typeof t === 'string' ? '' : t.value; + const displayName = t.displayName || name; + const defaultFill = t.defaultFill || name; const matchSegment = segment.toLowerCase(); const matchName = name.toLowerCase(); @@ -312,8 +314,8 @@ function matchSegments (listOfThings, segment, type, limit = -1) { score: name.length, // In case we want to sort by this // CodeMirror - text: name, - displayText: name, + text: defaultFill, + displayText: displayName, render: renderHintMatch, hint: replaceHintMatch }); diff --git a/app/ui/components/codemirror/extensions/nunjucks-tags.js b/app/ui/components/codemirror/extensions/nunjucks-tags.js index 3a1a46779..d6fc16295 100644 --- a/app/ui/components/codemirror/extensions/nunjucks-tags.js +++ b/app/ui/components/codemirror/extensions/nunjucks-tags.js @@ -2,6 +2,8 @@ import CodeMirror from 'codemirror'; import * as misc from '../../../../common/misc'; import NunjucksVariableModal from '../../modals/nunjucks-modal'; import {showModal} from '../../modals/index'; +import {tokenizeTag} from '../../../../templating/utils'; +import {getTagDefinitions} from '../../../../templating/index'; CodeMirror.defineExtension('enableNunjucksTags', function (handleRender) { if (!handleRender) { @@ -222,18 +224,23 @@ async function _updateElementText (render, mark, text) { .trim(); if (tagMatch) { - const tag = tagMatch[1]; + const tagData = tokenizeTag(str); + const tagDefinition = getTagDefinitions().find(d => d.name === tagData.name); - // Don't render other tags because they may be two-parters - // eg. {% for %}...{% endfor %} - const cleaned = cleanedStr.replace(tag, '').trim(); - const short = cleaned.length > 30 ? `${cleaned.slice(0, 30)}…` : cleaned; - el.innerHTML = ` ${short}`.trim(); - - if (['response', 'res', 'uuid', 'timestamp', 'now', 'base64'].includes(tag)) { + if (tagDefinition) { // Try rendering these so we can show errors if needed + const firstArg = tagDefinition.args[0]; + if (firstArg && firstArg.type === 'enum') { + const argData = tagData.args[0]; + const option = firstArg.options.find(d => d.value === argData.value); + el.innerHTML = `${tagDefinition.displayName} ⇒ ${option.name}`; + } else { + el.innerHTML = `${tagData.name}`; + } el.title = await render(str); } else { + el.innerHTML = `${cleanedStr}`; + el.title = 'Unrecognized tag'; el.setAttribute('data-ignore', 'on'); } } else { diff --git a/app/ui/components/modals/nunjucks-modal.js b/app/ui/components/modals/nunjucks-modal.js index 700d15a54..7a2004921 100644 --- a/app/ui/components/modals/nunjucks-modal.js +++ b/app/ui/components/modals/nunjucks-modal.js @@ -56,7 +56,7 @@ class NunjucksModal extends PureComponent { } render () { - const {handleRender, handleGetRenderContext, uniqueKey} = this.props; + const {handleRender, handleGetRenderContext, uniqueKey, workspace} = this.props; const {defaultTemplate} = this.state; let editor = null; @@ -78,21 +78,22 @@ class NunjucksModal extends PureComponent { onChange={this._handleTemplateChange} defaultValue={defaultTemplate} handleRender={handleRender} + workspace={workspace} /> ); } return ( -
- Edit {title} - + Edit {title} + + {editor} - - - - - + +
+ + +
); } @@ -101,7 +102,8 @@ class NunjucksModal extends PureComponent { NunjucksModal.propTypes = { uniqueKey: PropTypes.string.isRequired, handleRender: PropTypes.func.isRequired, - handleGetRenderContext: PropTypes.func.isRequired + handleGetRenderContext: PropTypes.func.isRequired, + workspace: PropTypes.object.isRequired }; export default NunjucksModal; diff --git a/app/ui/components/templating/tag-editor.js b/app/ui/components/templating/tag-editor.js index 96d816dc8..647a9ba8f 100644 --- a/app/ui/components/templating/tag-editor.js +++ b/app/ui/components/templating/tag-editor.js @@ -1,45 +1,91 @@ import React, {PropTypes, PureComponent} from 'react'; import autobind from 'autobind-decorator'; -import Input from '../codemirror/one-line-editor'; - -const TAGS = [ - {name: `uuid 'v4'`}, - {name: `uuid 'v1'`}, - {name: `now 'ISO-8601'`}, - {name: `now 'unix'`}, - {name: `now 'millis'`}, - {name: `base64 'encode'`, suffix: `, 'my string'`}, - {name: `base64 'decode'`, suffix: `, 'bXkgc3RyaW5n'`} - // 'response' -]; - -const CUSTOM_TAG_VALUE = `{% custom 'tag' %}`; +import clone from 'clone'; +import * as templating from '../../../templating'; +import * as templateUtils from '../../../templating/utils'; +import * as db from '../../../common/database'; +import {types as allModelTypes} from '../../../models'; +import HelpTooltip from '../help-tooltip'; @autobind class TagEditor extends PureComponent { constructor (props) { super(props); - const inner = props.defaultValue - .replace(/\s*%}$/, '') - .replace(/^{%\s*/, ''); + const activeTagData = templateUtils.tokenizeTag(props.defaultValue); + + const tagDefinitions = templating.getTagDefinitions(); + const activeTagDefinition = tagDefinitions.find(d => d.name === activeTagData.name); + + // Edit tags raw that we don't know about + if (!activeTagDefinition) { + activeTagData.rawValue = props.defaultValue; + } - const value = `{% ${inner} %}`; this.state = { - tags: TAGS, - value: value, - selectValue: value, + activeTagData, + activeTagDefinition, + loadingModels: true, + models: {}, preview: '', error: '' }; } - componentWillMount () { - this._update(this.state.value, true); + async componentWillMount () { + await this._refreshModels(this.props.workspace); + await this._update(this.state.activeTagDefinition, this.state.activeTagData, true); + } + + componentWillReceiveProps (nextProps) { + const {workspace} = nextProps; + + if (this.props.workspace._id !== workspace._id) { + this._refreshModels(workspace); + } + } + + async _refreshModels (workspace) { + const models = {}; + for (const type of allModelTypes()) { + models[type] = []; + } + + for (const doc of await db.withDescendants(workspace)) { + models[doc.type].push(doc); + } + + this.setState({models, loadingModels: false}); + } + + _updateArg (argValue, argIndex) { + const {activeTagData, activeTagDefinition} = this.state; + + const tagData = clone(activeTagData); + tagData.args[argIndex].value = argValue; + + this._update(activeTagDefinition, tagData, false); } _handleChange (e) { - this._update(e.target.value, false, e.target.value); + const parent = e.target.parentNode; + const argIndex = parent.getAttribute('data-arg-index'); + return this._updateArg(e.target.value, argIndex); + } + + _handleChangeCustomArg (e) { + const {activeTagData, activeTagDefinition} = this.state; + + const tagData = clone(activeTagData); + tagData.rawValue = e.target.value; + + this._update(activeTagDefinition, tagData, false); + } + + _handleChangeTag (e) { + const name = e.target.value; + const tagDefinition = templating.getTagDefinitions().find(d => d.name === name); + this._update(tagDefinition, false); } _setSelectRef (n) { @@ -51,14 +97,35 @@ class TagEditor extends PureComponent { }, 100); } - async _update (value, noCallback = false, selectValue = null) { + async _update (tagDefinition, tagData, noCallback = false) { const {handleRender} = this.props; let preview = ''; let error = ''; + let activeTagData = tagData; + if (!activeTagData && tagDefinition) { + activeTagData = { + name: tagDefinition.name, + rawValue: null, + args: tagDefinition.args.map(arg => { + if (arg.type === 'enum') { + return {type: 'string', value: arg.options[0].value}; + } else { + return {type: 'string', value: ''}; + } + }) + }; + } else if (!activeTagData && !tagDefinition) { + activeTagData = {name: 'custom', rawValue: "{% tag 'arg1', 'arg2' %}"}; + } + + let template; try { - preview = await handleRender(value, true); + template = typeof activeTagData.rawValue === 'string' + ? activeTagData.rawValue + : templateUtils.unTokenizeTag(activeTagData); + preview = await handleRender(template, true); } catch (err) { error = err.message; } @@ -66,53 +133,129 @@ class TagEditor extends PureComponent { const isMounted = !!this._select; if (isMounted) { this.setState({ + activeTagData, preview, error, - value, - selectValue: selectValue || this.state.selectValue + activeTagDefinition: tagDefinition }); } // Call the callback if we need to if (!noCallback) { - this.props.onChange(value); + this.props.onChange(template); } } + renderArgString (value, placeholder) { + return ( + + ); + } + + renderArgEnum (value, options) { + return ( + + ); + } + + renderArgModel (value, modelType) { + const {models, loadingModels} = this.state; + const docs = models[modelType] || []; + const id = value || 'n/a'; + + return ( + + ); + } + + renderArg (argDefinition, args, argIndex) { + // Decide whether or not to show it + if (argDefinition.hide && argDefinition.hide(args)) { + return null; + } + + const argData = args[argIndex]; + const value = argData.value; + + let argInput; + if (argDefinition.type === 'string') { + const {placeholder} = argDefinition; + argInput = this.renderArgString(value, placeholder); + } else if (argDefinition.type === 'enum') { + const {options} = argDefinition; + argInput = this.renderArgEnum(value, options); + } else if (argDefinition.type === 'model') { + const {model} = argDefinition; + argInput = this.renderArgModel(value, model); + } else { + return null; + } + + const label = argDefinition.label; + const labelStr = typeof label === 'function' ? label(args) : label; + + return ( +
+ +
+ ); + } + render () { - const {error, value, preview, tags, selectValue} = this.state; - const isFound = !!tags.find(v => value === `{% ${v.name} %}`); - const isFlexible = value.indexOf('{% base64') === 0; - const isCustom = !isFound && !isFlexible; + const {error, preview, activeTagDefinition, activeTagData} = this.state; return (
-
- {(!isFound || isFlexible) && ( + {activeTagDefinition && activeTagDefinition.args.map((argDefinition, index) => ( + this.renderArg(argDefinition, activeTagData.args, index) + ))} + {!activeTagDefinition && (
- +
)}
@@ -131,7 +274,8 @@ class TagEditor extends PureComponent { TagEditor.propTypes = { handleRender: PropTypes.func.isRequired, defaultValue: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired + onChange: PropTypes.func.isRequired, + workspace: PropTypes.object.isRequired }; export default TagEditor; diff --git a/app/ui/components/templating/variable-editor.js b/app/ui/components/templating/variable-editor.js index 882560cb4..b968f3158 100644 --- a/app/ui/components/templating/variable-editor.js +++ b/app/ui/components/templating/variable-editor.js @@ -1,6 +1,5 @@ import React, {PropTypes, PureComponent} from 'react'; import autobind from 'autobind-decorator'; -import Input from '../codemirror/one-line-editor'; @autobind class VariableEditor extends PureComponent { @@ -84,13 +83,7 @@ class VariableEditor extends PureComponent {
{isOther && (
- +
)}
diff --git a/app/ui/components/wrapper.js b/app/ui/components/wrapper.js index 7e9bc4b2c..4fa0b0b6c 100644 --- a/app/ui/components/wrapper.js +++ b/app/ui/components/wrapper.js @@ -386,6 +386,7 @@ class Wrapper extends PureComponent { ref={registerModal} handleRender={handleRender} handleGetRenderContext={handleGetRenderContext} + workspace={activeWorkspace} /> { for (const change of changes) { const [ @@ -688,6 +688,9 @@ class App extends PureComponent { ipcRenderer.on('toggle-sidebar', this._handleToggleSidebar); process.nextTick(() => ipcRenderer.send('app-ready')); + + // handle this + this._handleToggleMenuBar(this.props.settings.autoHideMenuBar); } componentWillUnmount () { @@ -751,6 +754,7 @@ App.propTypes = { paneWidth: PropTypes.number.isRequired, paneHeight: PropTypes.number.isRequired, handleCommand: PropTypes.func.isRequired, + settings: PropTypes.object.isRequired, activeWorkspace: PropTypes.shape({ _id: PropTypes.string.isRequired }).isRequired, diff --git a/app/ui/css/components/modal.less b/app/ui/css/components/modal.less index cbc507c34..4013be22c 100644 --- a/app/ui/css/components/modal.less +++ b/app/ui/css/components/modal.less @@ -34,15 +34,19 @@ .modal__content__wrapper { top: 1rem; width: @modal-width; + height: 100%; max-width: 100%; max-height: 100%; margin: auto; position: relative; transform: scale(0.95); transition: transform 150ms ease-out, top 200ms ease-out; + + // We want pointer events to pass through to the backdrop so we can close it + pointer-events: none; } - &.modal--fixed-height .modal__content__wrapper { + &.modal--fixed-height .modal__content { height: 100%; } @@ -65,11 +69,13 @@ box-shadow: 0 0 2rem 0 rgba(0, 0, 0, 0.2); max-width: 100%; max-height: 100%; - height: 100%; width: 100%; background-color: var(--color-bg); color: var(--color-font); border: 1px solid @hl-sm; + + // Since we disable pointer-events on the parent, re-enable them here + pointer-events: auto; } .modal__header { diff --git a/package.json b/package.json index 18962ed4b..cb72bc041 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "start-hot": "npm run build-main && cross-env HOT=1 INSOMNIA_ENV=development electron app", "build-main": "cross-env NODE_ENV=development webpack --config ./webpack/webpack.config.electron.babel.js", "hot-server": "webpack-dev-server --config ./webpack/webpack.config.development.babel.js", - "dev": "concurrently --kill-others 'npm run hot-server' 'npm run start-hot'", + "dev": "concurrently --kill-others \"npm run hot-server\" \"npm run start-hot\"", "build:clean": "rm -rf ./build/* && rm -rf ./dist/* && mkdirp ./build", "build:renderer": "cross-env NODE_ENV=production webpack --config ./webpack/webpack.config.production.babel.js", "build:main": "cross-env NODE_ENV=production webpack --config ./webpack/webpack.config.electron.babel.js",