vmui: enhancements multiline field editing (#4294)

* fix: change textarea for relabel page

* feat: add comment for monaco theme

* fix: change behavior of multiline fields

* vmui: merge master
This commit is contained in:
Yury Molodov 2023-07-21 02:15:00 +02:00 committed by GitHub
parent 8470eb44de
commit c400acbd18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 323 additions and 37 deletions

View file

@ -8,6 +8,7 @@
"name": "vmui", "name": "vmui",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.5.1",
"@types/lodash.debounce": "^4.0.6", "@types/lodash.debounce": "^4.0.6",
"@types/lodash.get": "^4.4.6", "@types/lodash.get": "^4.4.6",
"@types/lodash.throttle": "^4.1.6", "@types/lodash.throttle": "^4.1.6",
@ -26,7 +27,7 @@
"preact": "^10.7.1", "preact": "^10.7.1",
"qs": "^6.10.3", "qs": "^6.10.3",
"react-input-mask": "^2.0.4", "react-input-mask": "^2.0.4",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.10.0",
"sass": "^1.56.0", "sass": "^1.56.0",
"source-map-explorer": "^2.5.3", "source-map-explorer": "^2.5.3",
"typescript": "~4.6.2", "typescript": "~4.6.2",
@ -3432,6 +3433,30 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"node_modules/@monaco-editor/loader": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz",
"integrity": "sha512-6KKF4CTzcJiS8BJwtxtfyYt9shBiEv32ateQ9T4UVogwn4HM/uPo9iJd2Dmbkpz8CM6Y0PDUpjnZzCwC+eYo2Q==",
"dependencies": {
"state-local": "^1.0.6"
},
"peerDependencies": {
"monaco-editor": ">= 0.21.0 < 1"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.5.1.tgz",
"integrity": "sha512-NNDFdP+2HojtNhCkRfE6/D6ro6pBNihaOzMbGK84lNWzRu+CfBjwzGt4jmnqimLuqp5yE5viHS2vi+QOAnD5FQ==",
"dependencies": {
"@monaco-editor/loader": "^1.3.3"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1", "version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@ -13171,6 +13196,12 @@
"mkdirp": "bin/cmd.js" "mkdirp": "bin/cmd.js"
} }
}, },
"node_modules/monaco-editor": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.40.0.tgz",
"integrity": "sha512-1wymccLEuFSMBvCk/jT1YDW/GuxMLYwnFwF9CDyYCxoTw2Pt379J3FUhwy9c43j51JdcxVPjwk0jm0EVDsBS2g==",
"peer": true
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -17066,6 +17097,11 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View file

@ -4,6 +4,7 @@
"private": true, "private": true,
"homepage": "./", "homepage": "./",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.5.1",
"@types/lodash.debounce": "^4.0.6", "@types/lodash.debounce": "^4.0.6",
"@types/lodash.get": "^4.4.6", "@types/lodash.get": "^4.4.6",
"@types/lodash.throttle": "^4.1.6", "@types/lodash.throttle": "^4.1.6",

View file

@ -1,2 +1,2 @@
export const getExpandWithExprUrl = (server: string, query: string): string => export const getExpandWithExprUrl = (server: string, query: string): string =>
`${server}/expand-with-exprs?query=${query}&format=json`; `${server}/expand-with-exprs?query=${encodeURIComponent(query)}&format=json`;

View file

