Tern autocomplete

This commit is contained in:
Hetu Nandu 2020-05-20 11:30:53 +00:00
parent 50515a8ef5
commit 010b48d7fa
15 changed files with 1123 additions and 28 deletions

View File

@ -2,6 +2,7 @@ const widgetsPage = require("../../../locators/Widgets.json");
const commonlocators = require("../../../locators/commonlocators.json");
const dsl = require("../../../fixtures/commondsl.json");
const homePage = require("../../../locators/HomePage.json");
const pages = require("../../../locators/Pages.json");
describe("Button Widget Functionality", function() {
beforeEach(() => {
@ -9,7 +10,7 @@ describe("Button Widget Functionality", function() {
});
it("Button Widget Functionality", function() {
cy.get(".t--nav-link-widgets-editor").click();
cy.get(pages.widgetsEditor).click();
cy.openPropertyPane("buttonwidget");
//changing the Button Name

View File

@ -0,0 +1,55 @@
const dsl = require("../../../fixtures/commondsl.json");
const pages = require("../../../locators/Pages.json");
const dynamicInputLocators = require("../../../locators/DynamicInput.json");
describe("Dynamic input autocomplete", () => {
beforeEach(() => {
cy.addDsl(dsl);
});
it("opens autocomplete for bindings", () => {
cy.get(pages.widgetsEditor).click();
cy.openPropertyPane("buttonwidget");
cy.get(dynamicInputLocators.input)
.first()
.focus()
.type("{ctrl}{shift}{downarrow}")
.then($cm => {
if ($cm.val() !== "") {
cy.get(dynamicInputLocators.input)
.first()
.clear({
force: true,
});
}
cy.get(dynamicInputLocators.input)
.first()
.type("{{", {
force: true,
parseSpecialCharSequences: false,
});
// Tests if autocomplete will open
cy.get(dynamicInputLocators.hints).should("exist");
// Tests if data tree entities are sorted
cy.get(`${dynamicInputLocators.hints} li`)
.first()
.should("have.text", "Aditya");
// Tests if "No suggestions" message will pop if you type any garbage
cy.get(dynamicInputLocators.input)
.first()
.type("garbage", {
force: true,
parseSpecialCharSequences: false,
})
.then(() => {
cy.get(".CodeMirror-Tern-tooltip").should(
"have.text",
"No suggestions",
);
});
});
});
});

View File

@ -0,0 +1,4 @@
{
"input": ".CodeMirror textarea",
"hints": "ul.CodeMirror-hints"
}

View File

@ -94,12 +94,14 @@
"reselect": "^4.0.0",
"shallowequal": "^1.1.0",
"styled-components": "^4.1.3",
"tern": "^0.21.0",
"tinycolor2": "^1.4.1",
"toposort": "^2.0.2",
"ts-loader": "^6.0.4",
"typescript": "^3.6.3",
"unescape-js": "^1.1.4",
"url-search-params-polyfill": "^8.0.0"
"url-search-params-polyfill": "^8.0.0",
"workerize-loader": "^1.2.0"
},
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
@ -143,6 +145,7 @@
"@types/react-select": "^3.0.5",
"@types/react-tabs": "^2.3.1",
"@types/redux-form": "^8.1.9",
"@types/tern": "0.22.0",
"@types/toposort": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^2.0.0",
"@typescript-eslint/parser": "^2.0.0",
@ -168,9 +171,9 @@
"redux-devtools-extension": "^2.13.8",
"source-map-explorer": "^2.4.2",
"storybook-addon-designs": "^5.1.1",
"ts-jest": "^24.3.0",
"webpack-merge": "^4.2.2",
"workbox-webpack-plugin": "^5.1.2",
"ts-jest": "^24.3.0"
"workbox-webpack-plugin": "^5.1.2"
},
"husky": {
"hooks": {

View File

@ -6,11 +6,11 @@ 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 "codemirror/addon/edit/closebrackets";
import "codemirror/addon/display/autorefresh";
import "codemirror/addon/mode/multiplex";
import "codemirror/addon/tern/tern.css";
import { getDataTreeForAutocomplete } from "selectors/dataTreeSelectors";
import { AUTOCOMPLETE_MATCH_REGEX } from "constants/BindingsConstants";
import ErrorTooltip from "components/editorComponents/ErrorTooltip";
@ -21,6 +21,8 @@ import { parseDynamicString } from "utils/DynamicBindingUtils";
import { DataTree } from "entities/DataTree/dataTreeFactory";
import { Theme } from "constants/DefaultTheme";
import AnalyticsUtil from "utils/AnalyticsUtil";
import TernServer from "utils/autocomplete/TernServer";
import KeyboardShortcuts from "constants/KeyboardShortcuts";
require("codemirror/mode/javascript/javascript");
require("codemirror/mode/sql/sql");
require("codemirror/addon/hint/sql-hint");
@ -111,6 +113,14 @@ const HintStyles = createGlobalStyle`
background: #E9FAF3;
border-radius: 4px;
}
.CodeMirror-Tern-completion {
padding-left: 22px !important;
}
.CodeMirror-Tern-completion:before {
left: 4px !important;
bottom: 7px !important;
line-height: 15px !important;
}
`;
const Wrapper = styled.div<{
@ -279,6 +289,7 @@ type State = {
class DynamicAutocompleteInput extends Component<Props, State> {
textArea = React.createRef<HTMLTextAreaElement>();
editor: any;
ternServer?: TernServer = undefined;
constructor(props: Props) {
super(props);
@ -297,9 +308,7 @@ class DynamicAutocompleteInput extends Component<Props, State> {
options.scrollbarStyle = "null";
}
if (this.props.showLineNumbers) options.lineNumbers = true;
const extraKeys: Record<string, any> = {
"Ctrl-Space": "autocomplete",
};
const extraKeys: Record<string, any> = {};
if (!this.props.allowTabIndent) extraKeys["Tab"] = false;
this.editor = CodeMirror.fromTextArea(this.textArea.current, {
mode: this.props.mode || { name: "javascript", globalVars: true },
@ -307,21 +316,15 @@ class DynamicAutocompleteInput extends Component<Props, State> {
tabSize: 2,
indentWithTabs: true,
lineWrapping: !this.props.singleLine,
showHint: true,
extraKeys,
autoCloseBrackets: true,
...options,
});
this.editor.on("change", _.debounce(this.handleChange, 300));
this.editor.on("cursorActivity", this.handleAutocompleteVisibility);
this.editor.on("keyup", this.handleAutocompleteHide);
this.editor.on("focus", this.handleEditorFocus);
this.editor.on("blur", this.handleEditorBlur);
this.editor.setOption("hintOptions", {
completeSingle: false,
globalScope: this.props.dynamicData,
});
if (this.props.height) {
this.editor.setSize(0, this.props.height);
} else {
@ -334,6 +337,7 @@ class DynamicAutocompleteInput extends Component<Props, State> {
inputValue = JSON.stringify(inputValue, null, 2);
}
this.editor.setValue(inputValue);
this.startAutocomplete();
}
}
@ -358,15 +362,54 @@ class DynamicAutocompleteInput extends Component<Props, State> {
} else {
// Update the dynamic bindings for autocomplete
if (prevProps.dynamicData !== this.props.dynamicData) {
this.editor.setOption("hintOptions", {
completeSingle: false,
globalScope: this.props.dynamicData,
});
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,
});
}
}
}
}
}
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);
}
handleEditorFocus = () => {
this.setState({ isFocused: true });
this.editor.refresh();
@ -422,23 +465,27 @@ class DynamicAutocompleteInput extends Component<Props, State> {
cumulativeCharCount = start + segment.length;
});
const shouldShow = cursorBetweenBinding && !cm.state.completionActive;
const shouldShow = cursorBetweenBinding;
if (this.props.baseMode) {
// https://github.com/codemirror/CodeMirror/issues/5249#issue-295565980
cm.doc.modeOption = this.props.baseMode;
}
if (shouldShow) {
AnalyticsUtil.logEvent("AUTO_COMPELTE_SHOW", {});
this.setState({
autoCompleteVisible: true,
});
cm.showHint(cm);
if (this.ternServer) {
this.ternServer.complete(cm);
} else {
cm.showHint(cm);
}
} else {
this.setState({
autoCompleteVisible: false,
});
cm.closeHint();
}
}
};
@ -481,7 +528,6 @@ class DynamicAutocompleteInput extends Component<Props, State> {
showError =
hasError && this.state.isFocused && !this.state.autoCompleteVisible;
}
console.log(className);
return (
<ErrorTooltip message={meta ? meta.error : ""} isOpen={showError}>
<Wrapper

View File

@ -0,0 +1,7 @@
export default {
CodeEditor: {
OpenAutocomplete: "Ctrl-Space",
ShowTypeAndInfo: "Ctrl-I",
OpenDocsLink: "Ctrl-O",
},
};

View File

@ -10,7 +10,6 @@ import {
getCurlImportPageURL,
getProviderTemplatesURL,
} from "constants/routes";
import { RestAction } from "api/ActionAPI";
import { AppState } from "reducers";
import { ActionDataState } from "reducers/entityReducers/actionsReducer";
import { getImportedCollections } from "selectors/applicationSelectors";

View File

@ -1,7 +1,7 @@
import React from "react";
import { connect } from "react-redux";
import styled from "styled-components";
import { formValueSelector, change, Field } from "redux-form";
import { formValueSelector, change } from "redux-form";
import Select from "react-select";
import {
POST_BODY_FORMAT_OPTIONS,

View File

@ -4,7 +4,6 @@ import { ActionDataState } from "reducers/entityReducers/actionsReducer";
import { getEvaluatedDataTree } from "utils/DynamicBindingUtils";
import { extraLibraries } from "jsExecution/JSExecutionManagerSingleton";
import { DataTree, DataTreeFactory } from "entities/DataTree/dataTreeFactory";
import _ from "lodash";
import { getWidgets, getWidgetsMeta } from "sagas/selectors";
import * as log from "loglevel";
import "url-search-params-polyfill";
@ -83,7 +82,6 @@ export const getDataTreeForAutocomplete = createSelector(
}
});
}
_.omit(tree, ["MainContainer", "actionPaths"]);
const libs: Record<string, any> = {};
extraLibraries.forEach(config => (libs[config.accessor] = config.lib));
return { ...tree, ...cachedResponses, ...libs };

