Better Nunjucks Tag Editor (#234)

* Helper to tokenize Nunjucks tag

* More granular types

* Add tag definitions

* Improve editor to be more WYSIWYG

* Fixed tests

* Added raw response tag
This commit is contained in:
Gregory Schier 2017-05-23 15:05:31 -07:00 committed by GitHub
parent 461a36dec6
commit 79b3f3fe7c
22 changed files with 830 additions and 136 deletions

View File

@ -1,4 +0,0 @@
node_modules
build
dist
coverage

View File

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

View File

@ -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: '<foo><bar>Hello World!</bar></foo>'
});
@ -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: '<hi></hi></sstr>'});
await models.response.create({
parentId: request._id,
statusCode: 200,
body: '<hi></hi></sstr>'
});
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: '<foo><bar>Hello World!</bar></foo>'
});
@ -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: '<foo><bar>Hello World!</bar></foo>'
});
@ -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: '<foo><bar>Hello World!</bar><bar>And again!</bar></foo>'
});
@ -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!');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)}&hellip;` : cleaned;
el.innerHTML = `<label>${tag}</label> ${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 = `<label></label>${tagDefinition.displayName} &rArr; ${option.name}`;
} else {
el.innerHTML = `<label></label>${tagData.name}`;
}
el.title = await render(str);
} else {
el.innerHTML = `<label></label>${cleanedStr}`;
el.title = 'Unrecognized tag';
el.setAttribute('data-ignore', 'on');
}
} else {

View File

@ -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 (
<Modal ref={this._setModalRef} onHide={this._handleModalHide} key={uniqueKey}>
<form onSubmit={this._handleSubmit}>
<ModalHeader>Edit {title}</ModalHeader>
<ModalBody className="pad" key={defaultTemplate}>
<ModalHeader>Edit {title}</ModalHeader>
<ModalBody className="pad" key={defaultTemplate}>
<form onSubmit={this._handleSubmit}>
{editor}
</ModalBody>
<ModalFooter>
<button type="submit" className="btn">Done</button>
</ModalFooter>
</form>
</form>
</ModalBody>
<ModalFooter>
<button className="btn" onClick={this.hide}>Done</button>
</ModalFooter>
</Modal>
);
}
@ -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;

View File

@ -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 (
<input
type="text"
defaultValue={value || ''}
placeholder={placeholder}
onChange={this._handleChange}
/>
);
}
renderArgEnum (value, options) {
return (
<select value={value} onChange={this._handleChange}>
{options.map(option => {
return (
<option key={option.value} value={option.value}>
{option.name}
</option>
);
})}
</select>
);
}
renderArgModel (value, modelType) {
const {models, loadingModels} = this.state;
const docs = models[modelType] || [];
const id = value || 'n/a';
return (
<select value={id} disabled={loadingModels} onChange={this._handleChange}>
<option value="n/a">-- Select Item --</option>
{docs.map(m => (
<option key={m._id} value={m._id}>{m.name}</option>
))}
</select>
);
}
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 (
<div key={argDefinition.key} className="form-control form-control--outlined">
<label>
{labelStr || argDefinition.key}
{argDefinition.help && (
<HelpTooltip>{argDefinition.help}</HelpTooltip>
)}
<div data-arg-index={argIndex}>
{argInput}
</div>
</label>
</div>
);
}
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 (
<div>
<div className="form-control form-control--outlined">
<label>Template Function
<label>Function to Perform
<select ref={this._setSelectRef}
onChange={this._handleChange}
value={isCustom ? CUSTOM_TAG_VALUE : selectValue}>
{tags.map((t, i) => (
<option key={`${i}::${t.name}`} value={`{% ${t.name}${t.suffix || ''} %}`}>
{t.name}
onChange={this._handleChangeTag}
value={activeTagDefinition ? activeTagDefinition.name : ''}>
{templating.getTagDefinitions().map((tagDefinition, i) => (
<option key={`${i}::${tagDefinition.name}`} value={tagDefinition.name}>
{tagDefinition.displayName} {tagDefinition.description}
</option>
))}
<option value={`{% custom 'tag' %}`}>
-- Custom --
</option>
<option value="">-- Custom --</option>
</select>
</label>
</div>
{(!isFound || isFlexible) && (
{activeTagDefinition && activeTagDefinition.args.map((argDefinition, index) => (
this.renderArg(argDefinition, activeTagData.args, index)
))}
{!activeTagDefinition && (
<div className="form-control form-control--outlined">
<Input
key={selectValue}
forceEditor
mode="nunjucks"
type="text"
defaultValue={value}
onChange={this._update}
/>
<label>Custom
<input type="text"
defaultValue={activeTagData.rawValue}
onChange={this._handleChangeCustomArg}/>
</label>
</div>
)}
<div className="form-control form-control--outlined">
@ -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;

View File

@ -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 {
</div>
{isOther && (
<div className="form-control form-control--outlined">
<Input
forceEditor
mode="nunjucks"
type="text"
defaultValue={value}
onChange={this._update}
/>
<input type="text" defaultValue={value} onChange={this._handleChange} />
</div>
)}
<div className="form-control form-control--outlined">

View File

@ -386,6 +386,7 @@ class Wrapper extends PureComponent {
ref={registerModal}
handleRender={handleRender}
handleGetRenderContext={handleGetRenderContext}
workspace={activeWorkspace}
/>
<WorkspaceSettingsModal
ref={registerModal}

View File

@ -562,10 +562,12 @@ class App extends PureComponent {
}
}
async _handleToggleMenuBar (hide) {
_handleToggleMenuBar (hide) {
let win = remote.BrowserWindow.getFocusedWindow();
win.setAutoHideMenuBar(hide);
win.setMenuBarVisibility(!hide);
if (win.isMenuBarAutoHide() === hide) {
win.setAutoHideMenuBar(hide);
win.setMenuBarVisibility(!hide);
}
}
async _handleToggleSidebar () {
@ -629,8 +631,6 @@ class App extends PureComponent {
trackEvent('General', 'Launched', getAppVersion(), {nonInteraction: true});
}
this._handleToggleMenuBar(this.props.settings.autoHideMenuBar);
db.onChange(async changes => {
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,

View File

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

View File

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