PromucFlow_constructor/app/client/src/components/editorComponents/DynamicAutocompleteInput.tsx

633 lines
17 KiB
TypeScript
Raw Normal View History

import React, { Component, lazy, Suspense } from "react";
2019-12-06 13:16:08 +00:00
import { connect } from "react-redux";
import { AppState } from "reducers";
2020-01-02 13:36:35 +00:00
import styled, { createGlobalStyle } from "styled-components";
import CodeMirror, { EditorConfiguration, LineHandle } from "codemirror";
2019-12-30 07:35:16 +00:00
import "codemirror/lib/codemirror.css";
import "codemirror/theme/monokai.css";
import "codemirror/addon/hint/show-hint";
2020-01-02 13:36:35 +00:00
import "codemirror/addon/display/placeholder";
2020-02-04 10:40:55 +00:00
import "codemirror/addon/edit/closebrackets";
import "codemirror/addon/display/autorefresh";
import "codemirror/addon/mode/multiplex";
2020-05-20 11:30:53 +00:00
import "codemirror/addon/tern/tern.css";
2020-02-18 10:41:52 +00:00
import { getDataTreeForAutocomplete } from "selectors/dataTreeSelectors";
2020-01-02 13:36:35 +00:00
import { AUTOCOMPLETE_MATCH_REGEX } from "constants/BindingsConstants";
import ErrorTooltip from "components/editorComponents/ErrorTooltip";
import HelperTooltip from "components/editorComponents/HelperTooltip";
2020-01-02 13:36:35 +00:00
import { WrappedFieldInputProps, WrappedFieldMetaProps } from "redux-form";
import _ from "lodash";
import { parseDynamicString } from "utils/DynamicBindingUtils";
2020-02-18 10:41:52 +00:00
import { DataTree } from "entities/DataTree/dataTreeFactory";
2020-02-24 12:58:16 +00:00
import { Theme } from "constants/DefaultTheme";
2020-03-06 04:59:24 +00:00
import AnalyticsUtil from "utils/AnalyticsUtil";
2020-05-20 11:30:53 +00:00
import TernServer from "utils/autocomplete/TernServer";
import KeyboardShortcuts from "constants/KeyboardShortcuts";
const LightningMenu = lazy(() =>
import("components/editorComponents/LightningMenu"),
);
2019-12-30 07:35:16 +00:00
require("codemirror/mode/javascript/javascript");
require("codemirror/mode/sql/sql");
require("codemirror/addon/hint/sql-hint");
CodeMirror.defineMode("sql-js", function(config) {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
return CodeMirror.multiplexingMode(
CodeMirror.getMode(config, "text/x-sql"),
{
open: "{{",
close: "}}",
mode: CodeMirror.getMode(config, {
name: "javascript",
globalVars: true,
}),
},
// .. more multiplexed styles can follow here
);
});
CodeMirror.defineMode("js-js", function(config) {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
return CodeMirror.multiplexingMode(
CodeMirror.getMode(config, { name: "javascript", json: true }),
{
open: "{{",
close: "}}",
mode: CodeMirror.getMode(config, {
name: "javascript",
globalVars: true,
}),
},
// .. more multiplexed styles can follow here
);
});
2019-12-06 13:16:08 +00:00
2020-02-24 12:58:16 +00:00
const getBorderStyle = (
props: { theme: Theme } & {
editorTheme?: THEME;
hasError: boolean;
singleLine: boolean;
isFocused: boolean;
disabled?: boolean;
2020-02-24 12:58:16 +00:00
},
) => {
if (props.hasError) return props.theme.colors.error;
if (props.editorTheme !== THEMES.DARK) {
if (props.isFocused) return props.theme.colors.inputActiveBorder;
return props.theme.colors.border;
}
return "transparent";
};
2020-01-02 13:36:35 +00:00
const HintStyles = createGlobalStyle`
.CodeMirror-hints {
position: absolute;
z-index: 20;
2020-01-02 13:36:35 +00:00
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;
}
2020-05-20 11:30:53 +00:00
.CodeMirror-Tern-completion {
padding-left: 22px !important;
}
.CodeMirror-Tern-completion:before {
left: 4px !important;
bottom: 7px !important;
line-height: 15px !important;
}
2020-01-02 13:36:35 +00:00
`;
const Wrapper = styled.div<{
2020-02-24 12:58:16 +00:00
editorTheme?: THEME;
2020-01-02 13:36:35 +00:00
hasError: boolean;
2020-02-21 07:57:28 +00:00
singleLine: boolean;
2020-02-24 12:58:16 +00:00
isFocused: boolean;
disabled?: boolean;
setMaxHeight?: boolean;
2020-01-02 13:36:35 +00:00
}>`
2020-02-24 12:58:16 +00:00
${props =>
props.singleLine && props.isFocused
? `
2020-03-03 06:51:59 +00:00
z-index: 5;
2020-02-24 12:58:16 +00:00
position: absolute;
right: 0;
left: 0;
top: 0;
`
: `z-index: 0; position: relative`}
background-color: ${props =>
props.editorTheme === THEMES.DARK ? "#272822" : "#fff"};
background-color: ${props => props.disabled && "#eef2f5"};
2020-01-02 13:36:35 +00:00
border: 1px solid;
2020-02-24 12:58:16 +00:00
border-color: ${getBorderStyle};
2019-12-30 07:35:16 +00:00
border-radius: 4px;
2019-12-06 13:16:08 +00:00
display: flex;
flex: 1;
2020-01-02 13:36:35 +00:00
flex-direction: row;
2019-12-06 13:16:08 +00:00
text-transform: none;
2019-12-30 07:35:16 +00:00
min-height: 32px;
2020-01-02 13:36:35 +00:00
overflow: hidden;
height: auto;
${props =>
props.setMaxHeight &&
props.isFocused &&
`
z-index: 5;
position: absolute;
right: 0;
left: 0;
top: 0;
`}
${props => props.setMaxHeight && !props.isFocused && `max-height: 30px;`}
2020-01-02 13:36:35 +00:00
&& {
.binding-highlight {
color: ${props =>
2020-02-24 12:58:16 +00:00
props.editorTheme === THEMES.DARK ? "#f7c75b" : "#ffb100"};
2020-01-02 13:36:35 +00:00
font-weight: 700;
}
.CodeMirror {
flex: 1;
line-height: 21px;
z-index: 0;
border-radius: 4px;
height: auto;
}
${props =>
props.disabled &&
`
.CodeMirror-cursor {
display: none !important;
}
`}
2020-01-02 13:36:35 +00:00
.CodeMirror pre.CodeMirror-placeholder {
color: #a3b3bf;
}
2020-02-21 07:57:28 +00:00
${props =>
props.singleLine &&
`
2020-02-24 12:58:16 +00:00
.CodeMirror-hscrollbar {
2020-02-21 07:57:28 +00:00
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
`}
2020-01-02 13:36:35 +00:00
}
&& {
.CodeMirror-lines {
background-color: ${props => props.disabled && "#eef2f5"};
cursor: ${props => (props.disabled ? "not-allowed" : "text")}
}
}
.bp3-popover-target {
padding-right: 10px;
padding-top: 5px;
}
.leftImageStyles {
width: 20px;
height: 20px;
margin: 5px;
}
.linkStyles {
margin: 5px;
margin-right: 11px;
}
2020-01-02 13:36:35 +00:00
`;
const IconContainer = styled.div`
.bp3-icon {
border-radius: 4px 0 0 4px;
margin: 0;
2020-01-08 09:19:00 +00:00
height: 30px;
2020-01-02 13:36:35 +00:00
width: 30px;
display: flex;
align-items: center;
justify-content: center;
background-color: #eef2f5;
svg {
height: 20px;
width: 20px;
path {
fill: #979797;
}
}
}
.bp3-popover-target {
padding-right: 10px;
}
2019-12-06 13:16:08 +00:00
`;
const DynamicAutocompleteInputWrapper = styled.div`
width: 100%;
2020-05-21 04:22:57 +00:00
height: 100%;
flex: 1;
position: relative;
& > span:first-of-type {
position: absolute;
right: 0;
top: 2px;
width: 14px;
z-index: 10;
}
`;
2020-01-02 13:36:35 +00:00
const THEMES = {
LIGHT: "LIGHT",
DARK: "DARK",
};
type THEME = "LIGHT" | "DARK";
const AUTOCOMPLETE_CLOSE_KEY_CODES = ["Enter", "Tab", "Escape"];
2019-12-06 13:16:08 +00:00
interface ReduxStateProps {
2020-02-18 10:41:52 +00:00
dynamicData: DataTree;
2019-12-06 13:16:08 +00:00
}
2020-01-02 13:36:35 +00:00
export type DynamicAutocompleteInputProps = {
placeholder?: string;
leftIcon?: Function;
rightIcon?: Function;
description?: string;
height?: number;
2020-01-02 13:36:35 +00:00
theme?: THEME;
meta?: Partial<WrappedFieldMetaProps>;
showLineNumbers?: boolean;
allowTabIndent?: boolean;
2020-02-21 07:57:28 +00:00
singleLine: boolean;
mode?: string | object;
className?: string;
leftImage?: string;
disabled?: boolean;
link?: string;
baseMode?: string | object;
setMaxHeight?: boolean;
showLightningMenu?: boolean;
2019-12-06 13:16:08 +00:00
};
2020-01-02 13:36:35 +00:00
type Props = ReduxStateProps &
DynamicAutocompleteInputProps & {
input: Partial<WrappedFieldInputProps>;
};
2020-01-27 13:53:33 +00:00
type State = {
isFocused: boolean;
2020-04-03 05:15:57 +00:00
autoCompleteVisible: boolean;
2020-01-27 13:53:33 +00:00
};
class DynamicAutocompleteInput extends Component<Props, State> {
2019-12-30 07:35:16 +00:00
textArea = React.createRef<HTMLTextAreaElement>();
editor: any;
2020-05-20 11:30:53 +00:00
ternServer?: TernServer = undefined;
2019-12-30 07:35:16 +00:00
2020-01-27 13:53:33 +00:00
constructor(props: Props) {
super(props);
this.state = {
isFocused: false,
2020-04-03 05:15:57 +00:00
autoCompleteVisible: false,
2020-01-27 13:53:33 +00:00
};
2020-05-22 10:16:53 +00:00
this.updatePropertyValue = this.updatePropertyValue.bind(this);
2020-01-27 13:53:33 +00:00
}
2019-12-06 13:16:08 +00:00
componentDidMount(): void {
if (this.textArea.current) {
2019-12-30 07:35:16 +00:00
const options: EditorConfiguration = {};
//use this for lightning menu theme
2019-12-30 07:35:16 +00:00
if (this.props.theme === "DARK") options.theme = "monokai";
if (!this.props.input.onChange || this.props.disabled) {
options.readOnly = true;
options.scrollbarStyle = "null";
}
2020-01-02 13:36:35 +00:00
if (this.props.showLineNumbers) options.lineNumbers = true;
2020-05-20 11:30:53 +00:00
const extraKeys: Record<string, any> = {};
2020-01-02 13:36:35 +00:00
if (!this.props.allowTabIndent) extraKeys["Tab"] = false;
this.editor = CodeMirror.fromTextArea(this.textArea.current, {
mode: this.props.mode || { name: "javascript", globalVars: true },
2020-01-02 13:36:35 +00:00
viewportMargin: 10,
2019-12-30 07:35:16 +00:00
tabSize: 2,
indentWithTabs: true,
2020-02-24 12:58:16 +00:00
lineWrapping: !this.props.singleLine,
2020-01-02 13:36:35 +00:00
extraKeys,
2020-02-04 10:40:55 +00:00
autoCloseBrackets: true,
2019-12-30 07:35:16 +00:00
...options,
});
2020-02-20 15:03:14 +00:00
this.editor.on("change", _.debounce(this.handleChange, 300));
this.editor.on("keyup", this.handleAutocompleteHide);
2020-02-24 12:58:16 +00:00
this.editor.on("focus", this.handleEditorFocus);
this.editor.on("blur", this.handleEditorBlur);
if (this.props.height) {
this.editor.setSize(0, this.props.height);
2020-02-20 15:03:14 +00:00
} else {
this.editor.setSize(0, "auto");
}
2020-01-02 13:36:35 +00:00
this.editor.eachLine(this.highlightBindings);
2020-02-04 10:11:33 +00:00
// Set value of the editor
2020-02-04 10:40:55 +00:00
let inputValue = this.props.input.value || "";
2020-02-04 10:11:33 +00:00
if (typeof inputValue === "object") {
inputValue = JSON.stringify(inputValue, null, 2);
}
this.editor.setValue(inputValue);
2020-05-20 11:30:53 +00:00
this.startAutocomplete();
2019-12-06 13:16:08 +00:00
}
2019-12-30 07:35:16 +00:00
}
2019-12-06 13:16:08 +00:00
2020-01-27 13:53:33 +00:00
componentDidUpdate(prevProps: Props): void {
2019-12-30 07:35:16 +00:00
if (this.editor) {
2020-03-03 06:51:59 +00:00
this.editor.refresh();
2020-02-24 12:58:16 +00:00
if (!this.state.isFocused) {
const currentMode = this.editor.getOption("mode");
2020-02-24 12:58:16 +00:00
const editorValue = this.editor.getValue();
let inputValue = this.props.input.value;
// Safe update of value of the editor when value updated outside the editor
if (typeof inputValue === "object") {
inputValue = JSON.stringify(inputValue, null, 2);
}
if ((!!inputValue || inputValue === "") && inputValue !== editorValue) {
this.editor.setValue(inputValue);
}
if (currentMode !== this.props.mode) {
this.editor.setOption("mode", this.props?.mode);
}
2020-02-24 12:58:16 +00:00
} else {
// Update the dynamic bindings for autocomplete
if (prevProps.dynamicData !== this.props.dynamicData) {
2020-05-20 11:30:53 +00:00
if (this.ternServer) {
// const dataTreeDef = dataTreeTypeDefCreator(this.props.dynamicData);
// this.ternServer.updateDef("dataTree", dataTreeDef);
} else {
this.editor.setOption("hintOptions", {
completeSingle: false,
globalScope: this.props.dynamicData,
});
}
2020-02-24 12:58:16 +00:00
}
2020-01-27 13:53:33 +00:00
}
2019-12-06 13:16:08 +00:00
}
2019-12-30 07:35:16 +00:00
}
2020-05-20 11:30:53 +00:00
startAutocomplete() {
try {
this.ternServer = new TernServer(this.props.dynamicData);
} catch (e) {
console.error(e);
}
if (this.ternServer) {
this.editor.setOption("extraKeys", {
[KeyboardShortcuts.CodeEditor.OpenAutocomplete]: (
cm: CodeMirror.Editor,
) => {
if (this.ternServer) this.ternServer.complete(cm);
},
[KeyboardShortcuts.CodeEditor.ShowTypeAndInfo]: (cm: any) => {
if (this.ternServer) this.ternServer.showType(cm);
},
[KeyboardShortcuts.CodeEditor.OpenDocsLink]: (cm: any) => {
if (this.ternServer) this.ternServer.showDocs(cm);
},
});
} else {
// start normal autocomplete
this.editor.setOption("extraKeys", {
[KeyboardShortcuts.CodeEditor.OpenAutocomplete]: "autocomplete",
});
this.editor.setOption("showHint", true);
this.editor.setOption("hintOptions", {
completeSingle: false,
globalScope: this.props.dynamicData,
});
}
this.editor.on("cursorActivity", this.handleAutocompleteVisibility);
}
2020-02-24 12:58:16 +00:00
handleEditorFocus = () => {
this.setState({ isFocused: true });
this.editor.refresh();
if (this.props.singleLine) {
this.editor.setOption("lineWrapping", true);
}
};
handleEditorBlur = () => {
2020-03-03 06:51:59 +00:00
this.handleChange();
2020-02-24 12:58:16 +00:00
this.setState({ isFocused: false });
if (this.props.singleLine) {
this.editor.setOption("lineWrapping", false);
}
};
2020-03-06 04:59:24 +00:00
handleChange = (instance?: any, changeObj?: any) => {
2019-12-30 07:35:16 +00:00
const value = this.editor.getValue();
2020-03-06 04:59:24 +00:00
if (changeObj && changeObj.origin === "complete") {
AnalyticsUtil.logEvent("AUTO_COMPLETE_SELECT", {
searchString: changeObj.text[0],
});
}
2020-01-08 09:19:00 +00:00
const inputValue = this.props.input.value;
if (this.props.input.onChange && value !== inputValue) {
2019-12-30 07:35:16 +00:00
this.props.input.onChange(value);
2019-12-06 13:16:08 +00:00
}
2020-01-02 13:36:35 +00:00
this.editor.eachLine(this.highlightBindings);
2019-12-06 13:16:08 +00:00
};
handleAutocompleteVisibility = (cm: any) => {
2020-02-03 11:49:20 +00:00
if (this.state.isFocused) {
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;
});
2020-05-20 11:30:53 +00:00
const shouldShow = cursorBetweenBinding;
if (this.props.baseMode) {
// https://github.com/codemirror/CodeMirror/issues/5249#issue-295565980
cm.doc.modeOption = this.props.baseMode;
}
2020-02-03 11:49:20 +00:00
if (shouldShow) {
2020-03-06 04:59:24 +00:00
AnalyticsUtil.logEvent("AUTO_COMPELTE_SHOW", {});
2020-04-03 05:15:57 +00:00
this.setState({
autoCompleteVisible: true,
});
2020-05-20 11:30:53 +00:00
if (this.ternServer) {
this.ternServer.complete(cm);
} else {
cm.showHint(cm);
}
2020-04-03 05:15:57 +00:00
} else {
this.setState({
autoCompleteVisible: false,
});
2020-05-20 11:30:53 +00:00
cm.closeHint();
2020-01-02 13:36:35 +00:00
}
2019-12-06 13:16:08 +00:00
}
};
handleAutocompleteHide = (cm: any, event: KeyboardEvent) => {
if (AUTOCOMPLETE_CLOSE_KEY_CODES.includes(event.code)) {
cm.closeHint();
}
};
2020-01-02 13:36:35 +00:00
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",
},
);
}
};
updatePropertyValue(value: string, cursor?: number) {
this.editor.setValue(value);
this.editor.focus();
2020-05-22 07:29:15 +00:00
if (cursor === undefined) {
cursor = value.length - 2;
}
this.editor.setCursor({
2020-05-21 14:06:17 +00:00
line: 0,
ch: cursor,
});
2020-05-22 10:16:53 +00:00
}
2019-12-06 13:16:08 +00:00
render() {
const {
input,
meta,
theme,
singleLine,
disabled,
className,
setMaxHeight,
showLightningMenu,
} = this.props;
2020-01-02 13:36:35 +00:00
const hasError = !!(meta && meta.error);
let showError = false;
if (this.editor) {
2020-04-03 05:15:57 +00:00
showError =
hasError && this.state.isFocused && !this.state.autoCompleteVisible;
}
2020-05-21 04:22:57 +00:00
const hideLightningMenu = false;
2019-12-06 13:16:08 +00:00
return (
<DynamicAutocompleteInputWrapper>
2020-05-21 04:22:57 +00:00
{!hideLightningMenu &&
(showLightningMenu === undefined || showLightningMenu === true) && (
<LightningMenu
2020-05-22 05:29:54 +00:00
themeType={this.props.theme === "DARK" ? "dark" : "light"}
2020-05-21 04:22:57 +00:00
updatePropertyValue={this.updatePropertyValue}
/>
)}
<ErrorTooltip message={meta ? meta.error : ""} isOpen={showError}>
<Wrapper
editorTheme={theme}
hasError={hasError}
singleLine={singleLine}
isFocused={this.state.isFocused}
disabled={disabled}
className={className}
setMaxHeight={setMaxHeight}
>
<HintStyles />
<IconContainer>
{this.props.leftIcon && <this.props.leftIcon />}
</IconContainer>
{this.props.leftImage && (
<img
src={this.props.leftImage}
alt="img"
className="leftImageStyles"
/>
)}
<textarea
ref={this.textArea}
{..._.omit(this.props.input, ["onChange", "value"])}
defaultValue={input.value}
placeholder={this.props.placeholder}
/>
{this.props.link && (
<React.Fragment>
<a
href={this.props.link}
target="_blank"
className="linkStyles"
rel="noopener noreferrer"
>
API documentation
</a>
</React.Fragment>
)}
{this.props.rightIcon && (
<HelperTooltip
description={this.props.description}
rightIcon={this.props.rightIcon}
/>
)}
</Wrapper>
</ErrorTooltip>
</DynamicAutocompleteInputWrapper>
2019-12-06 13:16:08 +00:00
);
}
}
const mapStateToProps = (state: AppState): ReduxStateProps => ({
2020-02-18 10:41:52 +00:00
dynamicData: getDataTreeForAutocomplete(state),
2019-12-06 13:16:08 +00:00
});
export default connect(mapStateToProps)(DynamicAutocompleteInput);