import { mdiContentSave, mdiHelpCircle } from "@mdi/js"; import type { HassEntity } from "home-assistant-js-websocket"; import { load } from "js-yaml"; import type { CSSResultGroup, PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, query, queryAll, state, } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { any, array, assert, assign, object, optional, string, union, } from "superstruct"; import { ensureArray } from "../../../common/array/ensure-array"; import { storage } from "../../../common/decorators/storage"; import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input"; import { fireEvent } from "../../../common/dom/fire_event"; import { constructUrlCurrentPath } from "../../../common/url/construct-url"; import { extractSearchParam, removeSearchParam, } from "../../../common/url/search-params"; import "../../../components/ha-button"; import "../../../components/ha-fab"; import "../../../components/ha-icon-button"; import "../../../components/ha-markdown"; import type { AutomationConfig, Condition, ManualAutomationConfig, SidebarConfig, Trigger, } from "../../../data/automation"; import { isCondition, isTrigger, normalizeAutomationConfig, } from "../../../data/automation"; import { getActionType, type Action } from "../../../data/script"; import type { HomeAssistant } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; import { showToast } from "../../../util/toast"; import "./action/ha-automation-action"; import type HaAutomationAction from "./action/ha-automation-action"; import "./condition/ha-automation-condition"; import type HaAutomationCondition from "./condition/ha-automation-condition"; import "./ha-automation-sidebar"; import type HaAutomationSidebar from "./ha-automation-sidebar"; import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace"; import { manualEditorStyles, saveFabStyles } from "./styles"; import "./trigger/ha-automation-trigger"; 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()) })), ]); export const SIDEBAR_DEFAULT_WIDTH = 500; @customElement("manual-automation-editor") export class HaManualAutomationEditor extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: "is-wide", type: Boolean }) public isWide = false; @property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public saving = false; @property({ attribute: false }) public config!: ManualAutomationConfig; @property({ attribute: false }) public stateObj?: HassEntity; @property({ attribute: false }) public dirty = false; @state() private _pastedConfig?: ManualAutomationConfig; @state() private _sidebarConfig?: SidebarConfig; @state() private _sidebarKey?: string; @storage({ key: "automation-sidebar-width", state: false, subscribe: false, }) private _sidebarWidthPx = SIDEBAR_DEFAULT_WIDTH; @query("ha-automation-sidebar") private _sidebarElement?: HaAutomationSidebar; @queryAll("ha-automation-action, ha-automation-condition") private _collapsableElements?: NodeListOf< HaAutomationAction | HaAutomationCondition >; private _prevSidebarWidthPx?: number; public connectedCallback() { super.connectedCallback(); window.addEventListener("paste", this._handlePaste); } public disconnectedCallback() { window.removeEventListener("paste", this._handlePaste); super.disconnectedCallback(); } private _renderContent() { return html` ${this.config.description ? html`` : nothing}

${this.hass.localize( "ui.panel.config.automation.editor.triggers.header" )}

${!ensureArray(this.config.triggers)?.length ? html`

${this.hass.localize( "ui.panel.config.automation.editor.triggers.description" )}

` : nothing}

${this.hass.localize( "ui.panel.config.automation.editor.conditions.header" )} (${this.hass.localize("ui.common.optional")})

${!ensureArray(this.config.conditions)?.length ? html`

${this.hass.localize( "ui.panel.config.automation.editor.conditions.description", { user: this.hass.user?.name || "Alice" } )}

` : nothing}

${this.hass.localize( "ui.panel.config.automation.editor.actions.header" )}

${!ensureArray(this.config.actions)?.length ? html`

${this.hass.localize( "ui.panel.config.automation.editor.actions.description" )}

` : nothing} `; } protected render() { return html`
${this._renderContent()}
`; } protected firstUpdated(changedProps: PropertyValues): void { super.firstUpdated(changedProps); this.style.setProperty( "--sidebar-dynamic-width", `${this._sidebarWidthPx}px` ); const expanded = extractSearchParam("expanded"); if (expanded === "1") { this._clearParam("expanded"); this.expandAll(); } } private _clearParam(param: string) { window.history.replaceState( null, "", constructUrlCurrentPath(removeSearchParam(param)) ); } private async _openSidebar(ev: CustomEvent) { // deselect previous selected row this._sidebarConfig?.close?.(); this._sidebarConfig = ev.detail; this._sidebarKey = JSON.stringify(this._sidebarConfig); await this._sidebarElement?.updateComplete; this._sidebarElement?.focus(); } private _sidebarConfigChanged(ev: CustomEvent<{ value: SidebarConfig }>) { ev.stopPropagation(); if (!this._sidebarConfig) { return; } this._sidebarConfig = { ...this._sidebarConfig, ...ev.detail.value, }; } public triggerCloseSidebar() { if (this._sidebarConfig) { if (this._sidebarElement) { this._sidebarElement.triggerCloseSidebar(); return; } this._sidebarConfig?.close(); } } private _handleCloseSidebar() { this._sidebarConfig = undefined; } private _triggerChanged(ev: CustomEvent): void { ev.stopPropagation(); this.resetPastedConfig(); fireEvent(this, "value-changed", { value: { ...this.config!, triggers: ev.detail.value as Trigger[] }, }); } private _conditionChanged(ev: CustomEvent): void { ev.stopPropagation(); this.resetPastedConfig(); fireEvent(this, "value-changed", { value: { ...this.config!, conditions: ev.detail.value as Condition[], }, }); } private _actionChanged(ev: CustomEvent): void { ev.stopPropagation(); this.resetPastedConfig(); fireEvent(this, "value-changed", { value: { ...this.config!, actions: ev.detail.value as Action[] }, }); } private _saveAutomation() { this.triggerCloseSidebar(); fireEvent(this, "save-automation"); } private _handlePaste = async (ev: ClipboardEvent) => { if (!canOverrideAlphanumericInput(ev.composedPath())) { return; } const paste = ev.clipboardData?.getData("text"); if (!paste) { return; } let loaded: any; try { loaded = load(paste); } catch (_err: any) { showToast(this, { message: this.hass.localize( "ui.panel.config.automation.editor.paste_invalid_yaml" ), duration: 4000, dismissable: true, }); return; } if (!loaded || typeof loaded !== "object") { return; } let config = loaded; if ("automation" in config) { config = config.automation; if (Array.isArray(config)) { config = config[0]; } } if (Array.isArray(config)) { if (config.length === 1) { config = config[0]; } else { const newConfig: AutomationConfig = { triggers: [], conditions: [], actions: [], }; let found = false; config.forEach((cfg: any) => { if (isTrigger(cfg)) { found = true; (newConfig.triggers as Trigger[]).push(cfg); } if (isCondition(cfg)) { found = true; (newConfig.conditions as Condition[]).push(cfg); } if (getActionType(cfg) !== "unknown") { found = true; (newConfig.actions as Action[]).push(cfg); } }); if (found) { config = newConfig; } } } if (isTrigger(config)) { config = { triggers: [config] }; } if (isCondition(config)) { config = { conditions: [config] }; } if (getActionType(config) !== "unknown") { config = { actions: [config] }; } let normalized: AutomationConfig; try { normalized = normalizeAutomationConfig(config); } 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(); 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 if (this._tryInsertAfterSelected(normalized[keysPresent[0]])) { 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", 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) { this._pastedConfig = config; // make a copy otherwise we will modify the original config // which breaks the (referenced) config used for storing in undo stack const workingCopy: ManualAutomationConfig = { ...this.config }; if (!workingCopy) { return; } if ("triggers" in config) { workingCopy.triggers = ensureArray(workingCopy.triggers || []).concat( ensureArray(config.triggers) ); } if ("conditions" in config) { workingCopy.conditions = ensureArray(workingCopy.conditions || []).concat( ensureArray(config.conditions) ); } if ("actions" in config) { workingCopy.actions = ensureArray(workingCopy.actions || []).concat( ensureArray(config.actions) ) as Action[]; } this._showPastedToastWithUndo(); fireEvent(this, "value-changed", { value: { ...workingCopy!, }, }); } private _replaceExistingConfig(config: ManualAutomationConfig) { this._pastedConfig = config; this._showPastedToastWithUndo(); fireEvent(this, "value-changed", { value: { ...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, "undo-change"); this._pastedConfig = undefined; }, }, }); } public resetPastedConfig() { this._pastedConfig = undefined; showToast(this, { message: "", duration: 0, }); } public expandAll() { this._collapsableElements?.forEach((element) => { element.expandAll(); }); } public collapseAll() { this._collapsableElements?.forEach((element) => { element.collapseAll(); }); } 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 && "copy" in this._sidebarConfig) { this._sidebarConfig.copy(); } } public cutSelectedRow() { if (this._sidebarConfig && "cut" in this._sidebarConfig) { this._sidebarConfig.cut(); } } public deleteSelectedRow() { if (this._sidebarConfig && "delete" in this._sidebarConfig) { this._sidebarConfig.delete(); } } private _resizeSidebar(ev) { ev.stopPropagation(); const delta = ev.detail.deltaInPx as number; // set initial resize width to add / reduce delta from it if (!this._prevSidebarWidthPx) { this._prevSidebarWidthPx = this._sidebarElement?.clientWidth || SIDEBAR_DEFAULT_WIDTH; } const widthPx = delta + this._prevSidebarWidthPx; this._sidebarWidthPx = widthPx; this.style.setProperty( "--sidebar-dynamic-width", `${this._sidebarWidthPx}px` ); } private _stopResizeSidebar(ev) { ev.stopPropagation(); this._prevSidebarWidthPx = undefined; } static get styles(): CSSResultGroup { return [ saveFabStyles, manualEditorStyles, css` p { margin-top: 0; } .header { margin-top: 16px; display: flex; align-items: center; } .header:first-child { margin-top: -16px; } .header .name { font-weight: var(--ha-font-weight-normal); flex: 1; margin-bottom: 8px; } .header .small { font-size: small; font-weight: var(--ha-font-weight-normal); line-height: 0; } .description { margin-top: 16px; } `, ]; } } declare global { interface HTMLElementTagNameMap { "manual-automation-editor": HaManualAutomationEditor; } interface HASSDomEvents { "open-sidebar": SidebarConfig; "request-close-sidebar": undefined; "close-sidebar": undefined; } }