Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Config UI: clean yaml on paste #19148

Merged
merged 1 commit into from
Feb 24, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/js/components/Config/CircuitsModal.vue
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
:description="$t('config.circuits.description')"
docs="/docs/features/loadmanagement"
:defaultYaml="defaultYaml"
removeKey="circuits"
endpoint="/config/circuits"
@changed="$emit('changed')"
/>
1 change: 1 addition & 0 deletions assets/js/components/Config/EebusModal.vue
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
:description="$t('config.eebus.description')"
docs="/docs/reference/configuration/eebus"
:defaultYaml="defaultYaml"
removeKey="eebus"
endpoint="/config/eebus"
@changed="$emit('changed')"
/>
4 changes: 2 additions & 2 deletions assets/js/components/Config/FormRow.vue
Original file line number Diff line number Diff line change
@@ -19,9 +19,9 @@
<div v-if="example" class="hyphenate">
{{ $t("config.form.example") }}: {{ example }}
</div>
<div v-if="help">
<div v-if="help" class="d-flex gap-1">
<Markdown :markdown="help" class="text-gray hyphenate" />
<a v-if="link" class="ms-1 text-gray" :href="link" target="_blank">
<a v-if="link" class="text-gray" :href="link" target="_blank">
{{ $t("config.general.docsLink") }}
</a>
</div>
1 change: 1 addition & 0 deletions assets/js/components/Config/HemsModal.vue
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@
docs="/docs/reference/configuration/hems"
:defaultYaml="defaultYaml"
endpoint="/config/hems"
removeKey="hems"
size="md"
@changed="$emit('changed')"
/>
1 change: 1 addition & 0 deletions assets/js/components/Config/MessagingModal.vue
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@
docs="/docs/reference/configuration/messaging"
:defaultYaml="defaultYaml"
endpoint="/config/messaging"
removeKey="messaging"
data-testid="messaging-modal"
@changed="$emit('changed')"
/>
1 change: 1 addition & 0 deletions assets/js/components/Config/ModbusProxyModal.vue
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@
docs="/docs/reference/configuration/modbusproxy"
:defaultYaml="defaultYaml"
endpoint="/config/modbusproxy"
removeKey="modbusproxy"
size="md"
@changed="$emit('changed')"
/>
11 changes: 9 additions & 2 deletions assets/js/components/Config/SponsorModal.vue
Original file line number Diff line number Diff line change
@@ -43,7 +43,7 @@
:help="
$t('config.sponsor.descriptionToken', { url: 'https://sponsor.evcc.io/' })
"
docs-link="/docs/sponsorship"
docs-link="/docs/sponsorship#trial"
class="mt-4"
>
<textarea
@@ -53,6 +53,7 @@
rows="5"
spellcheck="false"
class="form-control"
@paste="handlePaste"
/>
</FormRow>
</div>
@@ -67,7 +68,7 @@ import Sponsor, { VICTRON_DEVICE } from "../Sponsor.vue";
import SponsorTokenExpires from "../SponsorTokenExpires.vue";
import store from "../../store";
import { docsPrefix } from "../../i18n";
import { cleanYaml } from "../../utils/cleanYaml";
export default {
name: "SponsorModal",
components: { FormRow, JsonModal, Sponsor, SponsorTokenExpires },
@@ -96,6 +97,12 @@ export default {
transformReadValues() {
return { token: "" };
},
handlePaste(event) {
event.preventDefault();
const text = event.clipboardData.getData("text");
const cleaned = cleanYaml(text, "sponsortoken");
event.target.value = cleaned;
},
},
};
</script>
1 change: 1 addition & 0 deletions assets/js/components/Config/TariffsModal.vue
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
:description="$t('config.tariffs.description')"
docs="/docs/reference/configuration/tariffs"
:defaultYaml="defaultYaml"
removeKey="tariffs"
endpoint="/config/tariffs"
data-testid="tariffs-modal"
@changed="$emit('changed')"
15 changes: 15 additions & 0 deletions assets/js/components/Config/YamlEditor.vue
Original file line number Diff line number Diff line change
@@ -25,13 +25,16 @@

