fleet/frontend/components/SQLEditor/SQLEditor.tsx
Scott Gress 28ba274f1f
Fix SQL query editor cursor alignment issue (#28878)
for #27233 

# Checklist for submitter

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.

## Details

This PR fixes an issue where the cursor in the SQL editor would become
misaligned under some circumstances. I was never able to reproduce this
personally, but big thanks to @mason-buettner for both the reproduction
and testing this fix.

The issue seems to stem from the Ace editor having a hard time dealing
with CSS scaling. I'm not sure what circumstances actually cause this to
occur, but a combination of Google and ChatGPT lead me to
https://github.com/securingsincity/react-ace/issues/750 and
https://github.com/ajaxorg/ace/issues/4794 which I combined for this fix
which seems to work.
2025-05-20 16:56:52 -05:00

316 lines
8.7 KiB
TypeScript

import React, { ReactNode, useCallback, useRef } from "react";
import AceEditor from "react-ace";
import ReactAce from "react-ace/lib/ace";
import { IAceEditor } from "react-ace/lib/types";
import classnames from "classnames";
import "ace-builds/src-noconflict/mode-sql";
import "ace-builds/src-noconflict/ext-linking";
import "ace-builds/src-noconflict/ext-language_tools";
import { noop } from "lodash";
import ace, { Ace } from "ace-builds";
import {
osqueryTableNames,
selectedTableColumns,
} from "utilities/osquery_tables";
import {
checkTable,
sqlBuiltinFunctions,
sqlDataTypes,
sqlKeyWords,
} from "utilities/sql_tools";
import "./mode";
import "./theme";
export interface ISQLEditorProps {
focus?: boolean;
error?: string | null;
fontSize?: number;
label?: string;
name?: string;
value?: string;
placeholder?: string;
readOnly?: boolean;
maxLines?: number;
showGutter?: boolean;
wrapEnabled?: boolean;
/** @deprecated use the prop `className` instead */
wrapperClassName?: string;
className?: string;
helpText?: ReactNode;
labelActionComponent?: React.ReactNode;
style?: React.CSSProperties;
onBlur?: (editor?: IAceEditor) => void;
onLoad?: (editor: IAceEditor) => void;
onChange?: (value: string) => void;
handleSubmit?: () => void;
disabled?: boolean;
}
const baseClass = "sql-editor";
const SQLEditor = ({
focus,
error,
fontSize = 14,
label,
labelActionComponent,
name = "query-editor",
value,
placeholder,
readOnly: _readOnly = false,
maxLines = 20,
showGutter = true,
wrapEnabled = false,
wrapperClassName,
className,
helpText,
style,
onBlur,
onLoad,
onChange,
handleSubmit = noop,
disabled = false,
}: ISQLEditorProps): JSX.Element => {
const editorRef = useRef<ReactAce>(null);
const wrapperClass = classnames(className, wrapperClassName, baseClass, {
[`${baseClass}__wrapper--error`]: !!error,
[`${baseClass}__wrapper--disabled`]: disabled,
});
const fixHotkeys = (editor: IAceEditor) => {
editor.commands.removeCommand("gotoline");
editor.commands.removeCommand("find");
};
const langTools = ace.require("ace/ext/language_tools");
const readOnly = disabled || _readOnly;
// Error handling within checkTableValues
if (!readOnly) {
// Takes SQL and returns what table(s) are being used
const checkTableValues = checkTable(value);
// Update completers if no sql errors or the errors include syntax near table name
const updateCompleters =
!checkTableValues.error ||
checkTableValues.error
.toString()
.includes("Syntax error found near Identifier (FROM Clause)");
if (updateCompleters) {
langTools.setCompleters([]); // Reset completers as modifications are additive
// Autocomplete sql keywords, builtin functions, and datatypes
const sqlKeyWordsCompleter = {
getCompletions: (
editor: Ace.Editor,
session: Ace.EditSession,
pos: Ace.Point,
prefix: string,
callback: Ace.CompleterCallback
): void => {
callback(null, [
...sqlKeyWords.map(
(keyWord: string) =>
({
caption: `${keyWord}`,
value: keyWord.toUpperCase(),
meta: "keyword",
} as Ace.Completion)
),
...sqlBuiltinFunctions.map(
(builtInFunction: string) =>
({
caption: builtInFunction,
value: builtInFunction.toUpperCase(),
meta: "built-in function",
} as Ace.Completion)
),
...sqlDataTypes.map(
(dataType: string) =>
({
caption: dataType,
value: dataType.toUpperCase(),
meta: "data type",
} as Ace.Completion)
),
]);
},
};
langTools.addCompleter(sqlKeyWordsCompleter); // Add selected table columns or all columns
const sqlTableColumns = selectedTableColumns(
checkTableValues.tables || []
);
// Autocomplete table columns
const sqlTableColumnsCompleter = {
getCompletions: (
editor: Ace.Editor,
session: Ace.EditSession,
pos: Ace.Point,
prefix: string,
callback: Ace.CompleterCallback
): void => {
callback(
null,
sqlTableColumns.map(
(column: { name: string; description: string }) =>
({
caption: column.name, // Distinct values from tables,
value: column.name,
meta: `${column.description.slice(0, 15)}... Column`,
} as Ace.Completion)
)
);
},
};
langTools.addCompleter(sqlTableColumnsCompleter); // Add selected table columns or all columns
// Add all table name completers if no table name found
const updateTableNameCompleters =
!checkTableValues.tables?.length || !sqlTableColumns.length;
if (updateTableNameCompleters) {
// Autocomplete table names
const sqlTables = osqueryTableNames;
const sqlTablesCompleter = {
getCompletions: (
editor: Ace.Editor,
session: Ace.EditSession,
pos: Ace.Point,
prefix: string,
callback: Ace.CompleterCallback
): void => {
callback(
null,
sqlTables.map(
(table: string) =>
({
caption: `${table}`, // Distinct values from columns,
value: table,
meta: "Table",
score: 1,
} as Ace.Completion)
)
);
},
};
langTools.addCompleter(sqlTablesCompleter); // Add table name completers
}
}
}
const onLoadHandler = (editor: IAceEditor) => {
fixHotkeys(editor);
// Lose focus using the Escape key so you can Tab forward (or Shift+Tab backwards) through app
editor.commands.addCommand({
name: "escapeToBlur",
bindKey: { win: "Esc", mac: "Esc" },
exec: (aceEditor) => {
aceEditor.blur(); // Lose focus from the editor
return true;
},
readOnly: true,
});
onLoad && onLoad(editor);
};
const onBlurHandler = (event: any, editor?: IAceEditor): void => {
onBlur && onBlur(editor);
};
const handleDelete = (deleteCommand: string) => {
const selectedText = editorRef.current?.editor.getSelectedText();
if (selectedText) {
editorRef.current?.editor.removeWordLeft();
} else {
editorRef.current?.editor.execCommand(deleteCommand);
}
};
const renderLabel = useCallback(() => {
const labelText = error || label;
const labelClassName = classnames(`${baseClass}__label`, {
[`${baseClass}__label--error`]: !!error,
[`${baseClass}__label--with-action`]: !!labelActionComponent,
});
if (!label) {
return <></>;
}
return (
<div className={labelClassName}>
{labelText}
{labelActionComponent}
</div>
);
}, [error, label, labelActionComponent]);
const renderHelpText = () => {
if (helpText) {
return <span className={`${baseClass}__help-text`}>{helpText}</span>;
}
return false;
};
return (
<div className={wrapperClass}>
{renderLabel()}
<AceEditor
ref={editorRef}
enableBasicAutocompletion
enableLiveAutocompletion
editorProps={{ $blockScrolling: Infinity }}
fontSize={fontSize}
mode="fleet"
minLines={2}
maxLines={maxLines}
name={name}
onChange={onChange}
onBlur={onBlurHandler}
onLoad={onLoadHandler}
readOnly={readOnly}
setOptions={{ enableLinking: true, hasCssTransforms: true }}
showGutter={showGutter}
showPrintMargin={false}
theme="fleet"
value={value}
placeholder={placeholder}
width="100%"
wrapEnabled={wrapEnabled}
style={style}
focus={focus}
commands={[
{
name: "commandName",
bindKey: { win: "Ctrl-Enter", mac: "Ctrl-Enter" },
exec: handleSubmit,
},
{
name: "deleteSelection",
bindKey: { win: "Delete", mac: "Delete" },
exec: () => handleDelete("del"),
},
{
name: "backspaceSelection",
bindKey: { win: "Backspace", mac: "Backspace" },
exec: () => handleDelete("backspace"),
},
]}
/>
{renderHelpText()}
</div>
);
};
export default SQLEditor;