@ -48,6 +48,9 @@ const QueryEditor: FC<QueryEditorProps> = ({
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, shiftKey } = e; const { key, ctrlKey, metaKey, shiftKey } = e;
const value = (e.target as HTMLTextAreaElement).value || "";
const isMultiline = value.split("\n").length > 1;
const ctrlMetaKey = ctrlKey || metaKey; const ctrlMetaKey = ctrlKey || metaKey;
const arrowUp = key === "ArrowUp"; const arrowUp = key === "ArrowUp";
const arrowDown = key === "ArrowDown"; const arrowDown = key === "ArrowDown";
@ -65,12 +68,21 @@ const QueryEditor: FC<QueryEditorProps> = ({
onArrowDown(); onArrowDown();
} }
if (enter && openAutocomplete) {
e.preventDefault();
}
// execute query // execute query
if (enter && !shiftKey && !openAutocomplete) { if (enter && !shiftKey && (!isMultiline || ctrlMetaKey) && !openAutocomplete) {
e.preventDefault();
onEnter(); onEnter();
} }
}; };
const handleChangeFoundOptions = (val: string[]) => {
setOpenAutocomplete(!!val.length);
};
return <div return <div
className="vm-query-editor" className="vm-query-editor"
ref={autocompleteAnchorEl} ref={autocompleteAnchorEl}
@ -93,7 +105,7 @@ const QueryEditor: FC<QueryEditorProps> = ({
options={options} options={options}
anchor={autocompleteAnchorEl} anchor={autocompleteAnchorEl}
onSelect={handleSelect} onSelect={handleSelect}
onOpenAutocomplete={setOpenAutocomplete} onFoundOptions={handleChangeFoundOptions}
/> />
)} )}
{showSeriesFetchedWarning && ( {showSeriesFetchedWarning && (

View file

@ -21,6 +21,7 @@ interface AutocompleteProps {
disabledFullScreen?: boolean disabledFullScreen?: boolean
onSelect: (val: string) => void onSelect: (val: string) => void
onOpenAutocomplete?: (val: boolean) => void onOpenAutocomplete?: (val: boolean) => void
onFoundOptions?: (val: string[]) => void
} }
const Autocomplete: FC<AutocompleteProps> = ({ const Autocomplete: FC<AutocompleteProps> = ({
@ -36,7 +37,8 @@ const Autocomplete: FC<AutocompleteProps> = ({
label, label,
disabledFullScreen, disabledFullScreen,
onSelect, onSelect,
onOpenAutocomplete onOpenAutocomplete,
onFoundOptions
}) => { }) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const wrapperEl = useRef<HTMLDivElement>(null); const wrapperEl = useRef<HTMLDivElement>(null);
@ -120,6 +122,10 @@ const Autocomplete: FC<AutocompleteProps> = ({
onOpenAutocomplete && onOpenAutocomplete(openAutocomplete); onOpenAutocomplete && onOpenAutocomplete(openAutocomplete);
}, [openAutocomplete]); }, [openAutocomplete]);
useEffect(() => {
onFoundOptions && onFoundOptions(foundOptions);
}, [foundOptions]);
return ( return (
<Popper <Popper
open={openAutocomplete} open={openAutocomplete}

View file

@ -59,9 +59,13 @@ const TextField: FC<TextFieldProps> = ({
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
onKeyDown && onKeyDown(e); onKeyDown && onKeyDown(e);
if (e.key === "Enter" && !e.shiftKey) {
const { key, ctrlKey, metaKey } = e;
const isEnter = key === "Enter";
const runByEnter = type !== "textarea" ? isEnter : isEnter && (metaKey || ctrlKey);
if (runByEnter && onEnter) {
e.preventDefault(); e.preventDefault();
onEnter && onEnter(); onEnter();
} }
}; };
@ -139,7 +143,8 @@ const TextField: FC<TextFieldProps> = ({
{helperText} {helperText}
</span> </span>
)} )}
</label>; </label>
;
}; };
export default TextField; export default TextField;

View file

@ -128,4 +128,13 @@
left: auto; left: auto;
right: $padding-small; right: $padding-small;
} }
&__controls-info {
position: absolute;
bottom: $padding-small;
right: $padding-global;
color: $color-text-secondary;
font-size: $font-size-small;
opacity: 0.8;
}
} }

View file