View File

@ -0,0 +1,25 @@
import _ from "lodash";
export enum Types {
STRING = "STRING",
NUMBER = "NUMBER",
BOOLEAN = "BOOLEAN",
OBJECT = "OBJECT",
ARRAY = "ARRAY",
FUNCTION = "FUNCTION",
UNDEFINED = "UNDEFINED",
NULL = "NULL",
UNKNOWN = "UNKNOWN",
}
export const getType = (value: unknown) => {
if (_.isString(value)) return Types.STRING;
if (_.isNumber(value)) return Types.NUMBER;
if (_.isBoolean(value)) return Types.BOOLEAN;
if (Array.isArray(value)) return Types.ARRAY;
if (_.isFunction(value)) return Types.FUNCTION;
if (_.isObject(value)) return Types.OBJECT;
if (_.isUndefined(value)) return Types.UNDEFINED;
if (_.isNull(value)) return Types.NULL;
return Types.UNKNOWN;
};

View File

@ -0,0 +1,280 @@
import { generateTypeDef } from "utils/autocomplete/dataTreeTypeDefCreator";
import { DataTreeAction } from "entities/DataTree/dataTreeFactory";
const isLoading = {
"!type": "bool",
"!doc": "Boolean value indicating if the entity is in loading state",
};
const isVisible = {
"!type": "bool",
"!doc": "Boolean value indicating if the widget is in visible state",
};
export const entityDefinitions = {
ACTION: (entity: DataTreeAction) => ({
"!doc":
"Actions allow you to connect your widgets to your backend data in a secure manner.",
"!url": "https://docs.appsmith.com/quick-start#connect-your-apis",
isLoading: "bool",
data: generateTypeDef(entity.data),
run: "fn(onSuccess: fn() -> void, onError: fn() -> void) -> void",
}),
CONTAINER_WIDGET: {
"!doc":
"Containers are used to group widgets together to form logical higher order widgets. Containers let you organize your page better and move all the widgets inside them together.",
"!url": "https://docs.appsmith.com/widget-reference/how-to-use-widgets",
backgroundColor: {
"!type": "string",
"!url": "https://docs.appsmith.com/widget-reference/how-to-use-widgets",
},
isVisible: isVisible,
},
INPUT_WIDGET: {
"!doc":
"An input text field is used to capture a users textual input such as their names, numbers, emails etc. Inputs are used in forms and can have custom validations.",
"!url": "https://docs.appsmith.com/widget-reference/input",
text: {
"!type": "string",
"!doc": "The text value of the input",
"!url": "https://docs.appsmith.com/widget-reference/input",
},
inputType: "string",
isDirty: "bool",
isFocused: "bool",
isLoading: isLoading,
isValid: "bool",
isVisible: isVisible,
defaultText: "string",
label: {
"!type": "string",
"!doc": "The label value of the input. Can be set as empty string",
"!url": "https://docs.appsmith.com/widget-reference/input",
},
},
TABLE_WIDGET: (widget: any) => ({
"!doc":
"The Table is the hero widget of Appsmith. You can display data from an API in a table, trigger an action when a user selects a row and even work with large paginated data sets",
"!url": "https://docs.appsmith.com/widget-reference/table",
isLoading: isLoading,
isVisible: isVisible,
selectedRow: generateTypeDef(widget.selectedRow),
selectedRowIndex: "number",
tableData: generateTypeDef(widget.tableData),
}),
DROP_DOWN_WIDGET: {
"!doc":
"Dropdown is used to capture user input/s from a specified list of permitted inputs. A Dropdown can capture a single choice as well as multiple choices",
"!url": "https://docs.appsmith.com/widget-reference/dropdown",
isLoading: isLoading,
isVisible: isVisible,
placeholderText: "string",
label: "string",
selectedIndex: "number",
selectedIndexArr: "[number]",
selectionType: "string",
selectedOption: "dropdownOption",
options: "[dropdownOption]",
defaultOptionValue: "string",
isRequired: "bool",
},
IMAGE_WIDGET: {
"!doc":
"Image widget is used to display images in your app. Images must be either a URL or a valid base64.",
"!url": "https://docs.appsmith.com/widget-reference/image",
isLoading: isLoading,
image: "string",
defaultImage: "string",
isVisible: isVisible,
},
TEXT_WIDGET: {
"!doc":
"Text widget is used to display textual information. Whether you want to display a paragraph or information or add a heading to a container, a text widget makes it easy to style and display text",
"!url": "https://docs.appsmith.com/widget-reference/text",
isLoading: isLoading,
isVisible: isVisible,
text: "string",
textStyle: "string",
textAlign: "string",
shouldScroll: "bool",
},
BUTTON_WIDGET: {
"!doc":
"Buttons are used to capture user intent and trigger actions based on that intent",
"!url": "https://docs.appsmith.com/widget-reference/button",
isLoading: isLoading,
isVisible: isVisible,
text: "string",
buttonStyle: "string",
isDisabled: "bool",
},
DATE_PICKER_WIDGET: {
"!doc":
"Datepicker is used to capture the date and time from a user. It can be used to filter data base on the input date range as well as to capture personal information such as date of birth",
"!url": "https://docs.appsmith.com/widget-reference/datepicker",
isLoading: isLoading,
isVisible: isVisible,
defaultDate: "string",
selectedDate: "string",
isDisabled: "bool",
dateFormat: "string",
label: "string",
datePickerType: "string",
maxDate: "Date",
minDate: "Date",
isRequired: "bool",
},
CHECKBOX_WIDGET: {
"!doc":
"Checkbox is a simple UI widget you can use when you want users to make a binary choice",
"!url": "https://docs.appsmith.com/widget-reference/checkbox",
isLoading: isLoading,
isVisible: isVisible,
label: "string",
defaultCheckedState: "bool",
isChecked: "bool",
isDisabled: "bool",
},
RADIO_GROUP_WIDGET: {
"!doc":
"Radio widget lets the user choose only one option from a predefined set of options. It is quite similar to a SingleSelect Dropdown in its functionality",
"!url": "https://docs.appsmith.com/widget-reference/radio",
isLoading: isLoading,
isVisible: isVisible,
label: "string",
options: "[dropdownOption]",
selectedOptionValue: "string",
defaultOptionValue: "string",
isRequired: "bool",
},
TABS_WIDGET: {
isLoading: isLoading,
isVisible: isVisible,
shouldScrollContents: "bool",
tabs: "[tabs]",
selectedTab: "string",
selectedTabId: "string",
},
MODAL_WIDGET: {
isLoading: isLoading,
isVisible: isVisible,
isOpen: "bool",
canOutsideClickClose: "bool",
canEscapeKeyClose: "bool",
shouldScrollContents: "bool",
size: "string",
},
RICH_TEXT_EDITOR_WIDGET: {
isLoading: isLoading,
isVisible: isVisible,
defaultText: "string",
text: "string",
placeholder: "string",
isDisabled: "string",
},
CHART_WIDGET: {
"!doc":
"Chart widget is used to view the graphical representation of your data. Chart is the go-to widget for your data visualisation needs.",
"!url": "https://docs.appsmith.com/widget-reference/chart",
isLoading: isLoading,
isVisible: isVisible,
chartType: "string",
chartData: "chartData",
xAxisName: "string",
yAxisName: "string",
chartName: "string",
},
FORM_WIDGET: {
"!doc":
"Form is used to capture a set of data inputs from a user. Forms are used specifically because they reset the data inputs when a form is submitted and disable submission for invalid data inputs",
"!url": "https://docs.appsmith.com/widget-reference/form",
isLoading: isLoading,
isVisible: isVisible,
},
FORM_BUTTON_WIDGET: {
"!doc":
"Form button is provided by default to every form. It is used for form submission and resetting form inputs",
"!url": "https://docs.appsmith.com/widget-reference/form",
isLoading: isLoading,
isVisible: isVisible,
text: "string",
buttonStyle: "string",
isDisabled: "bool",
resetFormOnClick: "bool",
disabledWhenInvalid: "bool",
},
MAP_WIDGET: {
isLoading: isLoading,
isVisible: isVisible,
enableSearch: "bool",
zoomLevel: "number",
allowZoom: "bool",
enablePickLocation: "bool",
mapCenter: "latLong",
center: "latLong",
defaultMarkers: "[mapMarker]",
markers: "[mapMarker]",
selectedMarker: "mapMarker",
},
FILE_PICKER_WIDGET: {
"!doc":
"Filepicker widget is used to allow users to upload files from their local machines to any cloud storage via API. Cloudinary and Amazon S3 have simple APIs for cloud storage uploads",
"!url": "https://docs.appsmith.com/widget-reference/filepicker",
isLoading: isLoading,
isVisible: isVisible,
label: "string",
maxNumFiles: "number",
maxFileSize: "number",
files: "[?]",
allowedFileTypes: "[string]",
isRequired: "bool",
uploadedFileUrls: "string",
},
};
export const GLOBAL_DEFS = {
dropdownOption: {
label: "string",
value: "string",
},
tabs: {
id: "string",
label: "string",
},
chartDataPoint: {
x: "string",
y: "string",
},
chartData: {
seriesName: "string",
data: "[chartDataPoint]",
},
latLong: {
lat: "number",
long: "number",
},
mapMarker: {
lat: "number",
long: "number",
title: "string",
description: "string",
},
};
export const GLOBAL_FUNCTIONS = {
navigateTo: {
"!doc": "Action to navigate the user to another page or url",
"!type": "fn(pageNameOrUrl: string, params: {}) -> void",
},
showAlert: {
"!doc": "Show a temporary notification style message to the user",
"!type": "fn(message: string, style: string) -> void",
},
showModal: {
"!doc": "Open a modal",
"!type": "fn(modalName: string) -> void",
},
closeModal: {
"!doc": "Close a modal",
"!type": "fn(modalName: string) -> void",
},
};

