diff --git a/.eslintrc.js b/.eslintrc.js index 2c54614d7..3a488b6d9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -62,7 +62,7 @@ module.exports = { 'no-new-require': ERROR, 'no-trailing-spaces': ERROR, 'no-unsafe-negation': ERROR, - 'no-unused-vars': [ ERROR, { varsIgnorePattern: '^_' } ], + 'no-unused-vars': [ ERROR, { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } ], 'no-useless-rename': WARN, 'no-var': WARN, 'object-curly-spacing': [ ERROR, 'always' ], diff --git a/app/app.js b/app/app.js index 9dd54a94b..da64e3c86 100644 --- a/app/app.js +++ b/app/app.js @@ -63,6 +63,7 @@ window["Webpack"] = { "components/shared/Emojify": require("./components/shared/Emojify").default, "components/shared/FormMarkdownEditorField": require("./components/shared/FormMarkdownEditorField").default, "components/shared/FormYAMLEditorField": require("./components/shared/FormYAMLEditorField").default, + "components/shared/FormConditionEditorField": require("./components/shared/FormConditionEditorField").default, "components/shared/FormRadioGroup": require("./components/shared/FormRadioGroup").default, "components/shared/FormTextarea": require("./components/shared/FormTextarea").default, "components/shared/FormTextField": require("./components/shared/FormTextField").default, diff --git a/app/components/shared/FormConditionEditorField/codemirror.js b/app/components/shared/FormConditionEditorField/codemirror.js new file mode 100644 index 000000000..78e219bee --- /dev/null +++ b/app/components/shared/FormConditionEditorField/codemirror.js @@ -0,0 +1,61 @@ +import CodeMirror from 'codemirror'; + +import 'codemirror/addon/hint/show-hint'; +import 'codemirror/addon/hint/show-hint.css'; +import 'codemirror/addon/comment/comment'; +import 'codemirror/addon/edit/matchbrackets'; +import 'codemirror/addon/edit/closebrackets'; +import 'codemirror/keymap/sublime'; +import 'codemirror/addon/mode/simple'; +import 'codemirror/addon/lint/lint'; +import 'codemirror/addon/lint/lint.css'; +import 'app/css/codemirror.css'; + +CodeMirror.defineSimpleMode("conditional", { + // The start state contains the rules that are intially used + start: [ + { regex: /\/\/.*/, token: "comment" }, + { regex: /'(?:[^\\']|\\.)*?(?:'|$)/, token: "string" }, + { regex: /"/, token: "string", push: "quotestring" }, + { regex: /\/(?:[^\\/]|\\.)+\/[a-z]*/, token: "regex" }, + { regex: /[().,]/, token: "punctuation" }, + { regex: /[!=~%|&]+/, token: "operator" }, + { regex: /[a-zA-Z_][a-zA-Z0-9_]*/, token: "variable" }, + { regex: /[0-9]+(?:\.[0-9]+)?/, token: "number" }, + { regex: /\$[a-zA-Z_][a-zA-Z0-9_]*/, token: "variable-2" }, + { regex: /\$\{/, token: "variable-3", push: "shellbrace" } + ], + + quotestring: [ + { regex: /"/, token: "string", pop: true }, + { regex: /\$[a-zA-Z_][a-zA-Z0-9_]*/, token: "variable-2" }, + { regex: /\$\{/, token: "variable-3", push: "shellbrace" }, + { regex: /\$\$|\\[0-7]{1,3}|\\x[0-9a-fA-F]{2}|\\./, token: "string-2" }, + { regex: /[^"$\\]+|./, token: "string" } + ], + + shellbrace: [ + { regex: /\}/, token: "variable-3", pop: true }, + { regex: /:?[+-]/, token: "variable-3", next: "shellstring" }, + { regex: /[:?]/, token: "variable-3" }, + { regex: /[0-9]+/, token: "number" }, + { regex: /[a-zA-Z_][a-zA-Z0-9_]*/, token: "variable-2" } + ], + + shellstring: [ + { regex: /\}/, token: "variable-3", pop: true }, + { regex: /\$[a-zA-Z_][a-zA-Z0-9_]*/, token: "variable-2" }, + { regex: /\$\{/, token: "variable-3", push: "shellbrace" }, + { regex: /'(?:[^\\']|\\.)*?(?:'|$)/, token: "string" }, + { regex: /"/, token: "string", push: "quotestring" }, + { regex: /\$\$|\\[0-7]{1,3}|\\x[0-9a-fA-F]{2}|\\./, token: "string-2" }, + { regex: /[^"'$\\}]+|./, token: "string-3" } + ], + + meta: { + dontIndentStates: ["comment"], + lineComment: "//" + } +}); + +export default CodeMirror; diff --git a/app/components/shared/FormConditionEditorField/index.js b/app/components/shared/FormConditionEditorField/index.js new file mode 100644 index 000000000..28895de94 --- /dev/null +++ b/app/components/shared/FormConditionEditorField/index.js @@ -0,0 +1,232 @@ +// @flow + +import React from 'react'; +import PropTypes from 'prop-types'; +import Loadable from 'react-loadable'; + +import Spinner from 'app/components/shared/Spinner'; + +type CodeMirrorInstance = { + showHint: ({}) => void, + on: (string, (...any) => void) => mixed, + off: (string, (...any) => void) => mixed, + getValue: () => string, + execCommand: (string) => void, + toTextArea: () => HTMLTextAreaElement +}; + +const CODEMIRROR_BUFFER = 8; +const CODEMIRROR_LINE_HEIGHT = 17; +const CODEMIRROR_MIN_HEIGHT = CODEMIRROR_BUFFER + CODEMIRROR_LINE_HEIGHT; + +const CODEMIRROR_CONFIG = { + lineNumbers: true, + tabSize: 2, + mode: 'conditional', + keyMap: 'sublime', + theme: 'conditional', + autoCloseBrackets: true, + matchBrackets: true, + showCursorWhenSelecting: true, + viewportMargin: Infinity, + gutters: ['CodeMirror-linenumbers'], + extraKeys: { + 'Ctrl-Left': 'goSubwordLeft', + 'Ctrl-Right': 'goSubwordRight', + 'Alt-Left': 'goGroupLeft', + 'Alt-Right': 'goGroupRight', + 'Cmd-Space': (cm) => cm.showHint({ completeSingle: true }), + 'Ctrl-Space': (cm) => cm.showHint({ completeSingle: true }), + 'Alt-Space': (cm) => cm.showHint({ completeSingle: true }) + } +}; + +const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z_]$/; + +type Props = { + name: string, + value: string, + autocompleteWords: Array, + fetchParseErrors: (string, (Array<{ from: Array, to: Array, message: string }>) => void) => void, + CodeMirror: CodeMirror +}; + +type CodeMirror = { + fromTextArea: (HTMLTextAreaElement, {}) => CodeMirrorInstance, + Pos: (number, number) => number +}; + +type ReactLoadableLoadingProps = { + value: string, + error?: string, + pastDelay?: boolean +}; + +class FormConditionEdtiorField extends React.Component { + editor: ?CodeMirrorInstance + input: ?HTMLTextAreaElement + + static propTypes = { + name: PropTypes.string, + value: PropTypes.string, + autocompleteWords: PropTypes.array, + CodeMirror: PropTypes.func + }; + + componentDidMount() { + const { CodeMirror } = this.props; + + const config = { + ...CODEMIRROR_CONFIG, + hintOptions: { + closeOnUnfocus: false, + completeSingle: false, + "hint": (cm, _options) => { + return new Promise((accept) => { + const cursor = cm.getCursor(); + const line = cm.getLine(cursor.line); + let start = cursor.ch; + let end = cursor.ch; + while (start && /\w/.test(line.charAt(start - 1))) { --start; } + while (end < line.length && /\w/.test(line.charAt(end))) { ++end; } + const word = line.slice(start, end).toLowerCase(); + + if (word.length < 2) { + return accept(null); + } + + const suggestions = []; + for (const candidate of this.props.autocompleteWords) { + if (candidate.toLowerCase().indexOf(word) !== -1) { + suggestions.push({ + text: candidate, + render: (el, self, data) => { + const labelElement = document.createElement("DIV"); + labelElement.className = "monospace"; + labelElement.appendChild(document.createTextNode(data.text)); + + const descriptionElement = document.createElement("DIV"); + descriptionElement.className = "system dark-gray"; + descriptionElement.appendChild(document.createTextNode("Very important information")); + + const suggestionElement = document.createElement("DIV"); + suggestionElement.appendChild(labelElement); + suggestionElement.appendChild(descriptionElement); + + el.appendChild(suggestionElement); + } + }); + } + } + + if (suggestions.length === 0) { + return accept(null); + } + + return accept({ + list: suggestions, + from: CodeMirror.Pos(cursor.line, start), + to: CodeMirror.Pos(cursor.line, end) + }); + }); + } + }, + lint: { + "getAnnotations": (text, updateLinting, _options, _cm) => { + this.props.fetchParseErrors(text, (parseErrors) => { + const collected = []; + for (const err of parseErrors) { + collected.push({ + from: CodeMirror.Pos(err.from[0] - 1, err.from[1] - 1), + to: CodeMirror.Pos(err.to[0] - 1, err.to[1] - 1), + message: err.message + }); + } + updateLinting(collected); + }); + }, + "async": true + } + }; + + if (this.input) { + this.editor = CodeMirror.fromTextArea(this.input, config); + this.editor.on("keyup", this.onEditorKeyUp); + } + } + + componentWillUnmount() { + if (this.editor) { + this.editor.toTextArea(); + delete this.editor; + } + } + + render() { + return ( +
+