diff --git a/app/templating/__tests__/utils.test.js b/app/templating/__tests__/utils.test.js index 0b2428dd8..fbb4e35e9 100644 --- a/app/templating/__tests__/utils.test.js +++ b/app/templating/__tests__/utils.test.js @@ -153,7 +153,7 @@ describe('tokenizeTag()', () => { describe('unTokenizeTag()', () => { beforeEach(globalBeforeEach); - it('untokenizes a tag', () => { + it('handles the default case', () => { const tagStr = `{% name bar, "baz \\"qux\\"" , 1 + 5, 'hi' %}`; const tagData = utils.tokenizeTag(tagStr); @@ -161,4 +161,18 @@ describe('unTokenizeTag()', () => { expect(result).toEqual(`{% name bar, "baz \\"qux\\"", 1 + 5, 'hi' %}`); }); + + it('fixes missing quotedBy attribute', () => { + const tagData = { + name: 'name', + args: [ + {type: 'file', value: 'foo/bar/baz'}, + {type: 'model', value: 'foo'} + ] + }; + + const result = utils.unTokenizeTag(tagData); + + expect(result).toEqual(`{% name 'foo/bar/baz', 'foo' %}`); + }); }); diff --git a/app/templating/extensions/__tests__/file-extension.test.js b/app/templating/extensions/__tests__/file-extension.test.js new file mode 100644 index 000000000..b3a7cc075 --- /dev/null +++ b/app/templating/extensions/__tests__/file-extension.test.js @@ -0,0 +1,32 @@ +import path from 'path'; +import * as templating from '../../index'; +import {globalBeforeEach} from '../../../__jest__/before-each'; + +function assertTemplate (txt, context, expected) { + return async function () { + const result = await templating.render(txt, {context}); + expect(result).toMatch(expected); + }; +} + +function assertTemplateFails (txt, context, expected) { + return async function () { + try { + await templating.render(txt, {context}); + fail(`Render should have thrown ${expected}`); + } catch (err) { + expect(err.message).toContain(expected); + } + }; +} + +describe('FileExtension', () => { + beforeEach(globalBeforeEach); + const ctx = {path: path.resolve(__dirname, path.join('./test.txt'))}; + const escapedPath = ctx.path.replace(/\\/g, '\\\\'); + it('reads from string', assertTemplate(`{% file "${escapedPath}" %}`, ctx, 'Hello World')); + it('reads a file correctly', assertTemplate('{% file path %}', ctx, 'Hello World!')); + it('fails on missing file', assertTemplateFails('{% file "/foo" %}', ctx, `ENOENT: no such file or directory, open '${path.resolve('/foo')}'`)); + it('fails on no 2nd param', assertTemplateFails('{% file %}', ctx, 'No file selected')); + it('fails on unknown variable', assertTemplateFails('{% file foo %}', ctx, 'No file selected')); +}); diff --git a/app/templating/extensions/__tests__/test.txt b/app/templating/extensions/__tests__/test.txt new file mode 100644 index 000000000..c57eff55e --- /dev/null +++ b/app/templating/extensions/__tests__/test.txt @@ -0,0 +1 @@ +Hello World! \ No newline at end of file diff --git a/app/templating/extensions/file-extension.js b/app/templating/extensions/file-extension.js new file mode 100644 index 000000000..eef25db0e --- /dev/null +++ b/app/templating/extensions/file-extension.js @@ -0,0 +1,21 @@ +// @flow +import fs from 'fs'; + +export default { + name: 'file', + displayName: 'File', + description: 'read contents from a file', + args: [ + { + displayName: 'Choose File', + type: 'file' + } + ], + run (context: Object, path: string): string { + if (!path) { + throw new Error('No file selected'); + } + + return fs.readFileSync(path, 'utf8'); + } +}; diff --git a/app/templating/extensions/index.js b/app/templating/extensions/index.js index a760f7c50..654937671 100644 --- a/app/templating/extensions/index.js +++ b/app/templating/extensions/index.js @@ -3,7 +3,8 @@ import * as plugins from '../../plugins/index'; import timestampExtension from './timestamp-extension'; import uuidExtension from './uuid-extension'; -import NowExtension from './now-extension'; +import nowExtension from './now-extension'; +import fileExtension from './file-extension'; import responseExtension from './response-extension'; import base64Extension from './base-64-extension'; import requestExtension from './request-extension'; @@ -29,28 +30,32 @@ export type PluginArgumentEnumOption = { } export type PluginArgumentEnum = PluginArgumentBase & { - type: 'enum'; + type: 'enum', options: Array, defaultValue?: PluginArgumentValue }; export type PluginArgumentModel = PluginArgumentBase & { - type: 'model'; + type: 'model', model: string, defaultValue?: string }; export type PluginArgumentString = PluginArgumentBase & { - type: 'string'; + type: 'string', placeholder?: string, defaultValue?: string }; export type PluginArgumentBoolean = PluginArgumentBase & { - type: 'boolean'; + type: 'boolean', defaultValue?: boolean }; +export type PluginArgumentFile = PluginArgumentBase & { + type: 'file' +}; + export type PluginArgumentNumber = PluginArgumentBase & { type: 'number'; placeholder?: string, @@ -62,6 +67,7 @@ export type PluginArgument = | PluginArgumentModel | PluginArgumentString | PluginArgumentBoolean + | PluginArgumentFile | PluginArgumentNumber; export type PluginTemplateTagContext = { @@ -90,9 +96,10 @@ export type PluginTemplateTag = { const DEFAULT_EXTENSIONS: Array = [ timestampExtension, - NowExtension, + nowExtension, uuidExtension, base64Extension, + fileExtension, requestExtension, responseExtension ]; diff --git a/app/templating/utils.js b/app/templating/utils.js index d5b72e906..5bb42e392 100644 --- a/app/templating/utils.js +++ b/app/templating/utils.js @@ -145,7 +145,7 @@ export function tokenizeTag (tagStr: string): NunjucksParsedTag { export function unTokenizeTag (tagData: NunjucksParsedTag): string { const args = []; for (const arg of tagData.args) { - if (arg.type === 'string') { + if (['string', 'model', 'file'].includes(arg.type)) { const q = arg.quotedBy || "'"; const re = new RegExp(`([^\\\\])${q}`, 'g'); const str = arg.value.toString().replace(re, `$1\\${q}`); @@ -174,6 +174,8 @@ export function getDefaultFill (name: string, args: Array) case 'boolean': return argDefinition.defaultValue ? 'true' : 'false'; case 'string': + case 'file': + case 'model': return `'${(argDefinition.defaultValue: any) || ''}'`; default: return "''"; diff --git a/app/ui/components/base/file-input-button.js b/app/ui/components/base/file-input-button.js index f34766af1..c47f59b72 100644 --- a/app/ui/components/base/file-input-button.js +++ b/app/ui/components/base/file-input-button.js @@ -1,20 +1,32 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; +// @flow +import * as React from 'react'; import autobind from 'autobind-decorator'; import {basename as pathBasename} from 'path'; import {remote} from 'electron'; +type Props = { + // Required + onChange: Function, + path: string, + + // Optional + showFileName?: boolean, + showFileIcon?: boolean, + name?: string +}; + @autobind -class FileInputButton extends PureComponent { +class FileInputButton extends React.PureComponent { + _button: ?HTMLButtonElement; focus () { - this._button.focus(); + this._button && this._button.focus(); } focusEnd () { - this._button.focus(); + this._button && this._button.focus(); } - _setRef (n) { + _setRef (n: ?HTMLButtonElement) { this._button = n; } @@ -52,15 +64,4 @@ class FileInputButton extends PureComponent { } } -FileInputButton.propTypes = { - // Required - onChange: PropTypes.func.isRequired, - path: PropTypes.string.isRequired, - - // Optional - showFileName: PropTypes.bool, - showFileIcon: PropTypes.bool, - name: PropTypes.string -}; - export default FileInputButton; diff --git a/app/ui/components/templating/tag-editor.js b/app/ui/components/templating/tag-editor.js index cf19e7d29..89eba84db 100644 --- a/app/ui/components/templating/tag-editor.js +++ b/app/ui/components/templating/tag-editor.js @@ -15,6 +15,7 @@ import type {BaseModel} from '../../../models/index'; import type {Workspace} from '../../../models/workspace'; import type {PluginArgumentEnumOption} from '../../../templating/extensions/index'; import {Dropdown, DropdownButton, DropdownDivider, DropdownItem} from '../base/dropdown/index'; +import FileInputButton from '../base/file-input-button'; type Props = { handleRender: Function, @@ -116,7 +117,8 @@ class TagEditor extends React.PureComponent { _updateArg ( argValue: string | number | boolean, argIndex: number, - forceNewType: string | null = null + forceNewType: string | null = null, + patch: Object = {} ) { const {tagDefinitions, activeTagData, activeTagDefinition} = this.state; @@ -130,6 +132,11 @@ class TagEditor extends React.PureComponent { return; } + // Fix strings + if (typeof argValue === 'string') { + argValue = argValue.replace(/\\/g, '\\\\'); + } + // Ensure all arguments exist const defaultArgs = this._getDefaultTagData(activeTagDefinition).args; for (let i = 0; i < defaultArgs.length; i++) { @@ -154,7 +161,7 @@ class TagEditor extends React.PureComponent { // Update type if we need to if (forceNewType) { // Ugh, what a hack (because it's enum) - (argData: any).type = forceNewType; + Object.assign((argData: any), {type: forceNewType}, patch); } this._update(tagDefinitions, activeTagDefinition, tagData, false); @@ -182,10 +189,14 @@ class TagEditor extends React.PureComponent { const initialType = argDef ? argDef.type : 'string'; const variable = variables.find(v => v.name === existingValue); const value = variable ? variable.value : ''; - return this._updateArg(value, argIndex, initialType); + return this._updateArg(value, argIndex, initialType, {quotedBy: "'"}); } } + _handleChangeFile (path: string, argIndex: number) { + return this._updateArg(path, argIndex); + } + _handleChange (e: SyntheticEvent, forceVariable: boolean = false) { const parent = e.currentTarget.parentNode; let argIndex = -1; @@ -315,6 +326,9 @@ class TagEditor extends React.PureComponent { return (