diff --git a/src/common/dom/can-override-input.ts b/src/common/dom/can-override-input.ts new file mode 100644 index 0000000000..d0896ba0a6 --- /dev/null +++ b/src/common/dom/can-override-input.ts @@ -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; + } +}; diff --git a/src/data/script.ts b/src/data/script.ts index 413b46c164..daf4878409 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -474,3 +474,16 @@ export const migrateAutomationAction = ( 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; +}; diff --git a/src/dialogs/shortcuts/dialog-shortcuts.ts b/src/dialogs/shortcuts/dialog-shortcuts.ts index c4b992fc52..99a3710ba6 100644 --- a/src/dialogs/shortcuts/dialog-shortcuts.ts +++ b/src/dialogs/shortcuts/dialog-shortcuts.ts @@ -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", items: [ diff --git a/src/panels/config/automation/action/ha-automation-action-row.ts b/src/panels/config/automation/action/ha-automation-action-row.ts index f079ef0762..4302972e52 100644 --- a/src/panels/config/automation/action/ha-automation-action-row.ts +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -674,6 +674,12 @@ export default class HaAutomationActionRow extends LitElement { ha-tooltip { 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); + } `, ]; } diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts index 8603ec411b..a70e45bac4 100644 --- a/src/panels/config/automation/action/ha-automation-action.ts +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -32,6 +32,8 @@ export default class HaAutomationAction extends LitElement { @property({ attribute: false }) public actions!: Action[]; + @property({ attribute: false }) public highlightedActions?: Action[]; + @state() private _showReorder = false; @storage({ @@ -91,6 +93,7 @@ export default class HaAutomationAction extends LitElement { @move-up=${this._moveUp} @value-changed=${this._actionChanged} .hass=${this.hass} + ?highlight=${this.highlightedActions?.includes(action)} > ${this._showReorder && !this.disabled ? html` diff --git a/src/panels/config/automation/condition/ha-automation-condition-row.ts b/src/panels/config/automation/condition/ha-automation-condition-row.ts index d1484d7499..4130c26c92 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-row.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-row.ts @@ -587,6 +587,12 @@ export default class HaAutomationConditionRow extends LitElement { ha-md-menu-item > ha-svg-icon { --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); + } `, ]; } diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index 77348cf308..5c3ed0f910 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -30,6 +30,8 @@ export default class HaAutomationCondition extends LitElement { @property({ attribute: false }) public conditions!: Condition[]; + @property({ attribute: false }) public highlightedConditions?: Condition[]; + @property({ type: Boolean }) public disabled = false; @state() private _showReorder = false; @@ -140,6 +142,7 @@ export default class HaAutomationCondition extends LitElement { @move-up=${this._moveUp} @value-changed=${this._conditionChanged} .hass=${this.hass} + ?highlight=${this.highlightedConditions?.includes(cond)} > ${this._showReorder && !this.disabled ? html` diff --git a/src/panels/config/automation/ha-automation-editor.ts b/src/panels/config/automation/ha-automation-editor.ts index dcd4202e04..ebf232fc29 100644 --- a/src/panels/config/automation/ha-automation-editor.ts +++ b/src/panels/config/automation/ha-automation-editor.ts @@ -21,7 +21,7 @@ import { import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues, TemplateResult } 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 { transform } from "../../../common/decorators/transform"; import { fireEvent } from "../../../common/dom/fire_event"; @@ -81,6 +81,7 @@ import { } from "./automation-save-dialog/show-dialog-automation-save"; import "./blueprint-automation-editor"; import "./manual-automation-editor"; +import type { HaManualAutomationEditor } from "./manual-automation-editor"; declare global { interface HTMLElementTagNameMap { @@ -149,6 +150,9 @@ export class HaAutomationEditor extends PreventUnsavedMixin( @consume({ context: fullEntitiesContext, subscribe: true }) _entityRegistry!: EntityRegistryEntry[]; + @query("manual-automation-editor") + private _manualEditor?: HaManualAutomationEditor; + private _configSubscriptions: Record< string, (config?: AutomationConfig) => void @@ -469,6 +473,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin( .stateObj=${stateObj} .config=${this._config} .disabled=${Boolean(this._readOnly)} + .dirty=${this._dirty} @value-changed=${this._valueChanged} > `} @@ -552,7 +557,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin( } as AutomationConfig; this._entityId = undefined; this._readOnly = false; - this._dirty = true; } if (changedProps.has("entityId") && this.entityId) { @@ -952,6 +956,8 @@ export class HaAutomationEditor extends PreventUnsavedMixin( return; } + this._manualEditor?.resetPastedConfig(); + const id = this.automationId || String(Date.now()); if (!this.automationId) { const saved = await this._promptAutomationAlias(); diff --git a/src/panels/config/automation/manual-automation-editor.ts b/src/panels/config/automation/manual-automation-editor.ts index 7799de3809..a064b65a8d 100644 --- a/src/panels/config/automation/manual-automation-editor.ts +++ b/src/panels/config/automation/manual-automation-editor.ts @@ -3,17 +3,30 @@ import { mdiHelpCircle } from "@mdi/js"; import type { HassEntity } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues } 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 { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-card"; import "../../../components/ha-icon-button"; import "../../../components/ha-markdown"; import type { + AutomationConfig, Condition, ManualAutomationConfig, Trigger, } from "../../../data/automation"; +import { normalizeAutomationConfig } from "../../../data/automation"; import type { Action } from "../../../data/script"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; @@ -29,6 +42,26 @@ import { removeSearchParam, } from "../../../common/url/search-params"; 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") export class HaManualAutomationEditor extends LitElement { @@ -44,6 +77,22 @@ export class HaManualAutomationEditor extends LitElement { @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 { super.firstUpdated(changedProps); const expanded = extractSearchParam("expanded"); @@ -123,6 +172,7 @@ export class HaManualAutomationEditor extends LitElement { role="region" aria-labelledby="triggers-heading" .triggers=${this.config.triggers || []} + .highlightedTriggers=${this._pastedConfig?.triggers || []} .path=${["triggers"]} @value-changed=${this._triggerChanged} .hass=${this.hass} @@ -164,6 +214,7 @@ export class HaManualAutomationEditor extends LitElement { role="region" aria-labelledby="conditions-heading" .conditions=${this.config.conditions || []} + .highlightedConditions=${this._pastedConfig?.conditions || []} .path=${["conditions"]} @value-changed=${this._conditionChanged} .hass=${this.hass} @@ -203,6 +254,7 @@ export class HaManualAutomationEditor extends LitElement { role="region" aria-labelledby="actions-heading" .actions=${this.config.actions || []} + .highlightedActions=${this._pastedConfig?.actions || []} .path=${["actions"]} @value-changed=${this._actionChanged} .hass=${this.hass} @@ -214,6 +266,7 @@ export class HaManualAutomationEditor extends LitElement { private _triggerChanged(ev: CustomEvent): void { ev.stopPropagation(); + this.resetPastedConfig(); fireEvent(this, "value-changed", { value: { ...this.config!, triggers: ev.detail.value as Trigger[] }, }); @@ -221,6 +274,7 @@ export class HaManualAutomationEditor extends LitElement { private _conditionChanged(ev: CustomEvent): void { ev.stopPropagation(); + this.resetPastedConfig(); fireEvent(this, "value-changed", { value: { ...this.config!, @@ -231,6 +285,7 @@ export class HaManualAutomationEditor extends LitElement { private _actionChanged(ev: CustomEvent): void { ev.stopPropagation(); + this.resetPastedConfig(); fireEvent(this, "value-changed", { 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((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 { return [ haStyle, diff --git a/src/panels/config/automation/paste-replace-dialog/dialog-paste-replace.ts b/src/panels/config/automation/paste-replace-dialog/dialog-paste-replace.ts new file mode 100644 index 0000000000..c6fd39aa38 --- /dev/null +++ b/src/panels/config/automation/paste-replace-dialog/dialog-paste-replace.ts @@ -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` + +