@ -0,0 +1,52 @@
import React, { FC } from "preact/compat";
import Editor, { useMonaco } from "@monaco-editor/react";
import useMonacoTheme from "./hooks/useMonacoTheme";
import useLabelsSyntax from "./hooks/useLabelsSyntax";
import useKeybindings from "./hooks/useKeybindings";
import "./style.scss";
import classNames from "classnames";
interface MonacoEditorProps {
value: string;
label?: string;
language?: string;
disabled?: boolean;
resize?: "vertical" | "horizontal" | "both" | "none";
onChange: (val: string | undefined) => void;
onEnter?: (val: string) => void;
}
const MonacoEditor: FC<MonacoEditorProps> = ({ value, label, language, disabled, resize = "none", onChange, onEnter }) => {
const monaco = useMonaco();
useMonacoTheme(monaco);
useLabelsSyntax(monaco);
useKeybindings(monaco, onEnter);
return (
<div className="vm-text-field vm-monaco-editor">
<Editor
className={classNames({
"vm-text-field__input": true,
"vm-monaco-editor__input": true,
[`vm-monaco-editor__input_resize-${resize}`]: resize,
})}
defaultLanguage={language}
value={value}
theme={"vm-theme"}
options={{
readOnly: disabled,
scrollBeyondLastLine: false,
automaticLayout: true,
lineNumbers: "off",
minimap: {
enabled: false
},
}}
onChange={onChange}
/>
{label && <span className="vm-text-field__label">{label}</span>}
</div>
);
};
export default MonacoEditor;

View file

@ -0,0 +1,22 @@
import { Monaco } from "@monaco-editor/react";
import { useEffect } from "preact/compat";
import * as monaco from "monaco-editor";
const useKeybindings = (monaco: Monaco | null, onEnter?: (val: string) => void) => {
const handleRunEnter = (e: monaco.editor.ICodeEditor) => {
onEnter && onEnter(e.getValue() || "");
};
useEffect(() => {
if (!monaco) return;
monaco.editor.addEditorAction({
id: "execute-ctrl-enter",
label: "Execute",
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
run: handleRunEnter
});
}, [monaco, onEnter]);
};
export default useKeybindings;

View file

