import React, { Component } from "react"; import { connect } from "react-redux"; import { AppState } from "reducers"; import styled, { createGlobalStyle } from "styled-components"; import CodeMirror, { EditorConfiguration, LineHandle } from "codemirror"; import "codemirror/lib/codemirror.css"; import "codemirror/theme/monokai.css"; import "codemirror/addon/hint/show-hint"; import "codemirror/addon/hint/javascript-hint"; import "codemirror/addon/display/placeholder"; import { getNameBindingsForAutocomplete, NameBindingsWithData, } from "selectors/nameBindingsWithDataSelector"; import { AUTOCOMPLETE_MATCH_REGEX } from "constants/BindingsConstants"; import ErrorTooltip from "components/editorComponents/ErrorTooltip"; import { WrappedFieldInputProps, WrappedFieldMetaProps } from "redux-form"; import _ from "lodash"; import { parseDynamicString } from "utils/DynamicBindingUtils"; require("codemirror/mode/javascript/javascript"); const HintStyles = createGlobalStyle` .CodeMirror-hints { position: absolute; z-index: 10; overflow: hidden; list-style: none; margin: 0; padding: 5px; font-size: 90%; font-family: monospace; max-height: 20em; width: 200px; overflow-y: auto; background: #FFFFFF; border: 1px solid #EBEFF2; box-shadow: 0px 2px 4px rgba(67, 70, 74, 0.14); border-radius: 4px; } .CodeMirror-hint { height: 32px; padding: 3px; margin: 0; white-space: pre; color: #2E3D49; cursor: pointer; display: flex; align-items: center; font-size: 14px; } li.CodeMirror-hint-active { background: #E9FAF3; border-radius: 4px; } `; const Wrapper = styled.div<{ borderStyle?: THEME; hasError: boolean; }>` border: 1px solid; border-color: ${props => props.hasError ? props.theme.colors.error : props.borderStyle !== THEMES.DARK ? "#d0d7dd" : "transparent"}; border-radius: 4px; display: flex; flex: 1; flex-direction: row; position: relative; text-transform: none; min-height: 32px; overflow: hidden; height: auto; && { .binding-highlight { color: ${props => props.borderStyle === THEMES.DARK ? "#f7c75b" : "#ffb100"}; font-weight: 700; } .CodeMirror { flex: 1; line-height: 21px; z-index: 0; border-radius: 4px; height: auto; } .CodeMirror pre.CodeMirror-placeholder { color: #a3b3bf; } } `; const IconContainer = styled.div` .bp3-icon { border-radius: 4px 0 0 4px; margin: 0; height: 30px; width: 30px; display: flex; align-items: center; justify-content: center; background-color: #eef2f5; svg { height: 20px; width: 20px; path { fill: #979797; } } } `; const THEMES = { LIGHT: "LIGHT", DARK: "DARK", }; type THEME = "LIGHT" | "DARK"; interface ReduxStateProps { dynamicData: NameBindingsWithData; } export type DynamicAutocompleteInputProps = { placeholder?: string; leftIcon?: Function; height?: number; theme?: THEME; meta?: Partial; showLineNumbers?: boolean; allowTabIndent?: boolean; }; type Props = ReduxStateProps & DynamicAutocompleteInputProps & { input: Partial; }; class DynamicAutocompleteInput extends Component { textArea = React.createRef(); editor: any; componentDidMount(): void { if (this.textArea.current) { const options: EditorConfiguration = {}; if (this.props.theme === "DARK") options.theme = "monokai"; if (!this.props.input.onChange) options.readOnly = true; if (this.props.showLineNumbers) options.lineNumbers = true; const extraKeys: Record = { "Ctrl-Space": "autocomplete", }; if (!this.props.allowTabIndent) extraKeys["Tab"] = false; this.editor = CodeMirror.fromTextArea(this.textArea.current, { mode: { name: "javascript", globalVars: true }, viewportMargin: 10, value: this.props.input.value, tabSize: 2, indentWithTabs: true, lineWrapping: true, showHint: true, extraKeys, ...options, }); this.editor.on("change", _.debounce(this.handleChange, 100)); this.editor.on("cursorActivity", this.handleAutocompleteVisibility); this.editor.setOption("hintOptions", { completeSingle: false, globalScope: this.props.dynamicData, }); if (this.props.height) { this.editor.setSize(0, this.props.height); } this.editor.eachLine(this.highlightBindings); } } componentDidUpdate(): void { if (this.editor) { const editorValue = this.editor.getValue(); let inputValue = this.props.input.value; if (typeof inputValue === "object") { inputValue = JSON.stringify(inputValue, null, 2); } if ((!!inputValue || inputValue === "") && inputValue !== editorValue) { const cursor = this.editor.getCursor(); this.editor.setValue(inputValue); this.editor.setCursor(cursor); } } } handleChange = () => { const value = this.editor.getValue(); const inputValue = this.props.input.value; if (this.props.input.onChange && value !== inputValue) { this.props.input.onChange(value); } this.editor.eachLine(this.highlightBindings); }; handleAutocompleteVisibility = (cm: any) => { let cursorBetweenBinding = false; const cursor = this.editor.getCursor(); const value = this.editor.getValue(); let cumulativeCharCount = 0; parseDynamicString(value).forEach(segment => { const start = cumulativeCharCount; const dynamicStart = segment.indexOf("{{"); const dynamicDoesStart = dynamicStart > -1; const dynamicEnd = segment.indexOf("}}"); const dynamicDoesEnd = dynamicEnd > -1; const dynamicStartIndex = dynamicStart + start + 1; const dynamicEndIndex = dynamicEnd + start + 1; if ( dynamicDoesStart && cursor.ch > dynamicStartIndex && ((dynamicDoesEnd && cursor.ch < dynamicEndIndex) || (!dynamicDoesEnd && cursor.ch > dynamicStartIndex)) ) { cursorBetweenBinding = true; } cumulativeCharCount = start + segment.length; }); const shouldShow = cursorBetweenBinding && !cm.state.completionActive; if (shouldShow) { cm.showHint(cm); } }; highlightBindings = (line: LineHandle) => { const lineNo = this.editor.getLineNumber(line); let match; while ((match = AUTOCOMPLETE_MATCH_REGEX.exec(line.text)) != null) { const start = match.index; const end = AUTOCOMPLETE_MATCH_REGEX.lastIndex; this.editor.markText( { ch: start, line: lineNo }, { ch: end, line: lineNo }, { className: "binding-highlight", }, ); } }; render() { const { input, meta, theme } = this.props; const hasError = !!(meta && meta.error); let showError = false; if (this.editor) { showError = hasError && this.editor.hasFocus(); } return ( {this.props.leftIcon && }