+ ${this.hass.localize( + `ui.panel.config.${this._params.domain}.editor.paste_confirm.text` + )} +

+ + + +
+ + ${this.hass.localize("ui.common.append")} + + + ${this.hass.localize("ui.common.replace")} + +
+
+ `; + } + + 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; + } +} diff --git a/src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace.ts b/src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace.ts new file mode 100644 index 0000000000..81ee976f4e --- /dev/null +++ b/src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace.ts @@ -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 { + 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, + }); +}; diff --git a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts index 7dbd244a49..85adcbadfd 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts @@ -738,6 +738,12 @@ export default class HaAutomationTriggerRow extends LitElement { ha-md-menu-item > ha-svg-icon { --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); + } `, ]; } diff --git a/src/panels/config/automation/trigger/ha-automation-trigger.ts b/src/panels/config/automation/trigger/ha-automation-trigger.ts index 49e53723b3..fabc23eef6 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger.ts @@ -32,6 +32,8 @@ export default class HaAutomationTrigger extends LitElement { @property({ attribute: false }) public triggers!: Trigger[]; + @property({ attribute: false }) public highlightedTriggers?: Trigger[]; + @property({ type: Boolean }) public disabled = false; @state() private _showReorder = false; @@ -92,6 +94,7 @@ export default class HaAutomationTrigger extends LitElement { @value-changed=${this._triggerChanged} .hass=${this.hass} .disabled=${this.disabled} + ?highlight=${this.highlightedTriggers?.includes(trg)} > ${this._showReorder && !this.disabled ? html` diff --git a/src/panels/config/script/ha-script-editor.ts b/src/panels/config/script/ha-script-editor.ts index fdc99c324d..b4121a0125 100644 --- a/src/panels/config/script/ha-script-editor.ts +++ b/src/panels/config/script/ha-script-editor.ts @@ -1,3 +1,4 @@ +import { consume } from "@lit/context"; import "@material/mwc-button"; import { mdiCog, @@ -20,21 +21,23 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; -import { consume } from "@lit/context"; import { fireEvent } from "../../../common/dom/fire_event"; import { navigate } from "../../../common/navigate"; import { slugify } from "../../../common/string/slugify"; import { computeRTL } from "../../../common/util/compute_rtl"; -import { afterNextRender } from "../../../common/util/render-status"; import { promiseTimeout } from "../../../common/util/promise-timeout"; +import { afterNextRender } from "../../../common/util/render-status"; import "../../../components/ha-button-menu"; import "../../../components/ha-fab"; +import { transform } from "../../../common/decorators/transform"; import "../../../components/ha-icon-button"; import "../../../components/ha-list-item"; import "../../../components/ha-svg-icon"; import "../../../components/ha-yaml-editor"; +import { substituteBlueprint } from "../../../data/blueprint"; import { validateConfig } from "../../../data/config"; +import { fullEntitiesContext } from "../../../data/context"; import { UNAVAILABLE } from "../../../data/entity"; import { type EntityRegistryEntry, @@ -42,12 +45,12 @@ import { } from "../../../data/entity_registry"; import type { BlueprintScriptConfig, ScriptConfig } from "../../../data/script"; import { + normalizeScriptConfig, deleteScript, fetchScriptFileConfig, getScriptEditorInitData, getScriptStateConfig, hasScriptFields, - migrateAutomationAction, showScriptEditor, triggerScript, } from "../../../data/script"; @@ -58,21 +61,18 @@ import { import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog"; import "../../../layouts/hass-subpage"; 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 type { Entries, HomeAssistant, Route } from "../../../types"; import { showToast } from "../../../util/toast"; import { showAutomationModeDialog } from "../automation/automation-mode-dialog/show-dialog-automation-mode"; import type { EntityRegistryUpdate } 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 "./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( PreventUnsavedMixin(KeyboardShortcutMixin(LitElement)) @@ -427,6 +427,7 @@ export class HaScriptEditor extends SubscribeMixin( .isWide=${this.isWide} .config=${this._config} .disabled=${this._readOnly} + .dirty=${this._dirty} @value-changed=${this._valueChanged} > `} @@ -499,12 +500,11 @@ export class HaScriptEditor extends SubscribeMixin( ...initData, } as ScriptConfig; this._readOnly = false; - this._dirty = true; } if (changedProps.has("entityId") && this.entityId) { getScriptStateConfig(this.hass, this.entityId).then((c) => { - this._config = this._normalizeConfig(c.config); + this._config = normalizeScriptConfig(c.config); this._checkValidation(); }); 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() { fetchScriptFileConfig(this.hass, this.scriptId!).then( (config) => { this._dirty = false; this._readOnly = false; - this._config = this._normalizeConfig(config); + this._config = normalizeScriptConfig(config); const entity = this.entityRegistry.find( (ent) => ent.platform === "script" && ent.unique_id === this.scriptId ); @@ -770,7 +757,7 @@ export class HaScriptEditor extends SubscribeMixin( ); const newConfig = { - ...this._normalizeConfig(result.substituted_config), + ...normalizeScriptConfig(result.substituted_config), alias: config.alias, description: config.description, }; @@ -913,6 +900,8 @@ export class HaScriptEditor extends SubscribeMixin( return; } + this._manualEditor?.resetPastedConfig(); + if (!this.scriptId) { const saved = await this._promptScriptAlias(); if (!saved) { diff --git a/src/panels/config/script/ha-script-field-row.ts b/src/panels/config/script/ha-script-field-row.ts index f9d0cc1a36..7c3d68fd23 100644 --- a/src/panels/config/script/ha-script-field-row.ts +++ b/src/panels/config/script/ha-script-field-row.ts @@ -353,6 +353,12 @@ export default class HaScriptFieldRow extends LitElement { li[role="separator"] { 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); + } `, ]; } diff --git a/src/panels/config/script/ha-script-fields.ts b/src/panels/config/script/ha-script-fields.ts index 66bbb1f2dc..08d30cb971 100644 --- a/src/panels/config/script/ha-script-fields.ts +++ b/src/panels/config/script/ha-script-fields.ts @@ -20,6 +20,8 @@ export default class HaScriptFields extends LitElement { @property({ attribute: false }) public fields!: Fields; + @property({ attribute: false }) public highlightedFields?: Fields; + private _focusLastActionOnChange = false; protected render() { @@ -37,6 +39,7 @@ export default class HaScriptFields extends LitElement { .disabled=${this.disabled} @value-changed=${this._fieldChanged} .hass=${this.hass} + ?highlight=${this.highlightedFields?.[key] !== undefined} > ` diff --git a/src/panels/config/script/manual-script-editor.ts b/src/panels/config/script/manual-script-editor.ts index 8ef9cc66d9..d15c41a49f 100644 --- a/src/panels/config/script/manual-script-editor.ts +++ b/src/panels/config/script/manual-script-editor.ts @@ -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((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, diff --git a/src/state/quick-bar-mixin.ts b/src/state/quick-bar-mixin.ts index 116c9d1fbd..598d064597 100644 --- a/src/state/quick-bar-mixin.ts +++ b/src/state/quick-bar-mixin.ts @@ -14,6 +14,7 @@ import { showToast } from "../util/toast"; import type { HassElement } from "./hass-element"; import { extractSearchParamsObject } from "../common/url/search-params"; import { showVoiceCommandDialog } from "../dialogs/voice-command-dialog/show-ha-voice-command-dialog"; +import { canOverrideAlphanumericInput } from "../common/dom/can-override-input"; declare global { interface HASSDomEvents { @@ -80,7 +81,7 @@ export default >(superClass: T) => private _showVoiceCommandDialog(e: KeyboardEvent) { if ( !this.hass?.enableShortcuts || - !this._canOverrideAlphanumericInput(e) || + !canOverrideAlphanumericInput(e.composedPath()) || !this._conversation(this.hass.config.components) ) { return; @@ -113,7 +114,7 @@ export default >(superClass: T) => private async _createMyLink(e: KeyboardEvent) { if ( !this.hass?.enableShortcuts || - !this._canOverrideAlphanumericInput(e) + !canOverrideAlphanumericInput(e.composedPath()) ) { return; } @@ -182,42 +183,7 @@ export default >(superClass: T) => return ( this.hass?.user?.is_admin && 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; - } - } }; diff --git a/src/translations/en.json b/src/translations/en.json index 17b88b5192..21b19c2a4c 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -372,7 +372,9 @@ "default": "Default", "dont_save": "Don't save", "copy": "Copy", - "show": "Show" + "show": "Show", + "replace": "Replace", + "append": "Append" }, "components": { "selectors": { @@ -1935,6 +1937,10 @@ "title": "Assist", "open_assist": "open Assist dialog" }, + "automations": { + "title": "Automations", + "paste": "to paste automation YAML from clipboard to automation editor" + }, "charts": { "title": "Charts", "drag_to_zoom": "to zoom in part of a chart", @@ -4343,7 +4349,13 @@ "add_category": "Add category", "add_labels": "Add labels", "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": { "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_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." - } + }, + "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": { "edit_script": "Edit script"