feat: Autocompletion hints in sql editor (#22827)
## Description This PR introduces autocompletion hints in the SQL editor Fixes #17441 Media <img width="600" alt="Screenshot 2023-05-07 at 14 31 11" src="https://user-images.githubusercontent.com/46670083/236755394-87eef153-8e20-4032-a96c-3fbaa1bdb4a2.png"> <img width="600" alt="Screenshot 2023-05-07 at 14 31 48" src="https://user-images.githubusercontent.com/46670083/236755411-6e63aaca-df6a-4b4e-91fe-cd5b1679d363.png"> ## Type of change > Please delete options that are not relevant. - New feature (non-breaking change which adds functionality) ## How Has This Been Tested? > Please describe the tests that you ran to verify your changes. Provide instructions, so we can reproduce. > Please also list any relevant details for your test configuration. > Delete anything that is not important - Manual - Jest - Cypress ### Test Plan https://github.com/appsmithorg/TestSmith/issues/2381 ### Issues raised during DP testing https://github.com/appsmithorg/appsmith/pull/22827#issuecomment-1536164809 ## Checklist: ### Dev activity - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] PR is being merged under a feature flag ### QA activity: - [ ] Test plan has been approved by relevant developers - [ ] Test plan has been peer reviewed by QA - [ ] Cypress test cases have been added and approved by either SDET or manual QA - [ ] Organized project review call with relevant stakeholders after Round 1/2 of QA - [ ] Added Test Plan Approved label after reveiwing all Cypress test
This commit is contained in:
parent
8810b0a8e2
commit
d9f1f59a99
|
|
@ -0,0 +1,25 @@
|
|||
const queryLocators = require("../../../../locators/QueryEditor.json");
|
||||
const datasource = require("../../../../locators/DatasourcesEditor.json");
|
||||
import { ObjectsRegistry } from "../../../../support/Objects/Registry";
|
||||
|
||||
const locator = ObjectsRegistry.CommonLocators;
|
||||
let datasourceName;
|
||||
|
||||
describe("SQL Autocompletion", function () {
|
||||
it("Shows autocompletion hints", function () {
|
||||
cy.NavigateToDatasourceEditor();
|
||||
cy.get(datasource.PostgreSQL).click();
|
||||
cy.fillPostgresDatasourceForm();
|
||||
|
||||
cy.generateUUID().then((uid) => {
|
||||
datasourceName = `Postgres CRUD ds ${uid}`;
|
||||
cy.renameDatasource(datasourceName);
|
||||
});
|
||||
cy.testSaveDatasource();
|
||||
cy.NavigateToActiveDSQueryPane(datasourceName);
|
||||
cy.get(queryLocators.templateMenu).click({ force: true });
|
||||
cy.get(".CodeMirror textarea").focus().type("S");
|
||||
cy.get(locator._hints).should("exist");
|
||||
cy.deleteQueryUsingContext();
|
||||
});
|
||||
});
|
||||
|
|
@ -84,9 +84,7 @@ describe("Validate CRUD queries for Postgres along with UI flow verifications",
|
|||
|
||||
cy.get(queryLocators.templateMenu).click({ force: true });
|
||||
//cy.typeValueNValidate(tableCreateQuery);//Since type method is slow for such big text - using paste!
|
||||
|
||||
cy.get(".CodeMirror textarea").paste(tableCreateQuery);
|
||||
cy.get(".CodeMirror textarea").focus();
|
||||
cy.get(".CodeMirror textarea").focus().paste(tableCreateQuery);
|
||||
cy.EvaluateCurrentValue(tableCreateQuery);
|
||||
cy.wait(3000);
|
||||
cy.runAndDeleteQuery(); //exeute actions - 200 response is verified in this method
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
"axios": "^0.27.2",
|
||||
"classnames": "^2.3.1",
|
||||
"clsx": "^1.2.1",
|
||||
"codemirror": "^5.59.2",
|
||||
"codemirror": "^5.65.13",
|
||||
"codemirror-graphql": "^1.2.14",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"core-js": "^3.9.1",
|
||||
|
|
|
|||
|
|
@ -5,17 +5,21 @@ import type { AutocompleteDataType } from "utils/autocomplete/AutocompleteDataTy
|
|||
import type { EntityNavigationData } from "selectors/navigationSelectors";
|
||||
import type { ExpectedValueExample } from "utils/validation/common";
|
||||
|
||||
export enum EditorModes {
|
||||
TEXT = "text/plain",
|
||||
SQL = "sql",
|
||||
TEXT_WITH_BINDING = "text-js",
|
||||
JSON = "application/json",
|
||||
JSON_WITH_BINDING = "json-js",
|
||||
SQL_WITH_BINDING = "sql-js",
|
||||
JAVASCRIPT = "javascript",
|
||||
GRAPHQL = "graphql",
|
||||
GRAPHQL_WITH_BINDING = "graphql-js",
|
||||
}
|
||||
import { editorSQLModes } from "./sql/config";
|
||||
|
||||
export const EditorModes = {
|
||||
TEXT: "text/plain",
|
||||
TEXT_WITH_BINDING: "text-js",
|
||||
JSON: "application/json",
|
||||
JSON_WITH_BINDING: "json-js",
|
||||
JAVASCRIPT: "javascript",
|
||||
GRAPHQL: "graphql",
|
||||
GRAPHQL_WITH_BINDING: "graphql-js",
|
||||
...editorSQLModes,
|
||||
} as const;
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
export type TEditorModes = ValueOf<typeof EditorModes>;
|
||||
|
||||
export enum EditorTheme {
|
||||
LIGHT = "LIGHT",
|
||||
|
|
@ -34,7 +38,7 @@ export enum EditorSize {
|
|||
|
||||
export type EditorConfig = {
|
||||
theme: EditorTheme;
|
||||
mode: EditorModes;
|
||||
mode: TEditorModes;
|
||||
tabBehaviour: TabBehaviour;
|
||||
size: EditorSize;
|
||||
hinting?: Array<HintHelper>;
|
||||
|
|
@ -55,7 +59,7 @@ export type FieldEntityInformation = {
|
|||
propertyPath?: string;
|
||||
blockCompletions?: Array<{ parentPath: string; subPath: string }>;
|
||||
example?: ExpectedValueExample;
|
||||
mode?: EditorModes;
|
||||
mode?: TEditorModes;
|
||||
};
|
||||
|
||||
export type HintHelper = (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type CodeMirror from "codemirror";
|
|||
import { ENTITY_TYPE } from "entities/AppsmithConsole";
|
||||
import type { WidgetEntity } from "entities/DataTree/dataTreeFactory";
|
||||
import type { ActionEntity } from "entities/DataTree/types";
|
||||
import { trim } from "lodash";
|
||||
import { getDynamicStringSegments } from "utils/DynamicBindingUtils";
|
||||
import { EditorSize } from "./EditorConfig";
|
||||
|
||||
|
|
@ -121,3 +122,16 @@ export const removeNewLineCharsIfRequired = (
|
|||
}
|
||||
return resultVal;
|
||||
};
|
||||
|
||||
// Checks if string at the position of the cursor is empty
|
||||
export function isCursorOnEmptyToken(editor: CodeMirror.Editor) {
|
||||
const currentCursorPosition = editor.getCursor();
|
||||
const { string: stringAtCurrentPosition } = editor.getTokenAt(
|
||||
currentCursorPosition,
|
||||
);
|
||||
const isEmptyString = !(
|
||||
stringAtCurrentPosition && trim(stringAtCurrentPosition)
|
||||
);
|
||||
|
||||
return isEmptyString;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import { theme } from "constants/DefaultTheme";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { sqlHint } from "./hintHelpers";
|
||||
|
||||
const hintContainerStyles: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
};
|
||||
const getHintIconStyles = (bgColor: string): React.CSSProperties => {
|
||||
return {
|
||||
background: bgColor,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
marginLeft: "3px",
|
||||
color: "#858282",
|
||||
lineHeight: "12px",
|
||||
};
|
||||
};
|
||||
|
||||
const hintStyles: React.CSSProperties = {
|
||||
paddingLeft: "5px",
|
||||
height: "24px",
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
lineHeight: "15px",
|
||||
letterSpacing: "-0.24px",
|
||||
fontSize: "12px",
|
||||
};
|
||||
|
||||
const hintLabelStyles: React.CSSProperties = {
|
||||
fontStyle: "italic",
|
||||
letterSpacing: "-0.24px",
|
||||
fontWeight: "normal",
|
||||
padding: "0 10px",
|
||||
lineHeight: "13px",
|
||||
fontSize: "10px",
|
||||
};
|
||||
|
||||
enum SQLHintType {
|
||||
unknown = "unknown",
|
||||
keyword = "keyword",
|
||||
text = "text",
|
||||
int4 = "int4",
|
||||
table = "table",
|
||||
}
|
||||
|
||||
const SQLDataTypeToBgColor: Record<
|
||||
string,
|
||||
NonNullable<React.CSSProperties["color"]>
|
||||
> = {
|
||||
unknown: theme.colors.dataTypeBg.unknown,
|
||||
keyword: theme.colors.dataTypeBg.object,
|
||||
text: theme.colors.dataTypeBg.number,
|
||||
int4: theme.colors.dataTypeBg.array,
|
||||
table: theme.colors.dataTypeBg.function,
|
||||
};
|
||||
function getHintDetailsFromClassName(
|
||||
text: string,
|
||||
className?: string,
|
||||
): {
|
||||
hintType: string;
|
||||
iconText: string;
|
||||
iconBg: string;
|
||||
} {
|
||||
switch (className) {
|
||||
case "CodeMirror-hint-table":
|
||||
const hintDataType = sqlHint.datasourceTableKeys[text];
|
||||
return hintDataType
|
||||
? {
|
||||
hintType: hintDataType,
|
||||
iconText: hintDataType.charAt(0).toLocaleUpperCase(),
|
||||
iconBg:
|
||||
SQLDataTypeToBgColor[hintDataType] ||
|
||||
SQLDataTypeToBgColor.unknown,
|
||||
}
|
||||
: {
|
||||
hintType: SQLHintType.unknown,
|
||||
iconText: "U",
|
||||
iconBg: SQLDataTypeToBgColor.unknown,
|
||||
};
|
||||
|
||||
case "CodeMirror-hint-keyword":
|
||||
return {
|
||||
hintType: SQLHintType.keyword,
|
||||
iconText: "K",
|
||||
iconBg: "#FFD6A5",
|
||||
};
|
||||
default:
|
||||
return { hintType: SQLHintType.unknown, iconText: "U", iconBg: "#4bb" };
|
||||
}
|
||||
}
|
||||
|
||||
// Avoiding styled components since ReactDOM.render cannot directly work with it
|
||||
export default function CustomHint({
|
||||
className,
|
||||
text,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { hintType, iconBg, iconText } = getHintDetailsFromClassName(
|
||||
text,
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<div style={hintContainerStyles}>
|
||||
<span style={getHintIconStyles(iconBg)}>{iconText}</span>
|
||||
<div style={hintStyles}>{text}</div>
|
||||
<span className="sql-hint-label" style={hintLabelStyles}>
|
||||
{hintType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderHint(
|
||||
LiElement: HTMLLIElement,
|
||||
text: string,
|
||||
className?: string,
|
||||
) {
|
||||
ReactDOM.render(<CustomHint className={className} text={text} />, LiElement);
|
||||
}
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
import type CodeMirror from "codemirror";
|
||||
import type { Hints } from "codemirror";
|
||||
import CodeMirror from "codemirror";
|
||||
import CodemirrorTernService from "utils/autocomplete/CodemirrorTernService";
|
||||
import KeyboardShortcuts from "constants/KeyboardShortcuts";
|
||||
import type { HintHelper } from "components/editorComponents/CodeEditor/EditorConfig";
|
||||
import { EditorModes } from "components/editorComponents/CodeEditor/EditorConfig";
|
||||
import AnalyticsUtil from "utils/AnalyticsUtil";
|
||||
import { checkIfCursorInsideBinding } from "components/editorComponents/CodeEditor/codeEditorUtils";
|
||||
import {
|
||||
checkIfCursorInsideBinding,
|
||||
isCursorOnEmptyToken,
|
||||
} from "components/editorComponents/CodeEditor/codeEditorUtils";
|
||||
import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
|
||||
import { isEmpty, isString } from "lodash";
|
||||
import type { getAllDatasourceTableKeys } from "selectors/entitiesSelector";
|
||||
import { renderHint } from "./customSQLHint";
|
||||
|
||||
export const bindingHint: HintHelper = (editor) => {
|
||||
editor.setOption("extraKeys", {
|
||||
|
|
@ -52,3 +60,92 @@ export const bindingHint: HintHelper = (editor) => {
|
|||
},
|
||||
};
|
||||
};
|
||||
|
||||
type HandleCompletions = (
|
||||
editor: CodeMirror.Editor,
|
||||
) =>
|
||||
| { showHints: false; completions: null }
|
||||
| { showHints: true; completions: Hints };
|
||||
|
||||
class SqlHintHelper {
|
||||
datasourceTableKeys: NonNullable<
|
||||
ReturnType<typeof getAllDatasourceTableKeys>
|
||||
> = {};
|
||||
tables: Record<string, string[]> = {};
|
||||
|
||||
constructor() {
|
||||
this.hinter = this.hinter.bind(this);
|
||||
this.setDatasourceTableKeys = this.setDatasourceTableKeys.bind(this);
|
||||
this.addCustomRendererToCompletions =
|
||||
this.addCustomRendererToCompletions.bind(this);
|
||||
this.generateTables = this.generateTables.bind(this);
|
||||
}
|
||||
|
||||
setDatasourceTableKeys(
|
||||
datasourceTableKeys: ReturnType<typeof getAllDatasourceTableKeys>,
|
||||
) {
|
||||
this.datasourceTableKeys = datasourceTableKeys || {};
|
||||
this.tables = this.generateTables(this.datasourceTableKeys);
|
||||
}
|
||||
hinter() {
|
||||
return {
|
||||
showHint: (editor: CodeMirror.Editor): boolean => {
|
||||
const { completions, showHints } = this.handleCompletions(editor);
|
||||
if (!showHints) return false;
|
||||
editor.showHint({
|
||||
hint: () => {
|
||||
return completions;
|
||||
},
|
||||
completeSingle: false,
|
||||
alignWithWord: false,
|
||||
extraKeys: {
|
||||
Tab: (editor: CodeMirror.Editor, handle) => {
|
||||
handle.pick();
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
generateTables(tableKeys: typeof this.datasourceTableKeys) {
|
||||
const tables: Record<string, string[]> = {};
|
||||
for (const tableKey of Object.keys(tableKeys)) {
|
||||
tables[`${tableKey}`] = [];
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
isSqlMode(editor: CodeMirror.Editor) {
|
||||
const editorMode = editor.getModeAt(editor.getCursor());
|
||||
return editorMode?.name === EditorModes.SQL;
|
||||
}
|
||||
|
||||
addCustomRendererToCompletions(completions: Hints): Hints {
|
||||
completions.list = completions.list.map((completion) => {
|
||||
if (isString(completion)) return completion;
|
||||
completion.render = (LiElement, data, currentHint) => {
|
||||
renderHint(LiElement, currentHint.text, currentHint.className);
|
||||
};
|
||||
return completion;
|
||||
});
|
||||
return completions;
|
||||
}
|
||||
|
||||
handleCompletions(editor: CodeMirror.Editor): ReturnType<HandleCompletions> {
|
||||
const noHints = { showHints: false, completions: null } as const;
|
||||
if (isCursorOnEmptyToken(editor) || !this.isSqlMode(editor)) return noHints;
|
||||
// @ts-expect-error: No types available
|
||||
const completions: Hints = CodeMirror.hint.sql(editor, {
|
||||
tables: this.tables,
|
||||
});
|
||||
if (isEmpty(completions.list)) return noHints;
|
||||
return {
|
||||
completions: this.addCustomRendererToCompletions(completions),
|
||||
showHints: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const sqlHint = new SqlHintHelper();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import "codemirror/addon/tern/tern.css";
|
|||
import "codemirror/addon/lint/lint";
|
||||
import "codemirror/addon/lint/lint.css";
|
||||
import "codemirror/addon/comment/comment";
|
||||
import "codemirror/mode/sql/sql.js";
|
||||
import "codemirror/addon/hint/show-hint";
|
||||
import "codemirror/addon/hint/show-hint.css";
|
||||
import "codemirror/addon/hint/sql-hint";
|
||||
import { getDataTreeForAutocomplete } from "selectors/dataTreeSelectors";
|
||||
import EvaluatedValuePopup from "components/editorComponents/CodeEditor/EvaluatedValuePopup";
|
||||
import type { WrappedFieldInputProps } from "redux-form";
|
||||
|
|
@ -33,6 +37,7 @@ import type {
|
|||
import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
|
||||
import { Skin } from "constants/DefaultTheme";
|
||||
import AnalyticsUtil from "utils/AnalyticsUtil";
|
||||
import "components/editorComponents/CodeEditor/sql/customMimes";
|
||||
import "components/editorComponents/CodeEditor/modes";
|
||||
import type {
|
||||
CodeEditorBorder,
|
||||
|
|
@ -66,7 +71,10 @@ import {
|
|||
PEEKABLE_LINE,
|
||||
PEEK_STYLE_PERSIST_CLASS,
|
||||
} from "components/editorComponents/CodeEditor/MarkHelpers/entityMarker";
|
||||
import { bindingHint } from "components/editorComponents/CodeEditor/hintHelpers";
|
||||
import {
|
||||
bindingHint,
|
||||
sqlHint,
|
||||
} from "components/editorComponents/CodeEditor/hintHelpers";
|
||||
import BindingPrompt from "./BindingPrompt";
|
||||
import { showBindingPrompt } from "./BindingPromptHelper";
|
||||
import { Button, ScrollIndicator } from "design-system-old";
|
||||
|
|
@ -139,6 +147,7 @@ import {
|
|||
} from "./utils/saveAndAutoIndent";
|
||||
import { getAssetUrl } from "@appsmith/utils/airgapHelpers";
|
||||
import { selectFeatureFlags } from "selectors/usersSelectors";
|
||||
import { getAllDatasourceTableKeys } from "selectors/entitiesSelector";
|
||||
|
||||
type ReduxStateProps = ReturnType<typeof mapStateToProps>;
|
||||
type ReduxDispatchProps = ReturnType<typeof mapDispatchToProps>;
|
||||
|
|
@ -249,7 +258,7 @@ const getEditorIdentifier = (props: EditorProps): string => {
|
|||
class CodeEditor extends Component<Props, State> {
|
||||
static defaultProps = {
|
||||
marking: [bindingMarker, entityMarker],
|
||||
hinting: [bindingHint, commandsHelper],
|
||||
hinting: [bindingHint, commandsHelper, sqlHint.hinter],
|
||||
lineCommentString: "//",
|
||||
};
|
||||
// this is the higlighted element for any highlighted text in the codemirror
|
||||
|
|
@ -426,6 +435,7 @@ class CodeEditor extends Component<Props, State> {
|
|||
}
|
||||
}, 200);
|
||||
}.bind(this);
|
||||
sqlHint.setDatasourceTableKeys(this.props.datasourceTableKeys);
|
||||
|
||||
// Finally create the Codemirror editor
|
||||
this.editor = CodeMirror(this.codeEditorTarget.current, options);
|
||||
|
|
@ -534,6 +544,9 @@ class CodeEditor extends Component<Props, State> {
|
|||
this.props.entitiesForNavigation,
|
||||
);
|
||||
}
|
||||
if (this.props.datasourceTableKeys !== prevProps.datasourceTableKeys) {
|
||||
sqlHint.setDatasourceTableKeys(this.props.datasourceTableKeys);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1459,6 +1472,7 @@ const mapStateToProps = (state: AppState, props: EditorProps) => ({
|
|||
props.dataTreePath?.split(".")[0],
|
||||
),
|
||||
featureFlags: selectFeatureFlags(state),
|
||||
datasourceTableKeys: getAllDatasourceTableKeys(state, props.dataTreePath),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => ({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import "codemirror/addon/mode/multiplex";
|
|||
import "codemirror/mode/javascript/javascript";
|
||||
import "codemirror/mode/sql/sql";
|
||||
import "codemirror/addon/hint/sql-hint";
|
||||
import { sqlModesConfig } from "./sql/config";
|
||||
|
||||
CodeMirror.defineMode(EditorModes.TEXT_WITH_BINDING, function (config) {
|
||||
// @ts-expect-error: Types are not available
|
||||
|
|
@ -33,20 +34,6 @@ CodeMirror.defineMode(EditorModes.JSON_WITH_BINDING, function (config) {
|
|||
);
|
||||
});
|
||||
|
||||
CodeMirror.defineMode(EditorModes.SQL_WITH_BINDING, function (config) {
|
||||
// @ts-expect-error: Types are not available
|
||||
return CodeMirror.multiplexingMode(
|
||||
CodeMirror.getMode(config, EditorModes.SQL),
|
||||
{
|
||||
open: "{{",
|
||||
close: "}}",
|
||||
mode: CodeMirror.getMode(config, {
|
||||
name: "javascript",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
CodeMirror.defineMode(EditorModes.GRAPHQL_WITH_BINDING, function (config) {
|
||||
// @ts-expect-error: Types are not available
|
||||
return CodeMirror.multiplexingMode(
|
||||
|
|
@ -67,3 +54,20 @@ CodeMirror.defineMode(EditorModes.GRAPHQL_WITH_BINDING, function (config) {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
for (const sqlModeConfig of Object.values(sqlModesConfig)) {
|
||||
if (!sqlModeConfig.isMultiplex) continue;
|
||||
CodeMirror.defineMode(sqlModeConfig.mode, function (config) {
|
||||
// @ts-expect-error: Types are not available
|
||||
return CodeMirror.multiplexingMode(
|
||||
CodeMirror.getMode(config, sqlModeConfig.mime),
|
||||
{
|
||||
open: "{{",
|
||||
close: "}}",
|
||||
mode: CodeMirror.getMode(config, {
|
||||
name: "javascript",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import { find } from "lodash";
|
||||
import type { TEditorModes } from "../EditorConfig";
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
export type TEditorSqlModes = ValueOf<typeof editorSQLModes>;
|
||||
|
||||
type SqlModeConfig = Record<
|
||||
TEditorSqlModes,
|
||||
{
|
||||
mime: string;
|
||||
mode: TEditorSqlModes;
|
||||
// CodeMirror.multiplexingMode
|
||||
isMultiplex: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export const editorSQLModes = {
|
||||
// SQL only
|
||||
SQL: "sql",
|
||||
// SQL flavour + JS
|
||||
SNOWFLAKE_WITH_BINDING: "snowflakesql-js",
|
||||
ARANGO_WITH_BINDING: "arangosql-js",
|
||||
REDIS_WITH_BINDING: "redissql-js",
|
||||
POSTGRESQL_WITH_BINDING: "pgsql-js",
|
||||
SQL_WITH_BINDING: "sql-js",
|
||||
MYSQL_WITH_BINDING: "mysql-js",
|
||||
MSSQL_WITH_BINDING: "mssql-js",
|
||||
PLSQL_WITH_BINDING: "plsql-js",
|
||||
} as const;
|
||||
|
||||
// Mime available in sql mode https://github.com/codemirror/codemirror5/blob/9974ded36bf01746eb2a00926916fef834d3d0d0/mode/sql/sql.js#L290
|
||||
export const sqlModesConfig: SqlModeConfig = {
|
||||
[editorSQLModes.SQL]: {
|
||||
mime: "sql",
|
||||
mode: editorSQLModes.SQL,
|
||||
isMultiplex: false,
|
||||
},
|
||||
[editorSQLModes.SQL_WITH_BINDING]: {
|
||||
mime: "text/x-sql",
|
||||
mode: editorSQLModes.SQL_WITH_BINDING,
|
||||
isMultiplex: true,
|
||||
},
|
||||
[editorSQLModes.MYSQL_WITH_BINDING]: {
|
||||
mime: "text/x-mysql",
|
||||
mode: editorSQLModes.MYSQL_WITH_BINDING,
|
||||
isMultiplex: true,
|
||||
},
|
||||
[editorSQLModes.MSSQL_WITH_BINDING]: {
|
||||
mime: "text/x-mssql",
|
||||
mode: editorSQLModes.MSSQL_WITH_BINDING,
|
||||
isMultiplex: true,
|
||||
},
|
||||
[editorSQLModes.PLSQL_WITH_BINDING]: {
|
||||
mime: "text/x-plsql",
|
||||
mode: editorSQLModes.PLSQL_WITH_BINDING,
|
||||
isMultiplex: true,
|
||||
},
|
||||
[editorSQLModes.POSTGRESQL_WITH_BINDING]: {
|
||||
mime: "text/x-pgsql",
|
||||
mode: editorSQLModes.POSTGRESQL_WITH_BINDING,
|
||||
isMultiplex: true,
|
||||
},
|
||||
// Custom mimes
|
||||
[editorSQLModes.SNOWFLAKE_WITH_BINDING]: {
|
||||
mime: "text/x-snowflakesql",
|
||||
mode: editorSQLModes.SNOWFLAKE_WITH_BINDING,
|
||||
isMultiplex: true,
|
||||
},
|
||||
[editorSQLModes.ARANGO_WITH_BINDING]: {
|
||||
mime: "text/x-arangosql",
|
||||
mode: editorSQLModes.ARANGO_WITH_BINDING,
|
||||
isMultiplex: true,
|
||||
},
|
||||
[editorSQLModes.REDIS_WITH_BINDING]: {
|
||||
mime: "text/x-redis",
|
||||
mode: editorSQLModes.REDIS_WITH_BINDING,
|
||||
isMultiplex: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const pluginNameToSqlMIME: Record<string, TEditorSqlModes> = {
|
||||
PostgreSQL: editorSQLModes.POSTGRESQL_WITH_BINDING,
|
||||
MySQL: editorSQLModes.MYSQL_WITH_BINDING,
|
||||
"Microsoft SQL Server": editorSQLModes.MSSQL_WITH_BINDING,
|
||||
Oracle: editorSQLModes.PLSQL_WITH_BINDING,
|
||||
Redshift: editorSQLModes.PLSQL_WITH_BINDING,
|
||||
Snowflake: editorSQLModes.SNOWFLAKE_WITH_BINDING,
|
||||
ArangoDB: editorSQLModes.ARANGO_WITH_BINDING,
|
||||
Redis: editorSQLModes.REDIS_WITH_BINDING,
|
||||
};
|
||||
|
||||
export function getSqlEditorModeFromPluginName(name: string) {
|
||||
return pluginNameToSqlMIME[name] ?? editorSQLModes.SQL_WITH_BINDING;
|
||||
}
|
||||
|
||||
export function getSqlMimeFromMode(mode: TEditorSqlModes) {
|
||||
const modeConfig = find(sqlModesConfig, { mode });
|
||||
return modeConfig?.mime ?? "text/x-sql";
|
||||
}
|
||||
|
||||
export function isSqlMode(mode: TEditorModes) {
|
||||
return !!Object.keys(sqlModesConfig).includes(mode);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import CodeMirror from "codemirror";
|
||||
import { merge } from "lodash";
|
||||
import { EditorModes } from "../../EditorConfig";
|
||||
import { getSqlMimeFromMode } from "../config";
|
||||
import { spaceSeparatedStringToObject } from "./utils";
|
||||
|
||||
// @ts-expect-error: No type available
|
||||
const defaultSQLConfig = CodeMirror.resolveMode("text/x-sql");
|
||||
|
||||
export const arangoKeywordsMap = {
|
||||
// https://www.arangodb.com/docs/stable/aql/fundamentals-syntax.html
|
||||
keywords: spaceSeparatedStringToObject(
|
||||
"for return filter search sort limit let collect window insert update replace remove upsert with aggregate all all_shortest_paths and any asc collect desc distinct false filter for graph in inbound insert into k_paths k_shortest_paths let like limit none not null or outbound remove replace return shortest_path sort true update upsert window with keep count options prune search to current new",
|
||||
),
|
||||
};
|
||||
const arangoConfig = merge(defaultSQLConfig, arangoKeywordsMap);
|
||||
|
||||
// Inspired by https://github.com/codemirror/codemirror5/blob/9974ded36bf01746eb2a00926916fef834d3d0d0/mode/sql/sql.js#L290
|
||||
CodeMirror.defineMIME(
|
||||
getSqlMimeFromMode(EditorModes.ARANGO_WITH_BINDING),
|
||||
arangoConfig,
|
||||
);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import "./arango";
|
||||
import "./snowflake";
|
||||
import "./redis";
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import CodeMirror from "codemirror";
|
||||
import { merge } from "lodash";
|
||||
import { EditorModes } from "../../EditorConfig";
|
||||
import { getSqlMimeFromMode } from "../config";
|
||||
import { spaceSeparatedStringToObject } from "./utils";
|
||||
|
||||
// @ts-expect-error: No type available
|
||||
const defaultSQLConfig = CodeMirror.resolveMode("text/x-sql");
|
||||
|
||||
export const redisKeywordsMap = {
|
||||
// https://redis.io/commands/
|
||||
keywords: spaceSeparatedStringToObject(
|
||||
"acl cat deluser dryrun genpass getuser list load log save setuser users whoami append asking auth bf.add bf.card bf.exists bf.info bf.insert bf.loadchunk bf.madd bf.mexists bf.reserve bf.scandump bgrewriteaof bgsave bitcount bitfield bitfield_ro bitop bitpos blmove blmpop blpop brpop brpoplpush bzmpop bzpopmax bzpopmin cf.add cf.addnx cf.count cf.del cf.exists cf.info cf.insert cf.insertnx cf.loadchunk cf.mexists cf.reserve cf.scandump client caching getname getredir id info kill no-evict no-touch pause reply setinfo setname tracking trackinginfo unblock unpause cluster addslots addslotsrange bumpepoch count-failure-reports countkeysinslot delslots delslotsrange failover flushslots forget getkeysinslot keyslot links meet myid myshardid nodes replicas replicate reset saveconfig set-config-epoch setslot shards slaves slots cms.incrby cms.info cms.initbydim cms.initbyprob cms.merge cms.query command count docs getkeys getkeysandflags config get resetstat rewrite set copy dbsize decr decrby del discard dump echo eval eval_ro evalsha evalsha_ro exec exists expire expireat expiretime fcall fcall_ro flushall flushdb ft._list ft.aggregate ft.aliasadd ft.aliasdel ft.aliasupdate ft.alter ft.create ft.dictadd ft.dictdel ft.dictdump ft.dropindex ft.explain ft.explaincli ft.info ft.profile ft.search ft.spellcheck ft.sugadd ft.sugdel ft.sugget ft.suglen ft.syndump ft.synupdate ft.tagvals function delete flush restore stats geoadd geodist geohash geopos georadius georadius_ro georadiusbymember georadiusbymember_ro geosearch geosearchstore getbit getdel getex getrange getset graph.config graph.constraint create drop graph.delete graph.explain graph.list graph.profile graph.query graph.ro_query graph.slowlog hdel hello hexists hget hgetall hincrby hincrbyfloat hkeys hlen hmget hmset hrandfield hscan hset hsetnx hstrlen hvals incr incrby incrbyfloat json.arrappend json.arrindex json.arrinsert json.arrlen json.arrpop json.arrtrim json.clear json.debug json.del json.forget json.get json.mget json.numincrby json.nummultby json.objkeys json.objlen json.resp json.set json.strappend json.strlen json.toggle json.type keys lastsave latency doctor graph histogram history latest lcs lindex linsert llen lmove lmpop lolwut lpop lpos lpush lpushx lrange lrem lset ltrim mget migrate module loadex unload monitor move mset msetnx multi object encoding freq idletime refcount persist pexpire pexpireat pexpiretime pfadd pfcount pfdebug pfmerge pfselftest ping psetex psubscribe psync pttl publish pubsub channels numpat numsub shardchannels shardnumsub punsubscribe quit randomkey readonly readwrite rename renamenx replconf replicaof restore-asking role rpop rpoplpush rpush rpushx sadd scan scard script debug sdiff sdiffstore select setbit setex setnx setrange shutdown sinter sintercard sinterstore sismember slaveof slowlog len smembers smismember smove sort sort_ro spop spublish srandmember srem sscan ssubscribe strlen subscribe substr sunion sunionstore sunsubscribe swapdb sync tdigest.add tdigest.byrank tdigest.byrevrank tdigest.cdf tdigest.create tdigest.info tdigest.max tdigest.merge tdigest.min tdigest.quantile tdigest.rank tdigest.reset tdigest.revrank tdigest.trimmed_mean time topk.add topk.count topk.incrby topk.info topk.list topk.query topk.reserve touch ts.add ts.alter ts.create ts.createrule ts.decrby ts.del ts.deleterule ts.get ts.incrby ts.info ts.madd ts.mget ts.mrange ts.mrevrange ts.queryindex ts.range ts.revrange ttl type unlink unsubscribe unwatch wait waitaof watch xack xadd xautoclaim xclaim xdel xgroup createconsumer delconsumer destroy setid xinfo consumers groups stream xlen xpending xrange xread xreadgroup xrevrange xsetid xtrim zadd zcard zcount zdiff zdiffstore zincrby zinter zintercard zinterstore zlexcount zmpop zmscore zpopmax zpopmin zrandmember zrange zrangebylex zrangebyscore zrangestore zrank zrem zremrangebylex zremrangebyrank zremrangebyscore zrevrange zrevrangebylex zrevrangebyscore zrevrank zscan zscore zunion zunionstore",
|
||||
),
|
||||
};
|
||||
const redisConfig = merge(defaultSQLConfig, redisKeywordsMap);
|
||||
|
||||
// Inspired by https://github.com/codemirror/codemirror5/blob/9974ded36bf01746eb2a00926916fef834d3d0d0/mode/sql/sql.js#L290
|
||||
CodeMirror.defineMIME(
|
||||
getSqlMimeFromMode(EditorModes.REDIS_WITH_BINDING),
|
||||
redisConfig,
|
||||
);
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import CodeMirror from "codemirror";
|
||||
import { merge } from "lodash";
|
||||
import { EditorModes } from "../../EditorConfig";
|
||||
import { getSqlMimeFromMode } from "../config";
|
||||
import { spaceSeparatedStringToObject } from "./utils";
|
||||
|
||||
// @ts-expect-error: No type available
|
||||
const defaultSQLConfig = CodeMirror.resolveMode("text/x-sql");
|
||||
|
||||
export const snowflakeKeywordsMap = {
|
||||
// Ref: https://docs.snowflake.com/en/sql-reference/reserved-keywords
|
||||
keywords: spaceSeparatedStringToObject(
|
||||
"account all alter and any as between by case cast check column connect connection constraint create cross current current_date current_time current_timestamp current_user database delete distinct drop else exists false following for from full grant group gscluster having ilike in increment inner insert intersect into is issue join lateral left like localtime localtimestamp minus natural not null of on or order organization qualify regexp revoke right rlike row rows sample schema select set some start table tablesample then to trigger true try_cast union unique update using values view when whenever where with",
|
||||
),
|
||||
};
|
||||
const snowflakeConfig = merge(defaultSQLConfig, snowflakeKeywordsMap);
|
||||
|
||||
// Inspired by https://github.com/codemirror/codemirror5/blob/9974ded36bf01746eb2a00926916fef834d3d0d0/mode/sql/sql.js#L290
|
||||
CodeMirror.defineMIME(
|
||||
getSqlMimeFromMode(EditorModes.SNOWFLAKE_WITH_BINDING),
|
||||
snowflakeConfig,
|
||||
);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export function spaceSeparatedStringToObject(str: string) {
|
||||
const result: Record<string, true> = {};
|
||||
const words = str.split(" ");
|
||||
for (const eachWord of words) result[eachWord] = true;
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import CodeMirror from "codemirror";
|
||||
import { getPlatformOS } from "utils/helpers";
|
||||
import type { TEditorModes } from "../EditorConfig";
|
||||
import { EditorModes } from "../EditorConfig";
|
||||
import { isSqlMode } from "../sql/config";
|
||||
import { KEYBOARD_SHORTCUTS_BY_PLATFORM } from "./keyboardShortcutConstants";
|
||||
|
||||
export const getCodeCommentKeyMap = () => {
|
||||
|
|
@ -8,14 +10,8 @@ export const getCodeCommentKeyMap = () => {
|
|||
return KEYBOARD_SHORTCUTS_BY_PLATFORM[platformOS].codeComment;
|
||||
};
|
||||
|
||||
export function getLineCommentString(mode: EditorModes) {
|
||||
switch (mode) {
|
||||
case EditorModes.SQL:
|
||||
case EditorModes.SQL_WITH_BINDING:
|
||||
return "--";
|
||||
default:
|
||||
return "//";
|
||||
}
|
||||
export function getLineCommentString(editorMode: TEditorModes) {
|
||||
return isSqlMode(editorMode) ? "--" : "//";
|
||||
}
|
||||
|
||||
// Most of the code below is copied from https://github.com/codemirror/codemirror5/blob/master/addon/comment/comment.js
|
||||
|
|
@ -56,10 +52,11 @@ const noOptions: CodeMirror.CommentOptions = {};
|
|||
/**
|
||||
* Gives index of the first non whitespace character in the line
|
||||
**/
|
||||
function firstNonWhitespace(str: string, mode: EditorModes) {
|
||||
function firstNonWhitespace(str: string, mode: TEditorModes) {
|
||||
const found = str.search(
|
||||
[EditorModes.JAVASCRIPT, EditorModes.TEXT_WITH_BINDING].includes(mode) &&
|
||||
str.includes(JS_FIELD_BEGIN)
|
||||
(
|
||||
[EditorModes.JAVASCRIPT, EditorModes.TEXT_WITH_BINDING] as TEditorModes[]
|
||||
).includes(mode) && str.includes(JS_FIELD_BEGIN)
|
||||
? JS_FIELD_BEGIN
|
||||
: nonWhitespace,
|
||||
);
|
||||
|
|
@ -127,7 +124,7 @@ function performLineCommenting(
|
|||
// we need to explicitly check if the SQL comment string is passed, make the mode SQL
|
||||
commentString === getLineCommentString(EditorModes.SQL)
|
||||
? EditorModes.SQL
|
||||
: (mode.name as EditorModes),
|
||||
: (mode.name as TEditorModes),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import React from "react";
|
|||
import type { BaseFieldProps } from "redux-form";
|
||||
import { Field } from "redux-form";
|
||||
import type { EditorStyleProps } from "components/editorComponents/CodeEditor";
|
||||
import type { CodeEditorBorder } from "components/editorComponents/CodeEditor/EditorConfig";
|
||||
import type {
|
||||
CodeEditorBorder,
|
||||
TEditorModes,
|
||||
} from "components/editorComponents/CodeEditor/EditorConfig";
|
||||
import {
|
||||
EditorModes,
|
||||
EditorSize,
|
||||
|
|
@ -16,7 +19,7 @@ class DynamicTextField extends React.Component<
|
|||
EditorStyleProps & {
|
||||
size?: EditorSize;
|
||||
tabBehaviour?: TabBehaviour;
|
||||
mode?: EditorModes;
|
||||
mode?: TEditorModes;
|
||||
theme?: EditorTheme;
|
||||
hoverInteraction?: boolean;
|
||||
border?: CodeEditorBorder;
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import {
|
|||
import { QUERY_EDITOR_FORM_NAME } from "@appsmith/constants/forms";
|
||||
import type { AppState } from "@appsmith/reducers";
|
||||
import styled from "styled-components";
|
||||
import { getPluginResponseTypes } from "selectors/entitiesSelector";
|
||||
import {
|
||||
getPluginResponseTypes,
|
||||
getPluginNameFromId,
|
||||
} from "selectors/entitiesSelector";
|
||||
import { actionPathFromName } from "components/formControls/utils";
|
||||
import type { EvaluationSubstitutionType } from "entities/DataTree/dataTreeFactory";
|
||||
import { getSqlEditorModeFromPluginName } from "components/editorComponents/CodeEditor/sql/config";
|
||||
|
||||
const Wrapper = styled.div`
|
||||
min-width: 380px;
|
||||
|
|
@ -59,12 +63,13 @@ class DynamicTextControl extends BaseControl<
|
|||
configProperty,
|
||||
evaluationSubstitutionType,
|
||||
placeholderText,
|
||||
pluginName,
|
||||
responseType,
|
||||
} = this.props;
|
||||
const dataTreePath = actionPathFromName(actionName, configProperty);
|
||||
const mode =
|
||||
responseType === "TABLE"
|
||||
? EditorModes.SQL_WITH_BINDING
|
||||
? getSqlEditorModeFromPluginName(pluginName)
|
||||
: EditorModes.JSON_WITH_BINDING;
|
||||
|
||||
return (
|
||||
|
|
@ -94,6 +99,7 @@ export interface DynamicTextFieldProps extends ControlProps {
|
|||
placeholderText?: string;
|
||||
evaluationSubstitutionType: EvaluationSubstitutionType;
|
||||
mutedHinting?: boolean;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState, props: DynamicTextFieldProps) => {
|
||||
|
|
@ -103,11 +109,13 @@ const mapStateToProps = (state: AppState, props: DynamicTextFieldProps) => {
|
|||
const actionName = valueSelector(state, "name");
|
||||
const pluginId = valueSelector(state, "datasource.pluginId");
|
||||
const responseTypes = getPluginResponseTypes(state);
|
||||
const pluginName = getPluginNameFromId(state, pluginId);
|
||||
|
||||
return {
|
||||
actionName,
|
||||
pluginId,
|
||||
responseType: responseTypes[pluginId],
|
||||
pluginName,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -287,4 +287,23 @@ export const CodemirrorHintStyles = createGlobalStyle<{
|
|||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAB1JREFUGFdjZICC/3sY/jO6MDAygvgwDpiGcWAqASvpC745SEL8AAAAAElFTkSuQmCC");
|
||||
}
|
||||
}
|
||||
.sql-hint-label{
|
||||
color: #6D6D6D;
|
||||
}
|
||||
|
||||
.CodeMirror-hint:hover{
|
||||
.sql-hint-label{
|
||||
color: #090707;
|
||||
}
|
||||
}
|
||||
.CodeMirror-hint-active{
|
||||
.sql-hint-label{
|
||||
color: #fff
|
||||
}
|
||||
}
|
||||
.CodeMirror-hint-active:hover{
|
||||
.sql-hint-label{
|
||||
color: #fff
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
import { InstallState } from "reducers/uiReducers/libraryReducer";
|
||||
import recommendedLibraries from "pages/Editor/Explorer/Libraries/recommendedLibraries";
|
||||
import type { TJSLibrary } from "workers/common/JSLibrary";
|
||||
import { getEntityNameAndPropertyPath } from "@appsmith/workers/Evaluation/evaluationUtils";
|
||||
|
||||
export const getEntities = (state: AppState): AppState["entities"] =>
|
||||
state.entities;
|
||||
|
|
@ -1004,3 +1005,35 @@ export const selectJSCollectionByName = (collectionName: string) =>
|
|||
(collection) => collection.config.name === collectionName,
|
||||
);
|
||||
});
|
||||
|
||||
export const getAllDatasourceTableKeys = createSelector(
|
||||
(state: AppState) => getDatasourcesStructure(state),
|
||||
(state: AppState) => getActions(state),
|
||||
(state: AppState, dataTreePath: string | undefined) => dataTreePath,
|
||||
(
|
||||
datasourceStructures: ReturnType<typeof getDatasourcesStructure>,
|
||||
actions: ReturnType<typeof getActions>,
|
||||
dataTreePath: string | undefined,
|
||||
) => {
|
||||
if (!dataTreePath || !datasourceStructures) return;
|
||||
const { entityName } = getEntityNameAndPropertyPath(dataTreePath);
|
||||
const action = find(actions, ({ config: { name } }) => name === entityName);
|
||||
if (!action) return;
|
||||
const datasource = action.config.datasource;
|
||||
const datasourceId = "id" in datasource ? datasource.id : undefined;
|
||||
if (!datasourceId || !(datasourceId in datasourceStructures)) return;
|
||||
const tables: Record<string, string> = {};
|
||||
const { tables: datasourceTable } = datasourceStructures[datasourceId];
|
||||
if (!datasourceTable) return;
|
||||
datasourceTable.forEach((table) => {
|
||||
if (table?.name) {
|
||||
tables[table.name] = "table";
|
||||
table.columns.forEach((column) => {
|
||||
tables[`${table.name}.${column.name}`] = column.type;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tables;
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8922,7 +8922,7 @@ __metadata:
|
|||
chalk: ^4.1.1
|
||||
classnames: ^2.3.1
|
||||
clsx: ^1.2.1
|
||||
codemirror: ^5.59.2
|
||||
codemirror: ^5.65.13
|
||||
codemirror-graphql: ^1.2.14
|
||||
compression-webpack-plugin: ^10.0.0
|
||||
copy-to-clipboard: ^3.3.1
|
||||
|
|
@ -11394,10 +11394,10 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"codemirror@npm:^5.59.2":
|
||||
version: 5.59.2
|
||||
resolution: "codemirror@npm:5.59.2"
|
||||
checksum: d9e49082fcfa26f4ff7be61d2b9df004c9ad3c61cc82b317283a92881b112aa52d67cce996bd31f38fe02f8c873bbbb0dc2614da05b34e7e36deeae285ff562e
|
||||
"codemirror@npm:^5.65.13":
|
||||
version: 5.65.13
|
||||
resolution: "codemirror@npm:5.65.13"
|
||||
checksum: 47060461edaebecd03b3fba4e73a30cdccc0c51ce3a3a05bafae3c9cafd682101383e94d77d54081eaf1ae18da5b74343e98343c637c52cea409956469039098
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user