View File

@ -0,0 +1,493 @@
/* eslint-disable @typescript-eslint/ban-ts-ignore */
// Heavily inspired from https://github.com/codemirror/CodeMirror/blob/master/addon/tern/tern.js
import { DataTree } from "entities/DataTree/dataTreeFactory";
import tern, { Server } from "tern";
import ecma from "tern/defs/ecmascript.json";
import { dataTreeTypeDefCreator } from "utils/autocomplete/dataTreeTypeDefCreator";
import CodeMirror, { Hint, Pos, cmpPos } from "codemirror";
const DEFS = [ecma];
const bigDoc = 250;
const cls = "CodeMirror-Tern-";
const hintDelay = 1700;
type Completion = Hint & {
origin: string;
data: {
doc: string;
};
};
type TernDocs = Record<string, TernDoc>;
type TernDoc = {
doc: CodeMirror.Doc;
name: string;
changed: { to: number; from: number } | null;
};
type ArgHints = {
start: CodeMirror.Position;
type: { args: any[]; rettype: null | string };
name: string;
guess: boolean;
doc: CodeMirror.Doc;
};
class TernServer {
server: Server;
docs: TernDocs = Object.create(null);
cachedArgHints: ArgHints | null = null;
constructor(dataTree: DataTree) {
const dataTreeDef = dataTreeTypeDefCreator(dataTree);
this.server = new tern.Server({
async: true,
defs: [...DEFS, dataTreeDef],
});
}
complete(cm: CodeMirror.Editor) {
cm.showHint({ hint: this.getHint.bind(this), completeSingle: false });
}
showType(cm: CodeMirror.Editor) {
this.showContextInfo(cm, "type");
}
showDocs(cm: CodeMirror.Editor) {
this.showContextInfo(cm, "documentation", (data: any) => {
if (data.url) {
window.open(data.url, "_blank");
}
});
}
getHint(cm: CodeMirror.Editor) {
return new Promise(resolve => {
this.request(
cm,
{
type: "completions",
types: true,
docs: true,
urls: true,
origins: true,
},
(error, data) => {
if (error) return this.showError(cm, error);
if (data.completions.length === 0) {
return this.showError(cm, "No suggestions");
}
let completions: Completion[] = [];
let after = "";
const from = data.start;
const to = data.end;
if (
cm.getRange(Pos(from.line, from.ch - 2), from) === '["' &&
cm.getRange(to, Pos(to.line, to.ch + 2)) !== '"]'
) {
after = '"]';
}
for (let i = 0; i < data.completions.length; ++i) {
const completion = data.completions[i];
let className = this.typeToIcon(completion.type);
if (data.guess) className += " " + cls + "guess";
completions.push({
text: completion.name + after,
displayText: completion.displayName || completion.name,
className: className,
data: completion,
origin: completion.origin,
});
}
completions = this.sortCompletions(completions);
const obj = { from: from, to: to, list: completions };
let tooltip: HTMLElement | undefined = undefined;
CodeMirror.on(obj, "close", () => this.remove(tooltip));
CodeMirror.on(obj, "update", () => this.remove(tooltip));
CodeMirror.on(
obj,
"select",
(cur: { data: { doc: string } }, node: any) => {
this.remove(tooltip);
const content = cur.data.doc;
if (content) {
tooltip = this.makeTooltip(
node.parentNode.getBoundingClientRect().right +
window.pageXOffset,
node.getBoundingClientRect().top + window.pageYOffset,
content,
);
tooltip.className += " " + cls + "hint-doc";
}
},
);
resolve(obj);
},
);
});
}
sortCompletions(completions: Completion[]) {
// Add data tree completions before others
const dataTreeCompletions = completions
.filter(c => c.origin === "dataTree")
.sort((a, b) => {
return a.text.toLowerCase().localeCompare(b.text.toLowerCase());
});
const otherCompletions = completions.filter(c => c.origin !== "dataTree");
return [...dataTreeCompletions, ...otherCompletions];
}
typeToIcon(type: string) {
let suffix;
if (type === "?") suffix = "unknown";
else if (type === "number" || type === "string" || type === "bool")
suffix = type;
else if (/^fn\(/.test(type)) suffix = "fn";
else if (/^\[/.test(type)) suffix = "array";
else suffix = "object";
return cls + "completion " + cls + "completion-" + suffix;
}
showContextInfo(
cm: CodeMirror.Editor,
queryName: string,
callbackFn?: Function,
) {
this.request(cm, { type: queryName }, (error, data) => {
if (error) return this.showError(cm, error);
const tip = this.elt(
"span",
null,
this.elt("strong", null, data.type || "not found"),
);
if (data.doc) tip.appendChild(document.createTextNode(" — " + data.doc));
if (data.url) {
tip.appendChild(document.createTextNode(" "));
const child = tip.appendChild(this.elt("a", null, "[docs]"));
// @ts-ignore
child.href = data.url;
// @ts-ignore
child.target = "_blank";
}
this.tempTooltip(cm, tip);
if (callbackFn) callbackFn(data);
});
}
request(
cm: CodeMirror.Editor,
query: {
type: string;
types?: boolean;
docs?: boolean;
urls?: boolean;
origins?: boolean;
preferFunction?: boolean;
end?: CodeMirror.Position;
},
callbackFn: (error: any, data: any) => void,
pos?: CodeMirror.Position,
) {
const doc = this.findDoc(cm.getDoc());
const request = this.buildRequest(doc, query, pos);
// @ts-ignore
this.server.request(request, callbackFn);
}
findDoc(doc: CodeMirror.Doc, name?: string): TernDoc {
for (const n in this.docs) {
const cur = this.docs[n];
if (cur.doc === doc) return cur;
}
if (!name) {
let n;
for (let i = 0; ; ++i) {
n = "[doc" + (i || "") + "]";
if (!this.docs[n]) {
name = n;
break;
}
}
}
return this.addDoc(name, doc);
}
addDoc(name: string, doc: CodeMirror.Doc) {
const data = { doc: doc, name: name, changed: null };
this.server.addFile(name, this.docValue(data));
CodeMirror.on(doc, "change", this.trackChange.bind(this));
return (this.docs[name] = data);
}
buildRequest(
doc: TernDoc,
query: {
type?: string;
types?: boolean;
docs?: boolean;
urls?: boolean;
origins?: boolean;
fullDocs?: any;
lineCharPositions?: any;
end?: any;
start?: any;
file?: any;
},
pos?: CodeMirror.Position,
) {
const files = [];
let offsetLines = 0;
const allowFragments = !query.fullDocs;
if (!allowFragments) delete query.fullDocs;
query.lineCharPositions = true;
if (!query.end) {
query.end = pos || doc.doc.getCursor("end");
if (doc.doc.somethingSelected()) {
query.start = doc.doc.getCursor("start");
}
}
const startPos = query.start || query.end;
if (doc.changed) {
if (
doc.doc.lineCount() > bigDoc &&
allowFragments &&
doc.changed.to - doc.changed.from < 100 &&
doc.changed.from <= startPos.line &&
doc.changed.to > query.end.line
) {
files.push(this.getFragmentAround(doc, startPos, query.end));
query.file = "#0";
offsetLines = files[0].offsetLines;
if (query.start) {
query.start = Pos(query.start.line - -offsetLines, query.start.ch);
}
query.end = Pos(query.end.line - offsetLines, query.end.ch);
} else {
files.push({
type: "full",
name: doc.name,
text: this.docValue(doc),
});
query.file = doc.name;
doc.changed = null;
}
} else {
query.file = doc.name;
}
for (const name in this.docs) {
const cur = this.docs[name];
if (cur.changed && cur !== doc) {
files.push({
type: "full",
name: cur.name,
text: this.docValue(cur),
});
cur.changed = null;
}
}
return { query: query, files: files };
}
trackChange(
doc: CodeMirror.Doc,
change: {
to: CodeMirror.Position;
from: CodeMirror.Position;
text: string | any[];
},
) {
const data = this.findDoc(doc);
const argHints = this.cachedArgHints;
if (
argHints &&
argHints.doc === doc &&
cmpPos(argHints.start, change.to) >= 0
)
this.cachedArgHints = null;
let changed = data.changed;
if (changed === null)
data.changed = changed = { from: change.from.line, to: change.from.line };
const end = change.from.line + (change.text.length - 1);
if (change.from.line < changed.to)
changed.to = changed.to - (change.to.line - end);
if (end >= changed.to) changed.to = end + 1;
if (changed.from > change.from.line) changed.from = change.from.line;
if (doc.lineCount() > bigDoc && changed.to - changed.from > 100)
setTimeout(() => {
if (data.changed && data.changed.to - data.changed.from > 100)
this.sendDoc(data);
}, 200);
}
sendDoc(doc: TernDoc) {
this.server.request(
// @ts-ignore
{ files: [{ type: "full", name: doc.name, text: this.docValue(doc) }] },
function(error: Error) {
if (error) window.console.error(error);
else doc.changed = null;
},
);
}
docValue(doc: TernDoc) {
return doc.doc.getValue();
}
getFragmentAround(
data: TernDoc,
start: CodeMirror.Position,
end: CodeMirror.Position,
) {
const doc = data.doc;
let minIndent = null;
let minLine = null;
let endLine;
const tabSize = 4;
for (let p = start.line - 1, min = Math.max(0, p - 50); p >= min; --p) {
const line = doc.getLine(p),
fn = line.search(/\bfunction\b/);
if (fn < 0) continue;
const indent = CodeMirror.countColumn(line, null, tabSize);
if (minIndent != null && minIndent <= indent) continue;
minIndent = indent;
minLine = p;
}
if (minLine === null) minLine = Math.max(0, start.line - 1);
const max = Math.min(doc.lastLine(), end.line + 20);
if (
minIndent === null ||
minIndent ===
CodeMirror.countColumn(doc.getLine(start.line), null, tabSize)
)
endLine = max;
else
for (endLine = end.line + 1; endLine < max; ++endLine) {
const indent = CodeMirror.countColumn(
doc.getLine(endLine),
null,
tabSize,
);
if (indent <= minIndent) break;
}
const from = Pos(minLine, 0);
return {
type: "part",
name: data.name,
offsetLines: from.line,
text: doc.getRange(
from,
Pos(endLine, end.line === endLine ? undefined : 0),
),
};
}
showError(cm: CodeMirror.Editor, msg: string) {
this.tempTooltip(cm, String(msg));
}
tempTooltip(cm: CodeMirror.Editor, content: HTMLElement | string) {
if (cm.state.ternTooltip) this.remove(cm.state.ternTooltip);
if (cm.state.completionActive) {
// @ts-ignore
cm.closeHint();
}
const where = cm.cursorCoords();
const tip = (cm.state.ternTooltip = this.makeTooltip(
// @ts-ignore
where.right + 1,
where.bottom,
content,
));
const maybeClear = () => {
old = true;
if (!mouseOnTip) clear();
};
const clear = () => {
cm.state.ternTooltip = null;
if (tip.parentNode) this.fadeOut(tip);
clearActivity();
};
let mouseOnTip = false;
let old = false;
CodeMirror.on(tip, "mousemove", function() {
mouseOnTip = true;
});
CodeMirror.on(tip, "mouseout", function(e: MouseEvent) {
const related = e.relatedTarget;
// @ts-ignore
if (!related || !CodeMirror.contains(tip, related)) {
if (old) clear();
else mouseOnTip = false;
}
});
setTimeout(maybeClear, hintDelay);
const clearActivity = this.onEditorActivity(cm, clear);
}
onEditorActivity(
cm: CodeMirror.Editor,
f: (instance: CodeMirror.Editor) => void,
) {
cm.on("cursorActivity", f);
cm.on("blur", f);
cm.on("scroll", f);
cm.on("setDoc", f);
return function() {
cm.off("cursorActivity", f);
cm.off("blur", f);
cm.off("scroll", f);
cm.off("setDoc", f);
};
}
makeTooltip(x: number, y: number, content: HTMLElement | string) {
const node = this.elt("div", cls + "tooltip", content);
node.style.left = x + "px";
node.style.top = y + "px";
document.body.appendChild(node);
return node;
}
remove(node?: HTMLElement) {
if (node) {
const p = node.parentNode;
if (p) p.removeChild(node);
}
}
elt(
tagName: string,
cls: string | null,
content: string | HTMLElement,
): HTMLElement {
const e = document.createElement(tagName);
if (cls) e.className = cls;
if (content) {
const eltNode =
typeof content === "string"
? document.createTextNode(content)
: content;
e.appendChild(eltNode);
}
return e;
}
fadeOut(tooltip: HTMLElement) {
tooltip.style.opacity = "0";
setTimeout(() => {
this.remove(tooltip);
}, 1100);
}
}
export default TernServer;

View File

@ -0,0 +1,60 @@
import {
generateTypeDef,
dataTreeTypeDefCreator,
} from "utils/autocomplete/dataTreeTypeDefCreator";
import { DataTree, ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
import { entityDefinitions } from "utils/autocomplete/EntityDefinitions";
describe("dataTreeTypeDefCreator", () => {
it("creates the right def for a widget", () => {
const dataTree: DataTree = {
Input1: {
widgetId: "yolo",
widgetName: "Input1",
parentId: "123",
renderMode: "CANVAS",
text: "yo",
type: "INPUT_WIDGET",
ENTITY_TYPE: ENTITY_TYPE.WIDGET,
parentColumnSpace: 1,
parentRowSpace: 2,
leftColumn: 2,
rightColumn: 3,
topRow: 1,
bottomRow: 2,
isLoading: false,
},
};
const def = dataTreeTypeDefCreator(dataTree);
// TODO hetu: needs better general testing
// instead of testing each widget maybe we can test to ensure
// that defs are in a correct format
expect(def.Input1).toBe(entityDefinitions.INPUT_WIDGET);
});
it("creates a correct def for an object", () => {
const obj = {
yo: "lo",
someNumber: 12,
someString: "123",
someBool: false,
unknownProp: undefined,
nested: {
someExtraNested: "yolo",
},
};
const expected = {
yo: "string",
someNumber: "number",
someString: "string",
someBool: "bool",
unknownProp: "?",
nested: {
someExtraNested: "string",
},
};
const objType = generateTypeDef(obj);
expect(objType).toStrictEqual(expected);
});
});

View File

@ -0,0 +1,70 @@
import { DataTree, ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
import _ from "lodash";
import { generateReactKey } from "utils/generators";
import {
entityDefinitions,
GLOBAL_DEFS,
GLOBAL_FUNCTIONS,
} from "utils/autocomplete/EntityDefinitions";
import { getType, Types } from "utils/TypeHelpers";
let extraDefs: any = {};
export const dataTreeTypeDefCreator = (dataTree: DataTree) => {
const def: any = {
"!name": "dataTree",
};
Object.keys(dataTree).forEach(entityName => {
const entity = dataTree[entityName];
if ("ENTITY_TYPE" in entity) {
if (entity.ENTITY_TYPE === ENTITY_TYPE.WIDGET) {
const widgetType = entity.type;
if (widgetType in entityDefinitions) {
const definition = _.get(entityDefinitions, widgetType);
if (_.isFunction(definition)) {
def[entityName] = definition(entity);
} else {
def[entityName] = definition;
}
}
}
if (entity.ENTITY_TYPE === ENTITY_TYPE.ACTION) {
def[entityName] = entityDefinitions.ACTION(entity);
}
}
});
def["!define"] = { ...GLOBAL_DEFS, ...extraDefs };
extraDefs = {};
return { ...def, ...GLOBAL_FUNCTIONS };
};
export function generateTypeDef(
obj: any,
): string | Record<string, string | object> {
const type = getType(obj);
switch (type) {
case Types.ARRAY: {
const arrayType = generateTypeDef(obj[0]);
const name = generateReactKey();
extraDefs[name] = arrayType;
return `[${name}]`;
}
case Types.OBJECT: {
const objType: Record<string, string | object> = {};
Object.keys(obj).forEach(k => {
objType[k] = generateTypeDef(obj[k]);
});
return objType;
}
case Types.STRING:
return "string";
case Types.NUMBER:
return "number";
case Types.BOOLEAN:
return "bool";
case Types.NULL:
case Types.UNDEFINED:
return "?";
default:
return "?";
}
}

View File

@ -2869,6 +2869,13 @@
dependencies:
"@types/estree" "*"
"@types/tern@0.22.0":
version "0.22.0"
resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.22.0.tgz#3b893a2368ca7db3452f0ce84f3fec1e7091e418"
integrity sha512-2nQnTbn924WLgnLFf2vWOUGqGGFcOYwW8f4w6PSXi9XDwzqEs04hhJ6hfv8TsyQJ1Bf6Rh7CQ+fB37z2ryw89A==
dependencies:
"@types/estree" "*"
"@types/tinycolor2@^1.4.2":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.2.tgz#721ca5c5d1a2988b4a886e35c2ffc5735b6afbdf"
@ -3306,9 +3313,10 @@ acorn@^3.1.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
acorn@^4.0.4, acorn@~4.0.2:
acorn@^4.0.4, acorn@^4.0.9, acorn@~4.0.2:
version "4.0.13"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787"
integrity sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=
acorn@^5.5.3:
version "5.7.3"
@ -5974,6 +5982,16 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0:
dependencies:
once "^1.4.0"
enhanced-resolve@^2.2.2:
version "2.3.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-2.3.0.tgz#a115c32504b6302e85a76269d7a57ccdd962e359"
integrity sha1-oRXDJQS2MC6Fp2Jp16V8zdli41k=
dependencies:
graceful-fs "^4.1.2"
memory-fs "^0.3.0"
object-assign "^4.0.1"
tapable "^0.2.3"
enhanced-resolve@^4.0.0, enhanced-resolve@^4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.1.tgz#2937e2b8066cd0fe7ce0990a98f0d71a35189f66"
@ -9492,6 +9510,14 @@ memoizerific@^1.11.3:
dependencies:
map-or-similar "^1.5.0"
memory-fs@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.3.0.tgz#7bcc6b629e3a43e871d7e29aca6ae8a7f15cbb20"
integrity sha1-e8xrYp46Q+hx1+Kaymrop/FcuyA=
dependencies:
errno "^0.1.3"
readable-stream "^2.0.1"
memory-fs@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"
@ -9658,7 +9684,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
dependencies:
@ -12808,6 +12834,11 @@ resolve-cwd@^2.0.0:
dependencies:
resolve-from "^3.0.0"
resolve-from@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57"
integrity sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=
resolve-from@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"
@ -13954,6 +13985,11 @@ table@^5.2.3:
slice-ansi "^2.1.0"
string-width "^3.0.0"
tapable@^0.2.3:
version "0.2.9"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.9.tgz#af2d8bbc9b04f74ee17af2b4d9048f807acd18a8"
integrity sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==
tapable@^1.0.0, tapable@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
@ -14019,6 +14055,17 @@ term-size@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753"
tern@^0.21.0:
version "0.21.0"
resolved "https://registry.yarnpkg.com/tern/-/tern-0.21.0.tgz#809c87a826e112494398cf8894f7c2d1b3464eb7"
integrity sha1-gJyHqCbhEklDmM+IlPfC0bNGTrc=
dependencies:
acorn "^4.0.9"
enhanced-resolve "^2.2.2"
glob "^7.1.1"
minimatch "^3.0.3"
resolve-from "2.0.0"
terser-webpack-plugin@2.3.4, terser-webpack-plugin@^2.1.2:
version "2.3.4"
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-2.3.4.tgz#ac045703bd8da0936ce910d8fb6350d0e1dee5fe"
@ -15349,6 +15396,13 @@ worker-rpc@^0.1.0:
dependencies:
microevent.ts "~0.1.1"
workerize-loader@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/workerize-loader/-/workerize-loader-1.2.0.tgz#0592d432ed58d9d12a7f2fa6cf8f9dc3b6326412"
integrity sha512-2RCaug+2QeLOepJNaI51ziYbjII4hvcbWEynTbyYGaCtYPbB3MNhAMebWoZCGGTmAJN0ORFCKP7eo5LCeRZqPg==
dependencies:
loader-utils "^1.2.3"
wrap-ansi@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"