@ -0,0 +1,84 @@
import { useEffect } from "preact/compat";
import { Monaco } from "@monaco-editor/react";
import * as monaco from "monaco-editor";
const languageId = "vm-labels";
export const language = {
ignoreCase: false,
defaultToken: "",
tokenizer: {
root: [
// labels
[ /[a-z_]\w*(?=\s*(=|!=|=~|!~))/, "tag" ],
// strings
[ /"([^"\\]|\\.)*$/, "string.invalid" ],
[ /'([^'\\]|\\.)*$/, "string.invalid" ],
[ /"/, "string", "@string_double" ],
[ /'/, "string", "@string_single" ],
[ /`/, "string", "@string_backtick" ],
// delimiters and operators
[ /[{}()[]]/, "@brackets" ],
],
string_double: [
[ /[^\\"]+/, "string" ],
[ /\\./, "string.escape.invalid" ],
[ /"/, "string", "@pop" ]
],
string_single: [
[ /[^\\']+/, "string" ],
[ /\\./, "string.escape.invalid" ],
[ /'/, "string", "@pop" ]
],
string_backtick: [
[ /[^\\`$]+/, "string" ],
[ /\\./, "string.escape.invalid" ],
[ /`/, "string", "@pop" ]
],
},
} as monaco.languages.IMonarchLanguage;
export const languageConfiguration: monaco.languages.LanguageConfiguration = {
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+[{\]}\\|;:'",.<>/?\s]+)/g,
comments: {
lineComment: "#",
},
brackets: [
[ "{", "}" ],
[ "[", "]" ],
[ "(", ")" ],
],
autoClosingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: "\"", close: "\"" },
{ open: "'", close: "'" },
],
surroundingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: "\"", close: "\"" },
{ open: "'", close: "'" },
{ open: "<", close: ">" },
],
folding: {}
};
const useLabelsSyntax = (monaco: Monaco | null) => {
useEffect(() => {
if (!monaco) return;
monaco.languages.register({ id: languageId });
monaco.languages.setMonarchTokensProvider(languageId, language);
monaco.languages.setLanguageConfiguration(languageId, languageConfiguration);
}, [monaco]);
};
export default useLabelsSyntax;

View file

@ -0,0 +1,25 @@
import { useEffect } from "preact/compat";
import { useAppState } from "../../../state/common/StateContext";
import { Monaco } from "@monaco-editor/react";
const useMonacoTheme = (monaco: Monaco | null) => {
const { isDarkTheme } = useAppState();
useEffect(() => {
if (!monaco) return;
monaco.editor.defineTheme("vm-theme", {
base: isDarkTheme ? "vs-dark" : "vs",
inherit: true,
rules: [],
colors: {
// #00000000 - for transparent
"editor.background": "#00000000",
"editor.lineHighlightBackground": "#00000000",
"editor.lineHighlightBorder": "#00000000"
}
});
monaco.editor.setTheme("vm-theme");
}, [monaco, isDarkTheme]);
};
export default useMonacoTheme;

View file

@ -0,0 +1,27 @@
@use "src/styles/variables" as *;
.vm-monaco-editor {
display: grid;
height: 100%;
min-height: inherit;
&__input {
height: 100%;
padding: $padding-small 0;
resize: none;
&_resize {
&-vertical {
resize: vertical;
}
&-horizontal {
resize: horizontal;
}
&-both {
resize: both;
}
}
}
}

View file

@ -1,6 +1,5 @@
import React, { FC, useEffect } from "preact/compat"; import React, { FC, useCallback, useEffect } from "preact/compat";
import "./style.scss"; import "./style.scss";
import TextField from "../../components/Main/TextField/TextField";
import Button from "../../components/Main/Button/Button"; import Button from "../../components/Main/Button/Button";
import { InfoIcon, PlayIcon, WikiIcon } from "../../components/Main/Icons"; import { InfoIcon, PlayIcon, WikiIcon } from "../../components/Main/Icons";
import "./style.scss"; import "./style.scss";
@ -9,6 +8,7 @@ import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert"; import Alert from "../../components/Main/Alert/Alert";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import useStateSearchParams from "../../hooks/useStateSearchParams"; import useStateSearchParams from "../../hooks/useStateSearchParams";
import MonacoEditor from "../../components/MonacoEditor/MonacoEditor";
const example = { const example = {
config: `- if: '{bar_label=~"b.*"}' config: `- if: '{bar_label=~"b.*"}'
@ -30,20 +30,20 @@ const Relabel: FC = () => {
const [config, setConfig] = useStateSearchParams("", "config"); const [config, setConfig] = useStateSearchParams("", "config");
const [labels, setLabels] = useStateSearchParams("", "labels"); const [labels, setLabels] = useStateSearchParams("", "labels");
const handleChangeConfig = (val: string) => { const handleChangeConfig = (val?: string) => {
setConfig(val); setConfig(val || "");
}; };
const handleChangeLabels = (val: string) => { const handleChangeLabels = (val?: string) => {
setLabels(val); setLabels(val || "");
}; };
const handleRunQuery = () => { const handleRunQuery = useCallback(() => {
fetchData(config, labels); fetchData(config, labels);
searchParams.set("config", config); searchParams.set("config", config);
searchParams.set("labels", labels); searchParams.set("labels", labels);
setSearchParams(searchParams); setSearchParams(searchParams);
}; }, [config, labels]);
const handleRunExample = () => { const handleRunExample = () => {
const { config, labels } = example; const { config, labels } = example;
@ -69,21 +69,24 @@ const Relabel: FC = () => {
<section className="vm-relabeling"> <section className="vm-relabeling">
{loading && <Spinner/>} {loading && <Spinner/>}
<div className="vm-relabeling-header vm-block"> <div className="vm-relabeling-header vm-block">
<div className="vm-relabeling-header__configs"> <div className="vm-relabeling-header-configs">
<TextField <MonacoEditor
type="textarea"
label="Relabel configs" label="Relabel configs"
value={config} value={config}
autofocus language={"yaml"}
resize={"vertical"}
onChange={handleChangeConfig} onChange={handleChangeConfig}
onEnter={handleRunQuery}
/> />
</div> </div>
<div className="vm-relabeling-header__labels"> <div className="vm-relabeling-header__labels">
<TextField <MonacoEditor
type="textarea"
label="Labels" label="Labels"
value={labels} value={labels}
language={"vm-labels"}
resize={"vertical"}
onChange={handleChangeLabels} onChange={handleChangeLabels}
onEnter={handleRunQuery}
/> />
</div> </div>
<div className="vm-relabeling-header-bottom"> <div className="vm-relabeling-header-bottom">

View file

@ -10,23 +10,13 @@
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
&__configs { &-configs {
textarea {
min-height: 200px; min-height: 200px;
} }
}
&__labels { &__labels {
textarea {
min-height: 60px; min-height: 60px;
} }
}
textarea {
overflow: auto;
width: 100%;
height: 100%;
}
&-bottom { &-bottom {
display: flex; display: flex;

View file

@ -99,6 +99,7 @@ const JsonForm: FC<JsonFormProps> = ({
error={error} error={error}
autofocus autofocus
onChange={handleChangeJson} onChange={handleChangeJson}
onEnter={handleApply}
disabled={!editable} disabled={!editable}
/> />
<div className="vm-json-form-footer"> <div className="vm-json-form-footer">

View file

@ -2,15 +2,19 @@ import { useAppState } from "../../../state/common/StateContext";
import { useState } from "react"; import { useState } from "react";
import { ErrorTypes } from "../../../types"; import { ErrorTypes } from "../../../types";
import { getExpandWithExprUrl } from "../../../api/expand-with-exprs"; import { getExpandWithExprUrl } from "../../../api/expand-with-exprs";
import { useSearchParams } from "react-router-dom";
export const useExpandWithExprs = () => { export const useExpandWithExprs = () => {
const { serverUrl } = useAppState(); const { serverUrl } = useAppState();
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState(""); const [data, setData] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>(); const [error, setError] = useState<ErrorTypes | string>();
const fetchData = async (query: string) => { const fetchData = async (query: string) => {
searchParams.set("expr", query);
setSearchParams(searchParams);
const fetchUrl = getExpandWithExprUrl(serverUrl, query); const fetchUrl = getExpandWithExprUrl(serverUrl, query);
setLoading(true); setLoading(true);
try { try {

View file

@ -1,16 +1,19 @@
import React, { FC } from "preact/compat"; import React, { FC } from "preact/compat";
import "./style.scss"; import "./style.scss";
import TextField from "../../components/Main/TextField/TextField"; import TextField from "../../components/Main/TextField/TextField";
import { useState } from "react"; import { useEffect, useState } from "react";
import Button from "../../components/Main/Button/Button"; import Button from "../../components/Main/Button/Button";
import { PlayIcon } from "../../components/Main/Icons"; import { PlayIcon } from "../../components/Main/Icons";
import WithTemplateTutorial from "./WithTemplateTutorial/WithTemplateTutorial"; import WithTemplateTutorial from "./WithTemplateTutorial/WithTemplateTutorial";
import { useExpandWithExprs } from "./hooks/useExpandWithExprs"; import { useExpandWithExprs } from "./hooks/useExpandWithExprs";
import Spinner from "../../components/Main/Spinner/Spinner"; import Spinner from "../../components/Main/Spinner/Spinner";
import { useSearchParams } from "react-router-dom";
const WithTemplate: FC = () => { const WithTemplate: FC = () => {
const [searchParams] = useSearchParams();
const { data, loading, error, expand } = useExpandWithExprs(); const { data, loading, error, expand } = useExpandWithExprs();
const [expr, setExpr] = useState(""); const [expr, setExpr] = useState(searchParams.get("expr") || "");
const handleChangeInput = (val: string) => { const handleChangeInput = (val: string) => {
setExpr(val); setExpr(val);
@ -20,6 +23,10 @@ const WithTemplate: FC = () => {
expand(expr); expand(expr);
}; };
useEffect(() => {
if (expr) expand(expr);
}, []);
return ( return (
<section className="vm-with-template"> <section className="vm-with-template">
{loading && <Spinner />} {loading && <Spinner />}
@ -32,6 +39,7 @@ const WithTemplate: FC = () => {
value={expr} value={expr}
error={error} error={error}
autofocus autofocus
onEnter={handleRunQuery}
onChange={handleChangeInput} onChange={handleChangeInput}
/> />
</div> </div>

View file

@ -26,6 +26,7 @@
} }
textarea { textarea {
font-family: $font-family-monospace;
overflow: auto; overflow: auto;
width: 100%; width: 100%;
height: 100%; height: 100%;