Config UI: replace Monaco editor with bundled CodeMirror 6 (#30855)

This commit is contained in:
Michael Geers 2026-06-14 22:52:27 +02:00 • committed by GitHub
parent 7c98d6db25
commit 81a84fcf81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 387 additions and 208 deletions

View file

@ -1,41 +1,12 @@
<template>
<VueMonacoEditor
v-if="active"
ref="editor"
class="editor"
language="yaml"
:theme="theme"
:options="options"
:value="modelValue"
data-testid="yaml-editor"
@update:value="$emit('update:modelValue', $event)"
@mount="ready"
>
<template #default> {{ $t("config.editor.loading") }} </template>
<template #failure>
<textarea
class="form-control"
:rows="lines"
:value="modelValue"
:disabled="disabled"
data-testid="yaml-editor-fallback"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
</VueMonacoEditor>
<div ref="host" class="editor" data-testid="yaml-editor">
<div v-if="loading" class="editor-loading">{{ $t("config.editor.loading") }}</div>
</div>
</template>
<script>
import { VueMonacoEditor, loader } from "@guolao/vue-monaco-editor";
import { cleanYaml } from "@/utils/cleanYaml.js";
// don't bundle monaco-editor but ensure that it get updated regularly
import { packages } from "../../../../package-lock.json";
const monacoVersion = packages["node_modules/monaco-editor"].version;
const $html = document.querySelector("html");
export default {
name: "YamlEditor",
components: { VueMonacoEditor },
props: {
modelValue: String,
errorLine: Number,
@ -45,101 +16,117 @@ export default {
emits: ["update:modelValue"],
data() {
return {
theme: "vs",
defaultOptions: {
automaticLayout: true,
formatOnType: true,
formatOnPaste: true,
minimap: { enabled: false },
showFoldingControls: "always",
scrollBeyondLastLine: false,
wordWrap: "off",
wrappingStrategy: "advanced",
overviewRulerLanes: 0,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
},
active: true,
pasteDisposable: null,
loading: true,
};
},
computed: {
options() {
return { ...this.defaultOptions, readOnly: this.disabled };
},
lines() {
return (this.modelValue || "").split("\n").length;
},
},
watch: {
errorLine() {
// force rerender to update decorations
this.active = false;
this.$nextTick(() => (this.active = true));
modelValue(value) {
this.editor?.setDoc(value || "");
},
errorLine(line) {
this.editor?.setErrorLine(line || null);
},
disabled(value) {
this.editor?.setReadOnly(value);
},
},
mounted() {
this.updateTheme();
$html.addEventListener("themechange", this.updateTheme);
created() {
// kept off the reactive data() so Vue doesn't proxy the EditorView
this.editor = null;
this.destroyed = false;
},
beforeMount() {
loader.config({
paths: { vs: `https://cdn.jsdelivr.net/npm/monaco-editor@${monacoVersion}/min/vs` },
async mounted() {
const { createEditor } = await import("./codemirror");
// component may have been torn down while the chunk loaded
if (this.destroyed || !this.$refs.host) return;
this.editor = createEditor({
parent: this.$refs.host,
doc: this.modelValue || "",
readOnly: this.disabled,
onChange: (value) => this.$emit("update:modelValue", value),
getRemoveKey: () => this.removeKey,
});
loader.init();
this.loading = false;
if (this.errorLine) this.editor.setErrorLine(this.errorLine);
},
unmounted() {
$html.removeEventListener("themechange", this.updateTheme);
this.pasteDisposable?.dispose();
},
methods: {
updateTheme() {
this.theme = $html.classList.contains("dark") ? "vs-dark" : "vs";
},
ready(editor, monaco) {
const disposable = editor.onDidPaste(async () => {
if (!this.removeKey) return;
await this.$nextTick();
const model = editor.getModel();
const cleaned = cleanYaml(model.getValue(), this.removeKey);
model.setValue(cleaned);
});
this.pasteDisposable = disposable;
let decorations = null;
const highlight = () => {
decorations?.clear();
if (this.errorLine) {
decorations = editor.createDecorationsCollection([
{
range: new monaco.Range(this.errorLine, 1, this.errorLine, 1),
options: {
isWholeLine: true,
className: "error",
lineNumberClassName: "error",
marginClassName: "error",
},
},
]);
}
};
editor.onDidChangeModelContent(() => highlight());
highlight();
},
this.destroyed = true;
this.editor?.destroy();
},
};
</script>
<style scoped>
.editor :global(.monaco-editor) {
--vscode-editor-background: var(--evcc-box) !important;
--vscode-editorGutter-background: var(--evcc-box-border) !important;
}
.editor :global(.error) {
background-color: var(--bs-danger-50) !important;
}
.editor {
border: 1px solid var(--bs-border-color);
min-height: 150px;
}
.editor-loading {
padding: 0.5rem 0.75rem;
color: var(--bs-secondary-color);
}
/* editor chrome */
.editor :global(.cm-editor) {
height: 100%;
font-size: 13px;
background-color: var(--evcc-box);
color: var(--bs-body-color);
}
.editor :global(.cm-editor.cm-focused) {
outline: none;
}
.editor :global(.cm-content) {
font-family: var(--bs-font-monospace, monospace);
}
/* override CM's built-in light gutter colors (we don't load CM's dark chrome) */
.editor :global(.cm-gutters) {
background-color: var(--evcc-box-border) !important;
color: var(--bs-secondary-color) !important;
border: none;
padding-left: 6px;
}
.editor :global(.cm-activeLine),
.editor :global(.cm-activeLineGutter) {
background-color: transparent;
}
.editor :global(.cm-cursor) {
border-left-color: var(--bs-body-color);
}
.editor :global(.cm-selectionBackground),
.editor :global(.cm-focused .cm-selectionBackground) {
background-color: var(--evcc-gray-50);
}
.editor :global(.cm-errorLine) {
background-color: var(--bs-danger) !important;
}
/* keep error text readable over the red regardless of syntax colors */
.editor :global(.cm-errorLine),
.editor :global(.cm-errorLine span) {
color: var(--bs-white) !important;
}
/* syntax tokens (classHighlighter), bootstrap palette reads well on light and dark */
.editor :global(.tok-comment),
.editor :global(.tok-meta) {
color: var(--bs-gray-medium);
}
.editor :global(.tok-propertyName),
.editor :global(.tok-keyword) {
color: var(--bs-orange);
}
.editor :global(.tok-string),
.editor :global(.tok-string2) {
color: var(--bs-green);
}
.editor :global(.tok-bool),
.editor :global(.tok-atom),
.editor :global(.tok-number) {
color: var(--bs-purple);
}
.editor :global(.tok-invalid) {
color: var(--bs-red);
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<div class="editor-container" :style="{ height: computedHeight }">
<div class="editor-container">
<YamlEditor
v-if="!hidden"
v-model="localValue"
@ -28,11 +28,6 @@ export default {
localValue: this.modelValue,
};
},
computed: {
computedHeight() {
return Math.max(150, (this.localValue || "").split("\n").length * 18) + 22 + "px";
},
},
watch: {
modelValue: {
handler(newVal) {

View file

@ -0,0 +1,127 @@
// Separate module so YamlEditor.vue can dynamic-import it, keeping CodeMirror out of the main chunk.
import { EditorState, Compartment, StateField, StateEffect } from "@codemirror/state";
import { EditorView, lineNumbers, keymap, Decoration, type DecorationSet } from "@codemirror/view";
import {
indentOnInput,
foldGutter,
syntaxHighlighting,
bracketMatching,
} from "@codemirror/language";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { yaml } from "@codemirror/lang-yaml";
import { classHighlighter } from "@lezer/highlight";
import { cleanYaml } from "@/utils/cleanYaml";
// 1-based line, null clears
const setErrorLineEffect = StateEffect.define<number | null>();
const errorLineField = StateField.define<number | null>({
create: () => null,
update(value, tr) {
let next = value;
for (const e of tr.effects) {
if (e.is(setErrorLineEffect)) next = e.value;
}
return next;
},
});
function errorLineFrom(state: EditorState) {
const line = state.field(errorLineField);
if (!line || line < 1 || line > state.doc.lines) return null;
return state.doc.line(line);
}
const errorLineExtension = [
errorLineField,
EditorView.decorations.compute([errorLineField, "doc"], (state): DecorationSet => {
const line = errorLineFrom(state);
if (!line) return Decoration.none;
return Decoration.set([Decoration.line({ class: "cm-errorLine" }).range(line.from)]);
}),
];
export interface EditorOptions {
parent: HTMLElement;
doc: string;
readOnly: boolean;
onChange: (value: string) => void;
getRemoveKey: () => string | undefined;
}
export interface EditorController {
view: EditorView;
setDoc(text: string): void;
setErrorLine(line: number | null): void;
setReadOnly(readOnly: boolean): void;
destroy(): void;
}
export function createEditor(opts: EditorOptions): EditorController {
const readOnlyCompartment = new Compartment();
const readOnlyExt = (readOnly: boolean) => [
EditorState.readOnly.of(readOnly),
EditorView.editable.of(!readOnly),
];
const holder: { view?: EditorView } = {};
const state = EditorState.create({
doc: opts.doc ?? "",
extensions: [
lineNumbers(),
foldGutter(),
history(),
bracketMatching(),
yaml(),
indentOnInput(),
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
errorLineExtension,
// emits stable tok-* classes so colors/theme live in YamlEditor.vue CSS
syntaxHighlighting(classHighlighter),
readOnlyCompartment.of(readOnlyExt(opts.readOnly)),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const value = update.state.doc.toString();
// strip a pasted wrapper key (e.g. "power:") and unindent, matching the editor's removeKey
const pasted = update.transactions.some((tr) => tr.isUserEvent("input.paste"));
const removeKey = pasted ? opts.getRemoveKey() : undefined;
if (removeKey) {
const cleaned = cleanYaml(value, removeKey);
if (cleaned !== value) {
// dispatch outside the update cycle to avoid a re-entrant transaction
queueMicrotask(() => {
const view = holder.view;
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: cleaned },
});
});
return;
}
}
opts.onChange(value);
}),
],
});
const view = new EditorView({ state, parent: opts.parent });
holder.view = view;
return {
view,
setDoc(text: string) {
if (view.state.doc.toString() === text) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
},
setErrorLine(line: number | null) {
view.dispatch({ effects: setErrorLineEffect.of(line || null) });
},
setReadOnly(readOnly: boolean) {
view.dispatch({ effects: readOnlyCompartment.reconfigure(readOnlyExt(readOnly)) });
},
destroy() {
view.destroy();
},
};
}

200
package-lock.json generated
View file

@ -7,9 +7,14 @@
"name": "evcc",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.5",
"@formkit/drag-and-drop": "^0.6.0",
"@guolao/vue-monaco-editor": "1.5.5",
"@h2d2/shopicons": "^1.9.1",
"@lezer/highlight": "^1.2.1",
"@popperjs/core": "^2.11.8",
"@unhead/vue": "^2.1.15",
"axios": "^1.17.0",
@ -21,7 +26,6 @@
"countup.js": "^2.10.0",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"monaco-editor": "0.52.2",
"qrcode": "^1.5.4",
"smoothscroll-polyfill": "^0.4.4",
"snarkdown": "^2.0.0",
@ -1684,6 +1688,80 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@ -2309,52 +2387,6 @@
}
}
},
"node_modules/@guolao/vue-monaco-editor": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.5.5.tgz",
"integrity": "sha512-NFGImQ8dBYj6ehIxy1DngPRkctB9b6GbxvCm6aXZztNsgm/TtM4u+YM9ZwZHQPlXt7a4IODXoKCcTYEVycBSyA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0",
"vue-demi": "latest"
},
"peerDependencies": {
"@vue/composition-api": "^1.7.1",
"monaco-editor": ">=0.43.0",
"vue": "^2.6.14 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@guolao/vue-monaco-editor/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@h2d2/shopicons": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@h2d2/shopicons/-/shopicons-1.9.1.tgz",
@ -2661,15 +2693,47 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@monaco-editor/loader": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
"integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==",
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@ -6253,6 +6317,12 @@
"integrity": "sha512-QQpZx7oYxsR+OeITlZe46fY/OQjV11oBqjY8wgIXzLU2jIz8GzOrbMhqKLysGY8bWI3T1ZNrYkwGzKb4JNgyzg==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@ -9546,12 +9616,6 @@
"pathe": "^2.0.1"
}
},
"node_modules/monaco-editor": {
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
"license": "MIT"
},
"node_modules/mqtt": {
"version": "5.15.1",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz",
@ -11689,12 +11753,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -12010,6 +12068,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -13882,6 +13946,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wait-on": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz",

View file

@ -20,9 +20,14 @@
"type": "module",
"main": "index.js",
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.5",
"@formkit/drag-and-drop": "^0.6.0",
"@guolao/vue-monaco-editor": "1.5.5",
"@h2d2/shopicons": "^1.9.1",
"@lezer/highlight": "^1.2.1",
"@popperjs/core": "^2.11.8",
"@unhead/vue": "^2.1.15",
"axios": "^1.17.0",
@ -34,7 +39,6 @@
"countup.js": "^2.10.0",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"monaco-editor": "0.52.2",
"qrcode": "^1.5.4",
"smoothscroll-polyfill": "^0.4.4",
"snarkdown": "^2.0.0",
@ -43,9 +47,6 @@
"vue-i18n": "^11.4.5",
"vue-router": "^5.1.0"
},
"overrides": {
"@monaco-editor/loader": "1.5.0"
},
"engines": {
"npm": ">=10.0.0",
"node": ">=24.0.0"

View file

@ -174,13 +174,13 @@ energy:
);
// no errors
await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
await expect(editor.locator(".cm-errorLine")).toHaveCount(0);
const restResult = modal.getByTestId("test-result");
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("mapping values are not allowed in this context");
await expect(editor.locator(".line-numbers.error")).toHaveCount(1);
await expect(editor.locator(".cm-errorLine")).toHaveCount(1);
// invalid field error
await editorClear(editor);
@ -194,7 +194,7 @@ energy:
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("has invalid keys: apower");
await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
await expect(editor.locator(".cm-errorLine")).toHaveCount(0);
// unknown source error
await editorClear(editor);
@ -208,7 +208,7 @@ energy:
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("invalid plugin type: unknown");
await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
await expect(editor.locator(".cm-errorLine")).toHaveCount(0);
// missing required field error
await editorClear(editor);
@ -222,6 +222,6 @@ energy:
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("power: missing plugin source");
await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
await expect(editor.locator(".cm-errorLine")).toHaveCount(0);
});
});

