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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 658 additions and 71 deletions

View File

@ -0,0 +1,30 @@
export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => {
if (composedPath.some((el) => "tagName" in el && el.tagName === "HA-MENU")) {
return false;
}
const el = composedPath[0] as Element;
if (el.tagName === "TEXTAREA") {
return false;
}
if (el.parentElement?.tagName === "HA-SELECT") {
return false;
}
if (el.tagName !== "INPUT") {
return true;
}
switch ((el as HTMLInputElement).type) {
case "button":
case "checkbox":
case "hidden":
case "radio":
case "range":
return true;
default:
return false;
}
};

View File

@ -474,3 +474,16 @@ export const migrateAutomationAction = (
return action; return action;
}; };
export const normalizeScriptConfig = (config: ScriptConfig): ScriptConfig => {
// Normalize data: ensure sequence is a list
// Happens when people copy paste their scripts into the config
const value = config.sequence;
if (value && !Array.isArray(value)) {
config.sequence = [value];
}
if (config.sequence) {
config.sequence = migrateAutomationAction(config.sequence);
}
return config;
};

View File

@ -68,6 +68,16 @@ const _SHORTCUTS: Section[] = [
}, },
], ],
}, },
{
key: "ui.dialogs.shortcuts.automations.title",
items: [
{
type: "shortcut",
shortcut: [{ key: "ui.dialogs.shortcuts.shortcuts.ctrl_cmd" }, "V"],
key: "ui.dialogs.shortcuts.automations.paste",
},
],
},
{ {
key: "ui.dialogs.shortcuts.charts.title", key: "ui.dialogs.shortcuts.charts.title",
items: [ items: [

View File

@ -674,6 +674,12 @@ export default class HaAutomationActionRow extends LitElement {
ha-tooltip { ha-tooltip {
cursor: default; cursor: default;
} }
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`, `,
]; ];
} }

View File

@ -32,6 +32,8 @@ export default class HaAutomationAction extends LitElement {
@property({ attribute: false }) public actions!: Action[]; @property({ attribute: false }) public actions!: Action[];
@property({ attribute: false }) public highlightedActions?: Action[];
@state() private _showReorder = false; @state() private _showReorder = false;
@storage({ @storage({
@ -91,6 +93,7 @@ export default class HaAutomationAction extends LitElement {
@move-up=${this._moveUp} @move-up=${this._moveUp}
@value-changed=${this._actionChanged} @value-changed=${this._actionChanged}
.hass=${this.hass} .hass=${this.hass}
?highlight=${this.highlightedActions?.includes(action)}
> >
${this._showReorder && !this.disabled ${this._showReorder && !this.disabled
? html` ? html`

View File

@ -587,6 +587,12 @@ export default class HaAutomationConditionRow extends LitElement {
ha-md-menu-item > ha-svg-icon { ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px; --mdc-icon-size: 24px;
} }
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`, `,
]; ];
} }

View File

@ -30,6 +30,8 @@ export default class HaAutomationCondition extends LitElement {
@property({ attribute: false }) public conditions!: Condition[]; @property({ attribute: false }) public conditions!: Condition[];
@property({ attribute: false }) public highlightedConditions?: Condition[];
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@state() private _showReorder = false; @state() private _showReorder = false;
@ -140,6 +142,7 @@ export default class HaAutomationCondition extends LitElement {
@move-up=${this._moveUp} @move-up=${this._moveUp}
@value-changed=${this._conditionChanged} @value-changed=${this._conditionChanged}
.hass=${this.hass} .hass=${this.hass}
?highlight=${this.highlightedConditions?.includes(cond)}
> >
${this._showReorder && !this.disabled ${this._showReorder && !this.disabled
? html` ? html`

View File

@ -21,7 +21,7 @@ import {
import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators"; import { property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { transform } from "../../../common/decorators/transform"; import { transform } from "../../../common/decorators/transform";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
@ -81,6 +81,7 @@ import {
} from "./automation-save-dialog/show-dialog-automation-save"; } from "./automation-save-dialog/show-dialog-automation-save";
import "./blueprint-automation-editor"; import "./blueprint-automation-editor";
import "./manual-automation-editor"; import "./manual-automation-editor";
import type { HaManualAutomationEditor } from "./manual-automation-editor";
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@ -149,6 +150,9 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
@consume({ context: fullEntitiesContext, subscribe: true }) @consume({ context: fullEntitiesContext, subscribe: true })
_entityRegistry!: EntityRegistryEntry[]; _entityRegistry!: EntityRegistryEntry[];
@query("manual-automation-editor")
private _manualEditor?: HaManualAutomationEditor;
private _configSubscriptions: Record< private _configSubscriptions: Record<
string, string,
(config?: AutomationConfig) => void (config?: AutomationConfig) => void
@ -469,6 +473,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
.stateObj=${stateObj} .stateObj=${stateObj}
.config=${this._config} .config=${this._config}
.disabled=${Boolean(this._readOnly)} .disabled=${Boolean(this._readOnly)}
.dirty=${this._dirty}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></manual-automation-editor> ></manual-automation-editor>
`} `}
@ -552,7 +557,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
} as AutomationConfig; } as AutomationConfig;
this._entityId = undefined; this._entityId = undefined;
this._readOnly = false; this._readOnly = false;
this._dirty = true;
} }
if (changedProps.has("entityId") && this.entityId) { if (changedProps.has("entityId") && this.entityId) {
@ -952,6 +956,8 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
return; return;
} }
this._manualEditor?.resetPastedConfig();
const id = this.automationId || String(Date.now()); const id = this.automationId || String(Date.now());
if (!this.automationId) { if (!this.automationId) {
const saved = await this._promptAutomationAlias(); const saved = await this._promptAutomationAlias();

View File

@ -3,17 +3,30 @@ import { mdiHelpCircle } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { load } from "js-yaml";
import {
any,
array,
assert,
assign,
object,
optional,
string,
union,
} from "superstruct";
import { ensureArray } from "../../../common/array/ensure-array"; import { ensureArray } from "../../../common/array/ensure-array";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-markdown"; import "../../../components/ha-markdown";
import type { import type {
AutomationConfig,
Condition, Condition,
ManualAutomationConfig, ManualAutomationConfig,
Trigger, Trigger,
} from "../../../data/automation"; } from "../../../data/automation";
import { normalizeAutomationConfig } from "../../../data/automation";
import type { Action } from "../../../data/script"; import type { Action } from "../../../data/script";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
@ -29,6 +42,26 @@ import {
removeSearchParam, removeSearchParam,
} from "../../../common/url/search-params"; } from "../../../common/url/search-params";
import { constructUrlCurrentPath } from "../../../common/url/construct-url"; import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { showToast } from "../../../util/toast";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
const baseConfigStruct = object({
alias: optional(string()),
description: optional(string()),
triggers: optional(array(any())),
conditions: optional(array(any())),
actions: optional(array(any())),
mode: optional(string()),
max_exceeded: optional(string()),
id: optional(string()),
});
const automationConfigStruct = union([
assign(baseConfigStruct, object({ triggers: array(any()) })),
assign(baseConfigStruct, object({ conditions: array(any()) })),
assign(baseConfigStruct, object({ actions: array(any()) })),
]);
@customElement("manual-automation-editor") @customElement("manual-automation-editor")
export class HaManualAutomationEditor extends LitElement { export class HaManualAutomationEditor extends LitElement {
@ -44,6 +77,22 @@ export class HaManualAutomationEditor extends LitElement {
@property({ attribute: false }) public stateObj?: HassEntity; @property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public dirty = false;
@state() private _pastedConfig?: ManualAutomationConfig;
private _previousConfig?: ManualAutomationConfig;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("paste", this._handlePaste);
}
public disconnectedCallback() {
window.removeEventListener("paste", this._handlePaste);
super.disconnectedCallback();
}
protected firstUpdated(changedProps: PropertyValues): void { protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
const expanded = extractSearchParam("expanded"); const expanded = extractSearchParam("expanded");
@ -123,6 +172,7 @@ export class HaManualAutomationEditor extends LitElement {
role="region" role="region"
aria-labelledby="triggers-heading" aria-labelledby="triggers-heading"
.triggers=${this.config.triggers || []} .triggers=${this.config.triggers || []}
.highlightedTriggers=${this._pastedConfig?.triggers || []}
.path=${["triggers"]} .path=${["triggers"]}
@value-changed=${this._triggerChanged} @value-changed=${this._triggerChanged}
.hass=${this.hass} .hass=${this.hass}
@ -164,6 +214,7 @@ export class HaManualAutomationEditor extends LitElement {
role="region" role="region"
aria-labelledby="conditions-heading" aria-labelledby="conditions-heading"
.conditions=${this.config.conditions || []} .conditions=${this.config.conditions || []}
.highlightedConditions=${this._pastedConfig?.conditions || []}
.path=${["conditions"]} .path=${["conditions"]}
@value-changed=${this._conditionChanged} @value-changed=${this._conditionChanged}
.hass=${this.hass} .hass=${this.hass}
@ -203,6 +254,7 @@ export class HaManualAutomationEditor extends LitElement {
role="region" role="region"
aria-labelledby="actions-heading" aria-labelledby="actions-heading"
.actions=${this.config.actions || []} .actions=${this.config.actions || []}
.highlightedActions=${this._pastedConfig?.actions || []}
.path=${["actions"]} .path=${["actions"]}
@value-changed=${this._actionChanged} @value-changed=${this._actionChanged}
.hass=${this.hass} .hass=${this.hass}
@ -214,6 +266,7 @@ export class HaManualAutomationEditor extends LitElement {
private _triggerChanged(ev: CustomEvent): void { private _triggerChanged(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
this.resetPastedConfig();
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { ...this.config!, triggers: ev.detail.value as Trigger[] }, value: { ...this.config!, triggers: ev.detail.value as Trigger[] },
}); });
@ -221,6 +274,7 @@ export class HaManualAutomationEditor extends LitElement {
private _conditionChanged(ev: CustomEvent): void { private _conditionChanged(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
this.resetPastedConfig();
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { value: {
...this.config!, ...this.config!,
@ -231,6 +285,7 @@ export class HaManualAutomationEditor extends LitElement {
private _actionChanged(ev: CustomEvent): void { private _actionChanged(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
this.resetPastedConfig();
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { ...this.config!, actions: ev.detail.value as Action[] }, value: { ...this.config!, actions: ev.detail.value as Action[] },
}); });
@ -245,6 +300,152 @@ export class HaManualAutomationEditor extends LitElement {
}); });
} }
private _handlePaste = async (ev: ClipboardEvent) => {
if (!canOverrideAlphanumericInput(ev.composedPath())) {
return;
}
const paste = ev.clipboardData?.getData("text");
if (!paste) {
return;
}
const loaded: any = load(paste);
if (loaded) {
let normalized: AutomationConfig | undefined;
try {
normalized = normalizeAutomationConfig(loaded);
} catch (_err: any) {
return;
}
try {
assert(normalized, automationConfigStruct);
} catch (_err: any) {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.automation.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: "automation",
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: ManualAutomationConfig) {
// make a copy otherwise we will reference the original config
this._previousConfig = { ...this.config } as ManualAutomationConfig;
this._pastedConfig = config;
if (!this.config) {
return;
}
if ("triggers" in config) {
this.config.triggers = ensureArray(this.config.triggers || []).concat(
ensureArray(config.triggers)
);
}
if ("conditions" in config) {
this.config.conditions = ensureArray(this.config.conditions || []).concat(
ensureArray(config.conditions)
);
}
if ("actions" in config) {
this.config.actions = ensureArray(this.config.actions || []).concat(
ensureArray(config.actions)
) as Action[];
}
this._showPastedToastWithUndo();
fireEvent(this, "value-changed", {
value: {
...this.config!,
},
});
}
private _replaceExistingConfig(config: ManualAutomationConfig) {
// make a copy otherwise we will reference the original config
this._previousConfig = { ...this.config } as ManualAutomationConfig;
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.automation.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 { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -0,0 +1,101 @@
import { customElement, property, state } from "lit/decorators";
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../../types";
import { fireEvent } from "../../../../common/dom/fire_event";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import { createCloseHeading } from "../../../../components/ha-dialog";
import "../trigger/ha-automation-trigger-row";
import type { PasteReplaceDialogParams } from "./show-dialog-paste-replace";
@customElement("ha-dialog-paste-replace")
class DialogPasteReplace extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _params!: PasteReplaceDialogParams;
public showDialog(params: PasteReplaceDialogParams): void {
this._opened = true;
this._params = params;
}
public closeDialog() {
if (this._opened) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._opened = false;
return true;
}
public render() {
if (!this._opened) {
return nothing;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
`ui.panel.config.${this._params.domain}.editor.paste_confirm.title`
)
)}
>
<p>
${this.hass.localize(
`ui.panel.config.${this._params.domain}.editor.paste_confirm.text`
)}
</p>
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this._params?.pastedConfig}
read-only
></ha-yaml-editor>
<div slot="primaryAction">
<ha-button @click=${this._handleAppend}>
${this.hass.localize("ui.common.append")}
</ha-button>
<ha-button @click=${this._handleReplace}>
${this.hass.localize("ui.common.replace")}
</ha-button>
</div>
</ha-dialog>
`;
}
private _handleReplace() {
this._params?.onReplace();
this.closeDialog();
}
private _handleAppend() {
this._params?.onAppend();
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-paste-replace": DialogPasteReplace;
}
}

View File

@ -0,0 +1,28 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { AutomationConfig } from "../../../../data/automation";
import type { ScriptConfig } from "../../../../data/script";
export const loadPasteReplaceDialog = () => import("./dialog-paste-replace");
interface BasePasteReplaceDialogParams<D, T> {
domain: D;
pastedConfig: T;
onClose: () => void;
onAppend: () => void;
onReplace: () => void;
}
export type PasteReplaceDialogParams =
| BasePasteReplaceDialogParams<"automation", AutomationConfig>
| BasePasteReplaceDialogParams<"script", ScriptConfig>;
export const showPasteReplaceDialog = (
element: HTMLElement,
params: PasteReplaceDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-paste-replace",
dialogImport: loadPasteReplaceDialog,
dialogParams: params,
});
};

View File

@ -738,6 +738,12 @@ export default class HaAutomationTriggerRow extends LitElement {
ha-md-menu-item > ha-svg-icon { ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px; --mdc-icon-size: 24px;
} }
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`, `,
]; ];
} }

View File

@ -32,6 +32,8 @@ export default class HaAutomationTrigger extends LitElement {
@property({ attribute: false }) public triggers!: Trigger[]; @property({ attribute: false }) public triggers!: Trigger[];
@property({ attribute: false }) public highlightedTriggers?: Trigger[];
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@state() private _showReorder = false; @state() private _showReorder = false;
@ -92,6 +94,7 @@ export default class HaAutomationTrigger extends LitElement {
@value-changed=${this._triggerChanged} @value-changed=${this._triggerChanged}
.hass=${this.hass} .hass=${this.hass}
.disabled=${this.disabled} .disabled=${this.disabled}
?highlight=${this.highlightedTriggers?.includes(trg)}
> >
${this._showReorder && !this.disabled ${this._showReorder && !this.disabled
? html` ? html`

View File

@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import "@material/mwc-button"; import "@material/mwc-button";
import { import {
mdiCog, mdiCog,
@ -20,21 +21,23 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { property, query, state } from "lit/decorators"; import { property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { consume } from "@lit/context";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { slugify } from "../../../common/string/slugify"; import { slugify } from "../../../common/string/slugify";
import { computeRTL } from "../../../common/util/compute_rtl"; import { computeRTL } from "../../../common/util/compute_rtl";
import { afterNextRender } from "../../../common/util/render-status";
import { promiseTimeout } from "../../../common/util/promise-timeout"; import { promiseTimeout } from "../../../common/util/promise-timeout";
import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/ha-button-menu"; import "../../../components/ha-button-menu";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import { transform } from "../../../common/decorators/transform";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-list-item"; import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import "../../../components/ha-yaml-editor"; import "../../../components/ha-yaml-editor";
import { substituteBlueprint } from "../../../data/blueprint";
import { validateConfig } from "../../../data/config"; import { validateConfig } from "../../../data/config";
import { fullEntitiesContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity"; import { UNAVAILABLE } from "../../../data/entity";
import { import {
type EntityRegistryEntry, type EntityRegistryEntry,
@ -42,12 +45,12 @@ import {
} from "../../../data/entity_registry"; } from "../../../data/entity_registry";
import type { BlueprintScriptConfig, ScriptConfig } from "../../../data/script"; import type { BlueprintScriptConfig, ScriptConfig } from "../../../data/script";
import { import {
normalizeScriptConfig,
deleteScript, deleteScript,
fetchScriptFileConfig, fetchScriptFileConfig,
getScriptEditorInitData, getScriptEditorInitData,
getScriptStateConfig, getScriptStateConfig,
hasScriptFields, hasScriptFields,
migrateAutomationAction,
showScriptEditor, showScriptEditor,
triggerScript, triggerScript,
} from "../../../data/script"; } from "../../../data/script";
@ -58,21 +61,18 @@ import {
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog"; import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin"; import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { Entries, HomeAssistant, Route } from "../../../types"; import type { Entries, HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast"; import { showToast } from "../../../util/toast";
import { showAutomationModeDialog } from "../automation/automation-mode-dialog/show-dialog-automation-mode"; import { showAutomationModeDialog } from "../automation/automation-mode-dialog/show-dialog-automation-mode";
import type { EntityRegistryUpdate } from "../automation/automation-save-dialog/show-dialog-automation-save"; import type { EntityRegistryUpdate } from "../automation/automation-save-dialog/show-dialog-automation-save";
import { showAutomationSaveDialog } from "../automation/automation-save-dialog/show-dialog-automation-save"; import { showAutomationSaveDialog } from "../automation/automation-save-dialog/show-dialog-automation-save";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import "./blueprint-script-editor"; import "./blueprint-script-editor";
import "./manual-script-editor"; import "./manual-script-editor";
import type { HaManualScriptEditor } from "./manual-script-editor"; import type { HaManualScriptEditor } from "./manual-script-editor";
import { substituteBlueprint } from "../../../data/blueprint";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { fullEntitiesContext } from "../../../data/context";
import { transform } from "../../../common/decorators/transform";
export class HaScriptEditor extends SubscribeMixin( export class HaScriptEditor extends SubscribeMixin(
PreventUnsavedMixin(KeyboardShortcutMixin(LitElement)) PreventUnsavedMixin(KeyboardShortcutMixin(LitElement))
@ -427,6 +427,7 @@ export class HaScriptEditor extends SubscribeMixin(
.isWide=${this.isWide} .isWide=${this.isWide}
.config=${this._config} .config=${this._config}
.disabled=${this._readOnly} .disabled=${this._readOnly}
.dirty=${this._dirty}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></manual-script-editor> ></manual-script-editor>
`} `}
@ -499,12 +500,11 @@ export class HaScriptEditor extends SubscribeMixin(
...initData, ...initData,
} as ScriptConfig; } as ScriptConfig;
this._readOnly = false; this._readOnly = false;
this._dirty = true;
} }
if (changedProps.has("entityId") && this.entityId) { if (changedProps.has("entityId") && this.entityId) {
getScriptStateConfig(this.hass, this.entityId).then((c) => { getScriptStateConfig(this.hass, this.entityId).then((c) => {
this._config = this._normalizeConfig(c.config); this._config = normalizeScriptConfig(c.config);
this._checkValidation(); this._checkValidation();
}); });
const regEntry = this.entityRegistry.find( const regEntry = this.entityRegistry.find(
@ -543,25 +543,12 @@ export class HaScriptEditor extends SubscribeMixin(
); );
} }
private _normalizeConfig(config: ScriptConfig): ScriptConfig {
// Normalize data: ensure sequence is a list
// Happens when people copy paste their scripts into the config
const value = config.sequence;
if (value && !Array.isArray(value)) {
config.sequence = [value];
}
if (config.sequence) {
config.sequence = migrateAutomationAction(config.sequence);
}
return config;
}
private async _loadConfig() { private async _loadConfig() {
fetchScriptFileConfig(this.hass, this.scriptId!).then( fetchScriptFileConfig(this.hass, this.scriptId!).then(
(config) => { (config) => {
this._dirty = false; this._dirty = false;
this._readOnly = false; this._readOnly = false;
this._config = this._normalizeConfig(config); this._config = normalizeScriptConfig(config);
const entity = this.entityRegistry.find( const entity = this.entityRegistry.find(
(ent) => ent.platform === "script" && ent.unique_id === this.scriptId (ent) => ent.platform === "script" && ent.unique_id === this.scriptId
); );
@ -770,7 +757,7 @@ export class HaScriptEditor extends SubscribeMixin(
); );
const newConfig = { const newConfig = {
...this._normalizeConfig(result.substituted_config), ...normalizeScriptConfig(result.substituted_config),
alias: config.alias, alias: config.alias,
description: config.description, description: config.description,
}; };
@ -913,6 +900,8 @@ export class HaScriptEditor extends SubscribeMixin(
return; return;
} }
this._manualEditor?.resetPastedConfig();
if (!this.scriptId) { if (!this.scriptId) {
const saved = await this._promptScriptAlias(); const saved = await this._promptScriptAlias();
if (!saved) { if (!saved) {

View File

@ -353,6 +353,12 @@ export default class HaScriptFieldRow extends LitElement {
li[role="separator"] { li[role="separator"] {
border-bottom-color: var(--divider-color); border-bottom-color: var(--divider-color);
} }
:host([highlight]) ha-card {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
`, `,
]; ];
} }

View File

@ -20,6 +20,8 @@ export default class HaScriptFields extends LitElement {
@property({ attribute: false }) public fields!: Fields; @property({ attribute: false }) public fields!: Fields;
@property({ attribute: false }) public highlightedFields?: Fields;
private _focusLastActionOnChange = false; private _focusLastActionOnChange = false;
protected render() { protected render() {
@ -37,6 +39,7 @@ export default class HaScriptFields extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
@value-changed=${this._fieldChanged} @value-changed=${this._fieldChanged}
.hass=${this.hass} .hass=${this.hass}
?highlight=${this.highlightedFields?.[key] !== undefined}
> >
</ha-script-field-row> </ha-script-field-row>
` `

View File

@ -2,7 +2,18 @@ import "@material/mwc-button/mwc-button";
import { mdiHelpCircle } from "@mdi/js"; import { mdiHelpCircle } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } 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 { fireEvent } from "../../../common/dom/fire_event";
import { constructUrlCurrentPath } from "../../../common/url/construct-url"; import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { import {
@ -13,6 +24,7 @@ import "../../../components/ha-card";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-markdown"; import "../../../components/ha-markdown";
import type { Action, Fields, ScriptConfig } from "../../../data/script"; import type { Action, Fields, ScriptConfig } from "../../../data/script";
import { MODES, normalizeScriptConfig } from "../../../data/script";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url"; 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 type HaAutomationAction from "../automation/action/ha-automation-action";
import "./ha-script-fields"; import "./ha-script-fields";
import type HaScriptFields from "./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") @customElement("manual-script-editor")
export class HaManualScriptEditor extends LitElement { export class HaManualScriptEditor extends LitElement {
@ -33,11 +59,17 @@ export class HaManualScriptEditor extends LitElement {
@property({ attribute: false }) public config!: ScriptConfig; @property({ attribute: false }) public config!: ScriptConfig;
@property({ attribute: false }) public dirty = false;
@query("ha-script-fields") @query("ha-script-fields")
private _scriptFields?: HaScriptFields; private _scriptFields?: HaScriptFields;
private _openFields = false; private _openFields = false;
@state() private _pastedConfig?: ScriptConfig;
private _previousConfig?: ScriptConfig;
public addFields() { public addFields() {
this._openFields = true; this._openFields = true;
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
@ -126,6 +158,7 @@ export class HaManualScriptEditor extends LitElement {
role="region" role="region"
aria-labelledby="fields-heading" aria-labelledby="fields-heading"
.fields=${this.config.fields} .fields=${this.config.fields}
.highlightedFields=${this._pastedConfig?.fields}
@value-changed=${this._fieldsChanged} @value-changed=${this._fieldsChanged}
.hass=${this.hass} .hass=${this.hass}
.disabled=${this.disabled} .disabled=${this.disabled}
@ -154,6 +187,7 @@ export class HaManualScriptEditor extends LitElement {
role="region" role="region"
aria-labelledby="sequence-heading" aria-labelledby="sequence-heading"
.actions=${this.config.sequence || []} .actions=${this.config.sequence || []}
.highlightedActions=${this._pastedConfig?.sequence || []}
.path=${["sequence"]} .path=${["sequence"]}
@value-changed=${this._sequenceChanged} @value-changed=${this._sequenceChanged}
.hass=${this.hass} .hass=${this.hass}
@ -165,6 +199,7 @@ export class HaManualScriptEditor extends LitElement {
private _fieldsChanged(ev: CustomEvent): void { private _fieldsChanged(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
this.resetPastedConfig();
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { ...this.config!, fields: ev.detail.value as Fields }, value: { ...this.config!, fields: ev.detail.value as Fields },
}); });
@ -172,11 +207,165 @@ export class HaManualScriptEditor extends LitElement {
private _sequenceChanged(ev: CustomEvent): void { private _sequenceChanged(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
this.resetPastedConfig();
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { ...this.config!, sequence: ev.detail.value as Action[] }, 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 { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -14,6 +14,7 @@ import { showToast } from "../util/toast";
import type { HassElement } from "./hass-element"; import type { HassElement } from "./hass-element";
import { extractSearchParamsObject } from "../common/url/search-params"; import { extractSearchParamsObject } from "../common/url/search-params";
import { showVoiceCommandDialog } from "../dialogs/voice-command-dialog/show-ha-voice-command-dialog"; import { showVoiceCommandDialog } from "../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { canOverrideAlphanumericInput } from "../common/dom/can-override-input";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
@ -80,7 +81,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
private _showVoiceCommandDialog(e: KeyboardEvent) { private _showVoiceCommandDialog(e: KeyboardEvent) {
if ( if (
!this.hass?.enableShortcuts || !this.hass?.enableShortcuts ||
!this._canOverrideAlphanumericInput(e) || !canOverrideAlphanumericInput(e.composedPath()) ||
!this._conversation(this.hass.config.components) !this._conversation(this.hass.config.components)
) { ) {
return; return;
@ -113,7 +114,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
private async _createMyLink(e: KeyboardEvent) { private async _createMyLink(e: KeyboardEvent) {
if ( if (
!this.hass?.enableShortcuts || !this.hass?.enableShortcuts ||
!this._canOverrideAlphanumericInput(e) !canOverrideAlphanumericInput(e.composedPath())
) { ) {
return; return;
} }
@ -182,42 +183,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
return ( return (
this.hass?.user?.is_admin && this.hass?.user?.is_admin &&
this.hass.enableShortcuts && this.hass.enableShortcuts &&
this._canOverrideAlphanumericInput(e) canOverrideAlphanumericInput(e.composedPath())
); );
} }
private _canOverrideAlphanumericInput(e: KeyboardEvent) {
const composedPath = e.composedPath();
if (
composedPath.some((el) => "tagName" in el && el.tagName === "HA-MENU")
) {
return false;
}
const el = composedPath[0] as Element;
if (el.tagName === "TEXTAREA") {
return false;
}
if (el.parentElement?.tagName === "HA-SELECT") {
return false;
}
if (el.tagName !== "INPUT") {
return true;
}
switch ((el as HTMLInputElement).type) {
case "button":
case "checkbox":
case "hidden":
case "radio":
case "range":
return true;
default:
return false;
}
}
}; };

View File

@ -372,7 +372,9 @@
"default": "Default", "default": "Default",
"dont_save": "Don't save", "dont_save": "Don't save",
"copy": "Copy", "copy": "Copy",
"show": "Show" "show": "Show",
"replace": "Replace",
"append": "Append"
}, },
"components": { "components": {
"selectors": { "selectors": {
@ -1935,6 +1937,10 @@
"title": "Assist", "title": "Assist",
"open_assist": "open Assist dialog" "open_assist": "open Assist dialog"
}, },
"automations": {
"title": "Automations",
"paste": "to paste automation YAML from clipboard to automation editor"
},
"charts": { "charts": {
"title": "Charts", "title": "Charts",
"drag_to_zoom": "to zoom in part of a chart", "drag_to_zoom": "to zoom in part of a chart",
@ -4343,7 +4349,13 @@
"add_category": "Add category", "add_category": "Add category",
"add_labels": "Add labels", "add_labels": "Add labels",
"add_area": "Add area" "add_area": "Add area"
} },
"paste_confirm": {
"title": "Pasted automation",
"text": "How do you want to paste your automation?"
},
"paste_toast_message": "Pasted automation from clipboard",
"paste_invalid_config": "Pasted automation is not editable in the visual editor"
}, },
"trace": { "trace": {
"refresh": "[%key:ui::common::refresh%]", "refresh": "[%key:ui::common::refresh%]",
@ -4576,7 +4588,13 @@
"unsaved_new_text": "You can save your changes, or delete this script. You can't undo this action.", "unsaved_new_text": "You can save your changes, or delete this script. You can't undo this action.",
"unsaved_confirm_title": "Save changes?", "unsaved_confirm_title": "Save changes?",
"unsaved_confirm_text": "You have made some changes in this script. You can save these changes, or discard them and leave. You can't undo this action." "unsaved_confirm_text": "You have made some changes in this script. You can save these changes, or discard them and leave. You can't undo this action."
} },
"paste_confirm": {
"title": "Pasted script",
"text": "How do you want to paste your script?"
},
"paste_toast_message": "Pasted script from clipboard",
"paste_invalid_config": "Pasted script is not editable in the visual editor"
}, },
"trace": { "trace": {
"edit_script": "Edit script" "edit_script": "Edit script"