Skip to content
This repository has been archived by the owner on Mar 1, 2019. It is now read-only.

Conditional editor #772

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' ],
Expand Down
1 change: 1 addition & 0 deletions app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions app/components/shared/FormConditionEditorField/codemirror.js
Original file line number Diff line number Diff line change
@@ -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;
232 changes: 232 additions & 0 deletions app/components/shared/FormConditionEditorField/index.js
Original file line number Diff line number Diff line change
@@ -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<string>,
fetchParseErrors: (string, (Array<{ from: Array<number>, to: Array<number>, 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<Props> {
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 (
<div style={{ minHeight: CODEMIRROR_MIN_HEIGHT }}>
<textarea
name={this.props.name}
defaultValue={this.props.value}
ref={(input) => this.input = input}
/>
</div>
);
}

onEditorKeyUp = (codeMirrorInstance: CodeMirrorInstance, event: { key: string }) => {
if (AUTO_COMPLETE_AFTER_KEY.test(event.key) || event.key === "Backspace") {
codeMirrorInstance.execCommand('autocomplete');
}
};
}

// Instead of exporting the editor directly, we'll export a `Loadable`
// Component that will allow us to load in dependencies and render the editor
// until then.
export default Loadable({
loader: () => (
import('./codemirror').then((module) => (
// Add a "zero" delay after the module has loaded, to allow their
// styles to take effect.
new Promise((resolve) => {
setTimeout(() => resolve(module.default), 0);
})
))
),

loading(loadable: ReactLoadableLoadingProps) {
if (loadable.error) {
return (
<div className="red">{loadable.error}</div>
);
} else if (loadable.pastDelay) {
//const lines = loadable.value.split("\n").length;
//let height = CODEMIRROR_BUFFER + (lines * CODEMIRROR_LINE_HEIGHT);
//if (CODEMIRROR_MIN_HEIGHT > height) {
const height = CODEMIRROR_MIN_HEIGHT;
//}

return (
<div className="flex items-center justify-center" style={{ height: height }}>
<Spinner /> Loading Editor…
</div>
);
}

return null;
},

/* eslint-disable react/prop-types */
render(loaded: CodeMirror, props: Props) {
return (
<FormConditionEdtiorField
CodeMirror={loaded}
name={props.name}
value={props.value}
fetchParseErrors={props.fetchParseErrors}
autocompleteWords={props.autocompleteWords}
/>
);
}
});
42 changes: 42 additions & 0 deletions app/css/codemirror.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ li.CodeMirror-hint-active {
background-color: var(--lime);
}

li.CodeMirror-hint-active .dark-gray {
color: white;
}

/* Prettier lint errors */

.CodeMirror-lint-tooltip {
Expand Down Expand Up @@ -93,6 +97,44 @@ li.CodeMirror-hint-active {
color: #999;
}

/* Custom Conditional Editor Theme*/

.cm-s-conditional .cm-punctuation, .cm-s-conditional .cm-operator {
color: #555;
}

.cm-s-conditional .cm-variable {
color: #1F61A0;
}

.cm-s-conditional .cm-variable-2 {
color: #B11A04;
}

.cm-s-conditional .cm-variable-3 {
color: #CA9800;
}

.cm-s-conditional .cm-string {
color: #388a26;
}

.cm-s-conditional .cm-string-2 {
color: #B33086;
}

.cm-s-conditional .cm-string-3 {
color: #0B7FC7;
}

.cm-s-conditional .cm-regex {
color: #ca1655;
}

.cm-s-conditional .cm-comment {
color: #999;
}

/* Custom GraphQL Editor Theme. These colors have been copied from the offical
* GraphQL editor theme as seen here:
* http://graphql.org/learn/queries/#fragments */
Expand Down