View file

@ -42,6 +42,11 @@ test.describe("messaging", async () => {
start:
title: Charge started
msg: Started charging`;
// differs from the stored config so saving is an actual change
const changedYaml = `events:
start:
title: Charge started
msg: Charging has begun`;
// default content
const editor = modal.getByTestId("yaml-editor");
@ -55,7 +60,7 @@ test.describe("messaging", async () => {
// clear and enter valid yaml
await editorClear(editor);
await editorPaste(editor, page, validYaml);
await editorPaste(editor, page, changedYaml);
await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).not.toBeVisible();

View file

@ -18,25 +18,19 @@ export async function expectModalHidden(modal: Locator): Promise<void> {
}
export async function editorClear(editor: Locator): Promise<void> {
const firstLine = editor.locator(".view-line").nth(0);
const content = editor.locator(".view-lines");
const content = editor.locator(".cm-content");
// wait for the async content load, otherwise clearing races the arriving text
await expect(firstLine).not.toHaveText("");
// retry until the editor is verified empty, keystrokes are dropped while monaco initializes
const deadline = Date.now() + 10_000;
do {
await firstLine.click();
await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 50 });
await editor.page().keyboard.press("Backspace", { delay: 50 });
if ((await content.textContent())?.trim() === "") return;
} while (Date.now() < deadline);
await expect(content).not.toHaveText("");
await content.click();
await editor.page().keyboard.press("ControlOrMeta+KeyA");
await editor.page().keyboard.press("Backspace");
await expect(content, "editor should be empty after clearing").toHaveText("");
}
export async function editorPaste(editor: Locator, page: Page, text: string): Promise<void> {
await editor.locator(".view-line").nth(0).click();
await editor.locator(".cm-content").click();
await page.evaluate((text) => navigator.clipboard.writeText(text), text);
await page.keyboard.press("ControlOrMeta+KeyV", { delay: 100 });
await page.keyboard.press("ControlOrMeta+KeyV");
}
export enum LoadpointType {