Expand pasting capabilities of automation editor (#26992)

This commit is contained in:
Bram Kragten
2025-09-18 17:01:42 +02:00
committed by GitHub
parent 034afd1375
commit 283da74e2d
12 changed files with 181 additions and 19 deletions

View File

@@ -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: {

View File

@@ -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

View File

@@ -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,

View File

@@ -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),
});
}

View File

@@ -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,

View File

@@ -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),
});
}

View File

@@ -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;
}
}

View File

@@ -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<boolean>((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();
}
}

View File

@@ -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])
),
});
}

View File

@@ -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, {

View File

@@ -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),
});
}

View File

@@ -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();