<script>
import { VueMonacoEditor, loader } from "@guolao/vue-monaco-editor";
import { cleanYaml } from "../../utils/cleanYaml";
const $html = document.querySelector("html");
export default {
name: "YamlEditor",
components: { VueMonacoEditor },
props: {
modelValue: String,
errorLine: Number,
removeKey: String,
disabled: Boolean,
},
emits: ["update:modelValue"],
@@ -50,6 +53,7 @@ export default {
overviewRulerLanes: 0,
},
active: true,
pasteDisposable: null,
};
},
computed: {
@@ -79,12 +83,23 @@ export default {
},
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();
8 changes: 7 additions & 1 deletion assets/js/components/Config/YamlModal.vue
Original file line number Diff line number Diff line change
@@ -9,7 +9,12 @@
<p v-if="error" class="text-danger" data-testid="error">{{ error }}</p>
<form ref="form" class="container mx-0 px-0">
<div class="editor-container" :style="{ height }">
<YamlEditor v-model="yaml" class="editor" :errorLine="errorLine" />
<YamlEditor
v-model="yaml"
class="editor"
:errorLine="errorLine"
:removeKey="removeKey"
/>
</div>

<div class="mt-4 d-flex justify-content-between">
@@ -54,6 +59,7 @@ export default {
docs: String,
endpoint: String,
defaultYaml: String,
removeKey: String,
size: { type: String, default: "xl" },
},
emits: ["changed"],
31 changes: 31 additions & 0 deletions assets/js/utils/cleanYaml.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { cleanYaml } from "./cleanYaml";

describe("cleanYaml", () => {
test("removes key from single line", () => {
const result = cleanYaml("key: value", "key");
expect(result).toBe("value");
});
test("removes key from multi-line yaml", () => {
const input = `key:\n nested: value\n another: thing`;
const expected = `nested: value\nanother: thing`;
const result = cleanYaml(input, "key");
expect(result).toBe(expected);
});
test("keep untouched if key not found", () => {
const result = cleanYaml("key: value", "not-found");
expect(result).toBe("key: value");
});
test("trim whitespace at the end of the line", () => {
const result = cleanYaml("key: \n - foo \n - bar ", "key");
expect(result).toBe("- foo\n- bar");
});
test("should remove leading comment lines", () => {
const result = cleanYaml("# this is\n# a comment\nkey: value", "key");
expect(result).toBe("value");
});
test("should note remove leading comment lines if key is not found", () => {
const result = cleanYaml("# this is\n# a comment\nnot-found: value", "key");
expect(result).toBe("# this is\n# a comment\nnot-found: value");
});
});
43 changes: 43 additions & 0 deletions assets/js/utils/cleanYaml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// accepts a multiline yaml string. if it starts with a key, the key will be removed, and the indent level will be reduced by one
// example 1: "key: value" -> "value"
// example 2:
// """
// key:
// foo: bar
// """
// will be transformed into:
// """
// foo: bar
// """
export function cleanYaml(text: string, removeKey: string) {
if (!removeKey) return text;
const result: string[] = [];

const prefix = removeKey + ":";
const lines = text.split("\n");

// remove first comment lines
while (lines[0].startsWith("#")) lines.shift();

const [firstLine, ...restLines] = lines;

if (!firstLine.startsWith(prefix)) {
// does not start with key, skip
return text;
} else {
const first = firstLine.slice(prefix.length).trim();
if (first) {
result.push(first);
}
}

if (restLines.length > 0) {
const indentChars = restLines[0].match(/^(\s+)/)?.[0] || "";
restLines
.map((l) => (l.startsWith(indentChars) ? l.slice(indentChars.length) : l))
.map((l) => l.trimEnd())
.forEach((l) => result.push(l));
}

return result.join("\n");
}