From 283da74e2d72e37d56fa8100a24e021d9a38833c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 18 Sep 2025 17:01:42 +0200 Subject: [PATCH] Expand pasting capabilities of automation editor (#26992) --- src/data/automation.ts | 3 ++ src/data/script.ts | 9 +++- .../action/ha-automation-action-row.ts | 15 +++++- .../automation/action/ha-automation-action.ts | 20 +++++++- .../condition/ha-automation-condition-row.ts | 15 +++++- .../condition/ha-automation-condition.ts | 20 +++++++- .../config/automation/ha-automation-editor.ts | 6 +++ .../automation/manual-automation-editor.ts | 46 +++++++++++++++---- .../automation/option/ha-automation-option.ts | 7 ++- .../trigger/ha-automation-trigger-row.ts | 16 ++++++- .../trigger/ha-automation-trigger.ts | 20 +++++++- .../config/script/manual-script-editor.ts | 23 +++++++++- 12 files changed, 181 insertions(+), 19 deletions(-) diff --git a/src/data/automation.ts b/src/data/automation.ts index e79c34b62f..bdd56573d2 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -567,6 +567,7 @@ export interface TriggerSidebarConfig extends BaseSidebarConfig { duplicate: () => void; cut: () => void; copy: () => void; + insertAfter: (value: Trigger | Trigger[]) => boolean; toggleYamlMode: () => void; config: Trigger; yamlMode: boolean; @@ -581,6 +582,7 @@ export interface ConditionSidebarConfig extends BaseSidebarConfig { duplicate: () => void; cut: () => void; copy: () => void; + insertAfter: (value: Condition | Condition[]) => boolean; toggleYamlMode: () => void; config: Condition; yamlMode: boolean; @@ -594,6 +596,7 @@ export interface ActionSidebarConfig extends BaseSidebarConfig { duplicate: () => void; cut: () => void; copy: () => void; + insertAfter: (value: Action | Action[]) => boolean; run: () => void; toggleYamlMode: () => void; config: { diff --git a/src/data/script.ts b/src/data/script.ts index aa3efc4437..c7a44a8a12 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -344,7 +344,11 @@ export const getActionType = (action: Action): ActionType => { if ("event" in action) { return "fire_event"; } - if ("device_id" in action) { + if ( + "device_id" in action && + !("trigger" in action) && + !("condition" in action) + ) { return "device_action"; } if ("repeat" in action) { @@ -380,6 +384,9 @@ export const getActionType = (action: Action): ActionType => { return "unknown"; }; +export const isAction = (value: unknown): value is Action => + getActionType(value as Action) !== "unknown"; + export const hasScriptFields = ( hass: HomeAssistant, entityId: string 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 54b8507a67..5bd7a08853 100644 --- a/src/panels/config/automation/action/ha-automation-action-row.ts +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -15,16 +15,19 @@ import { mdiStopCircleOutline, } from "@mdi/js"; import deepClone from "deep-clone-simple"; +import { dump } from "js-yaml"; import type { PropertyValues } from "lit"; import { LitElement, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../../../../common/array/ensure-array"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation"; import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; import { handleStructError } from "../../../../common/structs/handle-errors"; +import { copyToClipboard } from "../../../../common/util/copy-clipboard"; import "../../../../components/ha-automation-row"; import type { HaAutomationRow } from "../../../../components/ha-automation-row"; import "../../../../components/ha-card"; @@ -61,7 +64,7 @@ import type { NonConditionAction, RepeatAction, } from "../../../../data/script"; -import { getActionType } from "../../../../data/script"; +import { getActionType, isAction } from "../../../../data/script"; import { describeAction } from "../../../../data/script_i18n"; import { callExecuteScript } from "../../../../data/service"; import { @@ -506,6 +509,7 @@ export default class HaAutomationActionRow extends LitElement { ...this._clipboard, action: deepClone(this.action), }; + copyToClipboard(dump(this.action)); } private _onDisable = () => { @@ -636,6 +640,14 @@ export default class HaAutomationActionRow extends LitElement { fireEvent(this, "duplicate"); }; + private _insertAfter = (value: Action | Action[]) => { + if (ensureArray(value).some((val) => !isAction(val))) { + return false; + } + fireEvent(this, "insert-after", { value }); + return true; + }; + private _copyAction = () => { this._setClipboard(); showToast(this, { @@ -724,6 +736,7 @@ export default class HaAutomationActionRow extends LitElement { copy: this._copyAction, cut: this._cutAction, duplicate: this._duplicateAction, + insertAfter: this._insertAfter, run: this._runAction, config: { action: sidebarAction, diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts index e804816165..c5656e37e2 100644 --- a/src/panels/config/automation/action/ha-automation-action.ts +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -27,6 +27,7 @@ import { import { automationRowsStyles } from "../styles"; import type HaAutomationActionRow from "./ha-automation-action-row"; import { getAutomationActionType } from "./ha-automation-action-row"; +import { ensureArray } from "../../../../common/array/ensure-array"; @customElement("ha-automation-action") export default class HaAutomationAction extends LitElement { @@ -92,6 +93,7 @@ export default class HaAutomationAction extends LitElement { .narrow=${this.narrow} .disabled=${this.disabled} @duplicate=${this._duplicateAction} + @insert-after=${this._insertAfter} @move-down=${this._moveDown} @move-up=${this._moveUp} @value-changed=${this._actionChanged} @@ -364,7 +366,23 @@ export default class HaAutomationAction extends LitElement { ev.stopPropagation(); const index = (ev.target as any).index; fireEvent(this, "value-changed", { - value: this.actions.concat(deepClone(this.actions[index])), + // @ts-expect-error Requires library bump to ES2023 + value: this.actions.toSpliced( + index + 1, + 0, + deepClone(this.actions[index]) + ), + }); + } + + private _insertAfter(ev: CustomEvent) { + ev.stopPropagation(); + const index = (ev.target as any).index; + const inserted = ensureArray(ev.detail.value); + this.highlightedActions = inserted; + fireEvent(this, "value-changed", { + // @ts-expect-error Requires library bump to ES2023 + value: this.actions.toSpliced(index + 1, 0, ...inserted), }); } 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 3d097eba84..9891211c67 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-row.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-row.ts @@ -14,17 +14,20 @@ import { mdiStopCircleOutline, } from "@mdi/js"; import deepClone from "deep-clone-simple"; +import { dump } from "js-yaml"; import type { CSSResultGroup, PropertyValues } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../../../../common/array/ensure-array"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation"; import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; import { handleStructError } from "../../../../common/structs/handle-errors"; +import { copyToClipboard } from "../../../../common/util/copy-clipboard"; import "../../../../components/ha-automation-row"; import type { HaAutomationRow } from "../../../../components/ha-automation-row"; import "../../../../components/ha-card"; @@ -38,7 +41,7 @@ import type { Condition, ConditionSidebarConfig, } from "../../../../data/automation"; -import { testCondition } from "../../../../data/automation"; +import { isCondition, testCondition } from "../../../../data/automation"; import { describeCondition } from "../../../../data/automation_i18n"; import { CONDITION_BUILDING_BLOCKS, @@ -437,6 +440,7 @@ export default class HaAutomationConditionRow extends LitElement { ...this._clipboard, condition: deepClone(this.condition), }; + copyToClipboard(dump(this.condition)); } private _onDisable = () => { @@ -582,6 +586,14 @@ export default class HaAutomationConditionRow extends LitElement { fireEvent(this, "duplicate"); }; + private _insertAfter = (value: Condition | Condition[]) => { + if (ensureArray(value).some((val) => !isCondition(val))) { + return false; + } + fireEvent(this, "insert-after", { value }); + return true; + }; + private _copyCondition = () => { this._setClipboard(); showToast(this, { @@ -693,6 +705,7 @@ export default class HaAutomationConditionRow extends LitElement { disable: this._onDisable, delete: this._onDelete, duplicate: this._duplicateCondition, + insertAfter: this._insertAfter, copy: this._copyCondition, cut: this._cutCondition, test: this._testCondition, diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index 6cf80bca82..3305e469fe 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -25,6 +25,7 @@ import { import { automationRowsStyles } from "../styles"; import "./ha-automation-condition-row"; import type HaAutomationConditionRow from "./ha-automation-condition-row"; +import { ensureArray } from "../../../../common/array/ensure-array"; @customElement("ha-automation-condition") export default class HaAutomationCondition extends LitElement { @@ -170,6 +171,7 @@ export default class HaAutomationCondition extends LitElement { .disabled=${this.disabled} .narrow=${this.narrow} @duplicate=${this._duplicateCondition} + @insert-after=${this._insertAfter} @move-down=${this._moveDown} @move-up=${this._moveUp} @value-changed=${this._conditionChanged} @@ -383,7 +385,23 @@ export default class HaAutomationCondition extends LitElement { ev.stopPropagation(); const index = (ev.target as any).index; fireEvent(this, "value-changed", { - value: this.conditions.concat(deepClone(this.conditions[index])), + // @ts-expect-error Requires library bump to ES2023 + value: this.conditions.toSpliced( + index + 1, + 0, + deepClone(this.conditions[index]) + ), + }); + } + + private _insertAfter(ev: CustomEvent) { + ev.stopPropagation(); + const index = (ev.target as any).index; + const inserted = ensureArray(ev.detail.value); + this.highlightedConditions = inserted; + fireEvent(this, "value-changed", { + // @ts-expect-error Requires library bump to ES2023 + value: this.conditions.toSpliced(index + 1, 0, ...inserted), }); } diff --git a/src/panels/config/automation/ha-automation-editor.ts b/src/panels/config/automation/ha-automation-editor.ts index a5b93a7d67..bf30a4e40c 100644 --- a/src/panels/config/automation/ha-automation-editor.ts +++ b/src/panels/config/automation/ha-automation-editor.ts @@ -41,6 +41,8 @@ import type { AutomationConfig, AutomationEntity, BlueprintAutomationConfig, + Condition, + Trigger, } from "../../../data/automation"; import { deleteAutomation, @@ -60,6 +62,7 @@ import { type EntityRegistryEntry, updateEntityRegistryEntry, } from "../../../data/entity_registry"; +import type { Action } from "../../../data/script"; import { showAlertDialog, showConfirmationDialog, @@ -96,6 +99,9 @@ declare global { "move-down": undefined; "move-up": undefined; duplicate: undefined; + "insert-after": { + value: Trigger | Condition | Action | Trigger[] | Condition[] | Action[]; + }; "save-automation": undefined; } } diff --git a/src/panels/config/automation/manual-automation-editor.ts b/src/panels/config/automation/manual-automation-editor.ts index 724675da61..d2f8be71d6 100644 --- a/src/panels/config/automation/manual-automation-editor.ts +++ b/src/panels/config/automation/manual-automation-editor.ts @@ -35,7 +35,6 @@ import "../../../components/ha-fab"; import "../../../components/ha-icon-button"; import "../../../components/ha-markdown"; import type { - ActionSidebarConfig, AutomationConfig, Condition, ManualAutomationConfig, @@ -172,7 +171,7 @@ export class HaManualAutomationEditor extends LitElement { role="region" aria-labelledby="triggers-heading" .triggers=${this.config.triggers || []} - .highlightedTriggers=${this._pastedConfig?.triggers || []} + .highlightedTriggers=${this._pastedConfig?.triggers} @value-changed=${this._triggerChanged} .hass=${this.hass} .disabled=${this.disabled || this.saving} @@ -219,7 +218,7 @@ export class HaManualAutomationEditor extends LitElement { role="region" aria-labelledby="conditions-heading" .conditions=${this.config.conditions || []} - .highlightedConditions=${this._pastedConfig?.conditions || []} + .highlightedConditions=${this._pastedConfig?.conditions} @value-changed=${this._conditionChanged} .hass=${this.hass} .disabled=${this.disabled || this.saving} @@ -264,7 +263,7 @@ export class HaManualAutomationEditor extends LitElement { role="region" aria-labelledby="actions-heading" .actions=${this.config.actions || []} - .highlightedActions=${this._pastedConfig?.actions || []} + .highlightedActions=${this._pastedConfig?.actions} @value-changed=${this._actionChanged} @open-sidebar=${this._openSidebar} @request-close-sidebar=${this._triggerCloseSidebar} @@ -518,12 +517,30 @@ export class HaManualAutomationEditor extends LitElement { if (normalized) { ev.preventDefault(); + const keysPresent = Object.keys(normalized).filter( + (key) => ensureArray(normalized[key]).length + ); + + if ( + keysPresent.length === 1 && + ["triggers", "conditions", "actions"].includes(keysPresent[0]) + ) { + // if only one type of element is pasted, insert under the currently active item + const previousConfig = { ...this.config }; + if (this._tryInsertAfterSelected(normalized[keysPresent[0]])) { + this._previousConfig = previousConfig; + this._showPastedToastWithUndo(); + return; + } + } + if ( this.dirty || ensureArray(this.config.triggers)?.length || ensureArray(this.config.conditions)?.length || ensureArray(this.config.actions)?.length ) { + // ask if they want to append or replace if we have existing config or there are unsaved changes const result = await new Promise((resolve) => { showPasteReplaceDialog(this, { domain: "automation", @@ -644,21 +661,30 @@ export class HaManualAutomationEditor extends LitElement { }); } + private _tryInsertAfterSelected( + config: Trigger | Condition | Action | Trigger[] | Condition[] | Action[] + ): boolean { + if (this._sidebarConfig && "insertAfter" in this._sidebarConfig) { + return this._sidebarConfig.insertAfter(config as any); + } + return false; + } + public copySelectedRow() { - if ((this._sidebarConfig as ActionSidebarConfig)?.copy) { - (this._sidebarConfig as ActionSidebarConfig).copy(); + if (this._sidebarConfig && "copy" in this._sidebarConfig) { + this._sidebarConfig.copy(); } } public cutSelectedRow() { - if ((this._sidebarConfig as ActionSidebarConfig)?.cut) { - (this._sidebarConfig as ActionSidebarConfig).cut(); + if (this._sidebarConfig && "cut" in this._sidebarConfig) { + this._sidebarConfig.cut(); } } public deleteSelectedRow() { - if ((this._sidebarConfig as ActionSidebarConfig)?.delete) { - (this._sidebarConfig as ActionSidebarConfig).delete(); + if (this._sidebarConfig && "delete" in this._sidebarConfig) { + this._sidebarConfig.delete(); } } diff --git a/src/panels/config/automation/option/ha-automation-option.ts b/src/panels/config/automation/option/ha-automation-option.ts index e816066d8b..85db03518e 100644 --- a/src/panels/config/automation/option/ha-automation-option.ts +++ b/src/panels/config/automation/option/ha-automation-option.ts @@ -293,7 +293,12 @@ export default class HaAutomationOption extends LitElement { ev.stopPropagation(); const index = (ev.target as any).index; fireEvent(this, "value-changed", { - value: this.options.concat(deepClone(this.options[index])), + // @ts-expect-error Requires library bump to ES2023 + value: this.options.toSpliced( + index + 1, + 0, + deepClone(this.options[index]) + ), }); } 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 71ad872fa2..ec63a833af 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts @@ -14,17 +14,20 @@ import { mdiStopCircleOutline, } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { dump } from "js-yaml"; import type { CSSResultGroup, PropertyValues } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../../../../common/array/ensure-array"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation"; import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; import { handleStructError } from "../../../../common/structs/handle-errors"; +import { copyToClipboard } from "../../../../common/util/copy-clipboard"; import { debounce } from "../../../../common/util/debounce"; import "../../../../components/ha-alert"; import "../../../../components/ha-automation-row"; @@ -40,7 +43,7 @@ import type { Trigger, TriggerSidebarConfig, } from "../../../../data/automation"; -import { subscribeTrigger } from "../../../../data/automation"; +import { isTrigger, subscribeTrigger } from "../../../../data/automation"; import { describeTrigger } from "../../../../data/automation_i18n"; import { validateConfig } from "../../../../data/config"; import { fullEntitiesContext } from "../../../../data/context"; @@ -508,6 +511,7 @@ export default class HaAutomationTriggerRow extends LitElement { copy: this._copyTrigger, duplicate: this._duplicateTrigger, cut: this._cutTrigger, + insertAfter: this._insertAfter, config: trigger || this.trigger, uiSupported: this._uiSupported(this._getType(trigger || this.trigger)), yamlMode: this._yamlMode, @@ -529,6 +533,8 @@ export default class HaAutomationTriggerRow extends LitElement { ...this._clipboard, trigger: this.trigger, }; + + copyToClipboard(dump(this.trigger)); } private _onDelete = () => { @@ -636,6 +642,14 @@ export default class HaAutomationTriggerRow extends LitElement { fireEvent(this, "duplicate"); }; + private _insertAfter = (value: Trigger | Trigger[]) => { + if (ensureArray(value).some((val) => !isTrigger(val))) { + return false; + } + fireEvent(this, "insert-after", { value }); + return true; + }; + private _copyTrigger = () => { this._setClipboard(); showToast(this, { diff --git a/src/panels/config/automation/trigger/ha-automation-trigger.ts b/src/panels/config/automation/trigger/ha-automation-trigger.ts index fed57a0b46..061ac6843a 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger.ts @@ -26,6 +26,7 @@ import { import { automationRowsStyles } from "../styles"; import "./ha-automation-trigger-row"; import type HaAutomationTriggerRow from "./ha-automation-trigger-row"; +import { ensureArray } from "../../../../common/array/ensure-array"; @customElement("ha-automation-trigger") export default class HaAutomationTrigger extends LitElement { @@ -85,6 +86,7 @@ export default class HaAutomationTrigger extends LitElement { .last=${idx === this.triggers.length - 1} .trigger=${trg} @duplicate=${this._duplicateTrigger} + @insert-after=${this._insertAfter} @move-down=${this._moveDown} @move-up=${this._moveUp} @value-changed=${this._triggerChanged} @@ -323,7 +325,23 @@ export default class HaAutomationTrigger extends LitElement { ev.stopPropagation(); const index = (ev.target as any).index; fireEvent(this, "value-changed", { - value: this.triggers.concat(deepClone(this.triggers[index])), + // @ts-expect-error Requires library bump to ES2023 + value: this.triggers.toSpliced( + index + 1, + 0, + deepClone(this.triggers[index]) + ), + }); + } + + private _insertAfter(ev: CustomEvent) { + ev.stopPropagation(); + const index = (ev.target as any).index; + const inserted = ensureArray(ev.detail.value); + this.highlightedTriggers = inserted; + fireEvent(this, "value-changed", { + // @ts-expect-error Requires library bump to ES2023 + value: this.triggers.toSpliced(index + 1, 0, ...inserted), }); } diff --git a/src/panels/config/script/manual-script-editor.ts b/src/panels/config/script/manual-script-editor.ts index 086e48fd79..865095b0ce 100644 --- a/src/panels/config/script/manual-script-editor.ts +++ b/src/panels/config/script/manual-script-editor.ts @@ -209,7 +209,7 @@ export class HaManualScriptEditor extends LitElement { role="region" aria-labelledby="sequence-heading" .actions=${this.config.sequence || []} - .highlightedActions=${this._pastedConfig?.sequence || []} + .highlightedActions=${this._pastedConfig?.sequence} @value-changed=${this._sequenceChanged} @open-sidebar=${this._openSidebar} @request-close-sidebar=${this._triggerCloseSidebar} @@ -396,6 +396,20 @@ export class HaManualScriptEditor extends LitElement { if (normalized) { ev.preventDefault(); + const keysPresent = Object.keys(normalized).filter( + (key) => ensureArray(normalized[key]).length + ); + + if (keysPresent.length === 1 && ["sequence"].includes(keysPresent[0])) { + // if only one type of element is pasted, insert under the currently active item + const previousConfig = { ...this.config }; + if (this._tryInsertAfterSelected(normalized[keysPresent[0]])) { + this._previousConfig = previousConfig; + this._showPastedToastWithUndo(); + return; + } + } + if ( this.dirty || ensureArray(this.config.sequence)?.length || @@ -546,6 +560,13 @@ export class HaManualScriptEditor extends LitElement { fireEvent(this, "save-script"); } + private _tryInsertAfterSelected(config: Action | Action[]): boolean { + if (this._sidebarConfig && "insertAfter" in this._sidebarConfig) { + return this._sidebarConfig.insertAfter(config as any); + } + return false; + } + public expandAll() { this._collapsableElements?.forEach((element) => { element.expandAll();