Allow pasting YAML in automation/script editor directly (#24838)

* Allow pasting YAML in automation/script editor directly

* Highlight pasted items

* Change highlighting

* Also reset in script editor

* Show dialog when pasting to changed automation/script

* Add shortcuts to shortcuts dialog

* Use translated shortcut

* Change timeout and clear on save

* Fix imports

* Process code review

* Move paste logic to manual-*-editor
This commit is contained in:
Jan-Philipp Benecke
2025-04-29 18:28:07 +02:00
committed by GitHub
parent 29c11978b3
commit ab415188ba
19 changed files with 658 additions and 71 deletions

View File

@@ -2,7 +2,18 @@ import "@material/mwc-button/mwc-button";
import { mdiHelpCircle } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { load } from "js-yaml";
import {
any,
array,
assert,
enums,
number,
object,
optional,
string,
} from "superstruct";
import { fireEvent } from "../../../common/dom/fire_event";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import {
@@ -13,6 +24,7 @@ import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type { Action, Fields, ScriptConfig } from "../../../data/script";
import { MODES, normalizeScriptConfig } from "../../../data/script";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
@@ -20,6 +32,20 @@ import "../automation/action/ha-automation-action";
import type HaAutomationAction from "../automation/action/ha-automation-action";
import "./ha-script-fields";
import type HaScriptFields from "./ha-script-fields";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { showToast } from "../../../util/toast";
import { showPasteReplaceDialog } from "../automation/paste-replace-dialog/show-dialog-paste-replace";
import { ensureArray } from "../../../common/array/ensure-array";
const scriptConfigStruct = object({
alias: optional(string()),
description: optional(string()),
sequence: optional(array(any())),
icon: optional(string()),
mode: optional(enums([typeof MODES])),
max: optional(number()),
fields: optional(object()),
});
@customElement("manual-script-editor")
export class HaManualScriptEditor extends LitElement {
@@ -33,11 +59,17 @@ export class HaManualScriptEditor extends LitElement {
@property({ attribute: false }) public config!: ScriptConfig;
@property({ attribute: false }) public dirty = false;
@query("ha-script-fields")
private _scriptFields?: HaScriptFields;
private _openFields = false;
@state() private _pastedConfig?: ScriptConfig;
private _previousConfig?: ScriptConfig;
public addFields() {
this._openFields = true;
fireEvent(this, "value-changed", {
@@ -126,6 +158,7 @@ export class HaManualScriptEditor extends LitElement {
role="region"
aria-labelledby="fields-heading"
.fields=${this.config.fields}
.highlightedFields=${this._pastedConfig?.fields}
@value-changed=${this._fieldsChanged}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -154,6 +187,7 @@ export class HaManualScriptEditor extends LitElement {
role="region"
aria-labelledby="sequence-heading"
.actions=${this.config.sequence || []}
.highlightedActions=${this._pastedConfig?.sequence || []}
.path=${["sequence"]}
@value-changed=${this._sequenceChanged}
.hass=${this.hass}
@@ -165,6 +199,7 @@ export class HaManualScriptEditor extends LitElement {
private _fieldsChanged(ev: CustomEvent): void {
ev.stopPropagation();
this.resetPastedConfig();
fireEvent(this, "value-changed", {
value: { ...this.config!, fields: ev.detail.value as Fields },
});
@@ -172,11 +207,165 @@ export class HaManualScriptEditor extends LitElement {
private _sequenceChanged(ev: CustomEvent): void {
ev.stopPropagation();
this.resetPastedConfig();
fireEvent(this, "value-changed", {
value: { ...this.config!, sequence: ev.detail.value as Action[] },
});
}
public connectedCallback() {
super.connectedCallback();
window.addEventListener("paste", this._handlePaste);
}
public disconnectedCallback() {
window.removeEventListener("paste", this._handlePaste);
super.disconnectedCallback();
}
private _handlePaste = async (ev: ClipboardEvent) => {
// Ignore events on inputs/textareas
if (!canOverrideAlphanumericInput(ev.composedPath())) {
return;
}
const paste = ev.clipboardData?.getData("text");
if (!paste) {
return;
}
const loaded: any = load(paste);
if (loaded) {
let normalized: ScriptConfig | undefined;
try {
normalized = normalizeScriptConfig(loaded);
} catch (_err: any) {
return;
}
try {
assert(normalized, scriptConfigStruct);
} catch (_err: any) {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.script.editor.paste_invalid_config"
),
duration: 4000,
dismissable: true,
});
return;
}
if (normalized) {
ev.preventDefault();
if (this.dirty) {
const result = await new Promise<boolean>((resolve) => {
showPasteReplaceDialog(this, {
domain: "script",
pastedConfig: normalized,
onClose: () => resolve(false),
onAppend: () => {
this._appendToExistingConfig(normalized);
resolve(false);
},
onReplace: () => resolve(true),
});
});
if (!result) {
return;
}
}
// replace the config completely
this._replaceExistingConfig(normalized);
}
}
};
private _appendToExistingConfig(config: ScriptConfig) {
// make a copy otherwise we will reference the original config
this._previousConfig = { ...this.config } as ScriptConfig;
this._pastedConfig = config;
if (!this.config) {
return;
}
if ("fields" in config) {
this.config.fields = {
...this.config.fields,
...config.fields,
};
}
if ("sequence" in config) {
this.config.sequence = ensureArray(this.config.sequence || []).concat(
ensureArray(config.sequence)
) as Action[];
}
this._showPastedToastWithUndo();
fireEvent(this, "value-changed", {
value: {
...this.config,
},
});
}
private _replaceExistingConfig(config: ScriptConfig) {
// make a copy otherwise we will reference the original config
this._previousConfig = { ...this.config } as ScriptConfig;
this._pastedConfig = config;
this.config = config;
this._showPastedToastWithUndo();
fireEvent(this, "value-changed", {
value: {
...this.config,
},
});
}
private _showPastedToastWithUndo() {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.script.editor.paste_toast_message"
),
duration: 4000,
action: {
text: this.hass.localize("ui.common.undo"),
action: () => {
fireEvent(this, "value-changed", {
value: {
...this._previousConfig!,
},
});
this._previousConfig = undefined;
this._pastedConfig = undefined;
},
},
});
}
public resetPastedConfig() {
if (!this._previousConfig) {
return;
}
this._pastedConfig = undefined;
this._previousConfig = undefined;
showToast(this, {
message: "",
duration: 0,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,