import "@home-assistant/webawesome/dist/components/divider/divider"; import { mdiAppleKeyboardCommand, mdiCog, mdiContentSave, mdiDebugStepOver, mdiDelete, mdiDotsVertical, mdiFileEdit, mdiFormTextbox, mdiInformationOutline, mdiPlay, mdiPlaylistEdit, mdiPlusCircleMultipleOutline, mdiRedo, mdiRenameBox, mdiRobotConfused, mdiTag, mdiTransitConnection, mdiUndo, } from "@mdi/js"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, query } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { UndoRedoController } from "../../../common/controllers/undo-redo-controller"; import { fireEvent } from "../../../common/dom/fire_event"; import { goBack, navigate } from "../../../common/navigate"; import { slugify } from "../../../common/string/slugify"; import { promiseTimeout } from "../../../common/util/promise-timeout"; import "../../../components/ha-button"; import "../../../components/ha-dropdown"; import "../../../components/ha-dropdown-item"; import "../../../components/ha-fab"; import "../../../components/ha-icon-button"; import "../../../components/ha-svg-icon"; import "../../../components/ha-yaml-editor"; import { substituteBlueprint } from "../../../data/blueprint"; import { validateConfig } from "../../../data/config"; import { UNAVAILABLE } from "../../../data/entity/entity"; import { type EntityRegistryEntry, updateEntityRegistryEntry, } from "../../../data/entity/entity_registry"; import type { BlueprintScriptConfig, ScriptConfig } from "../../../data/script"; import { deleteScript, fetchScriptFileConfig, getScriptEditorInitData, getScriptStateConfig, hasScriptFields, normalizeScriptConfig, showScriptEditor, triggerScript, } from "../../../data/script"; import { showAlertDialog, showConfirmationDialog, } from "../../../dialogs/generic/show-dialog-box"; 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 } from "../../../types"; import { isMac } from "../../../util/is_mac"; import { showToast } from "../../../util/toast"; import { showAutomationModeDialog } from "../automation/automation-mode-dialog/show-dialog-automation-mode"; import { showAutomationSaveDialog } from "../automation/automation-save-dialog/show-dialog-automation-save"; import { showAutomationSaveTimeoutDialog } from "../automation/automation-save-timeout-dialog/show-dialog-automation-save-timeout"; import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import "./blueprint-script-editor"; import type { EditorDomainHooks } from "../automation/ha-automation-script-editor-mixin"; import { AutomationScriptEditorMixin, automationScriptEditorStyles, } from "../automation/ha-automation-script-editor-mixin"; import "./manual-script-editor"; import type { HaManualScriptEditor } from "./manual-script-editor"; import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown"; @customElement("ha-script-editor") export class HaScriptEditor extends SubscribeMixin( AutomationScriptEditorMixin( PreventUnsavedMixin(KeyboardShortcutMixin(LitElement)) ) ) { @property({ attribute: false }) public scriptId: string | null = null; @query("manual-script-editor") private _manualEditor?: HaManualScriptEditor; private _newScriptId?: string; protected domainHooks: EditorDomainHooks = { domain: "script", fetchFileConfig: fetchScriptFileConfig, normalizeConfig: normalizeScriptConfig, checkValidation: () => this._checkValidation(), }; private _undoRedoController = new UndoRedoController(this, { apply: (config) => this._applyUndoRedo(config), currentConfig: () => this.config!, }); protected willUpdate(changedProps) { super.willUpdate(changedProps); if ( this.entityRegCreated && this._newScriptId && changedProps.has("entityRegistry") ) { const script = this.entityRegistry.find( (entity: EntityRegistryEntry) => entity.platform === "script" && entity.unique_id === this._newScriptId ); if (script) { this.entityRegCreated(script); this.entityRegCreated = undefined; } } } protected render(): TemplateResult | typeof nothing { if (!this.config) { return this.renderLoading(); } const stateObj = this.currentEntityId ? this.hass.states[this.currentEntityId] : undefined; const useBlueprint = "use_blueprint" in this.config; const shortcutIcon = isMac ? html`` : this.hass.localize("ui.panel.config.automation.editor.ctrl"); return html` ${this.mode === "gui" && !this.narrow ? html` ${this.hass.localize("ui.common.undo")} (${shortcutIcon} + Z) ${this.hass.localize("ui.common.redo")} ( ${isMac ? html`${shortcutIcon} + Shift + Z` : html`${shortcutIcon} + Y`}) ` : nothing} ${this.scriptId && !this.narrow ? html` ${this.hass.localize( "ui.panel.config.script.editor.show_trace" )} ` : ""} ${this.mode === "gui" && this.narrow ? html` ${this.hass.localize("ui.common.undo")} ${this.hass.localize("ui.common.redo")} ` : nothing} ${this.hass.localize("ui.panel.config.script.editor.show_info")} ${this.hass.localize( "ui.panel.config.automation.picker.show_settings" )} ${this.hass.localize( `ui.panel.config.scene.picker.${this.registryEntry?.categories?.script ? "edit_category" : "assign_category"}` )} ${this.hass.localize("ui.panel.config.script.picker.run_script")} ${this.scriptId && this.narrow ? html` ${this.hass.localize( "ui.panel.config.automation.editor.show_trace" )} ` : nothing} ${!useBlueprint && !("fields" in this.config) ? html` ${this.hass.localize( "ui.panel.config.script.editor.field.add_fields" )} ` : nothing} ${this.hass.localize("ui.panel.config.script.editor.rename")} ${!useBlueprint ? html` ${this.hass.localize( "ui.panel.config.script.editor.change_mode" )} ` : nothing} ${this.hass.localize( this.readOnly ? "ui.panel.config.script.editor.migrate" : "ui.panel.config.script.editor.duplicate" )} ${useBlueprint ? html` ${this.hass.localize( "ui.panel.config.script.editor.take_control" )} ` : nothing} ${this.hass.localize( `ui.panel.config.automation.editor.edit_${this.mode === "gui" ? "yaml" : "ui"}` )} ${this.hass.localize("ui.panel.config.script.picker.delete")}
${this.mode === "gui" ? html`
${useBlueprint ? html` ` : html`
${this.errors || stateObj?.state === UNAVAILABLE ? html` ${this.errors || this.validationErrors} ${stateObj?.state === UNAVAILABLE ? html`` : nothing} ` : nothing} ${this.blueprintConfig ? html` ${this.hass.localize( "ui.panel.config.script.editor.confirm_take_control" )}
${this.hass.localize( "ui.common.yes" )} ${this.hass.localize( "ui.common.no" )}
` : this.readOnly ? html`${this.hass.localize( "ui.panel.config.script.editor.read_only" )} ${this.hass.localize( "ui.panel.config.script.editor.migrate" )} ` : nothing}
`}
` : this.mode === "yaml" ? html` ` : nothing}
`; } private _setEntityId() { const entity = this.entityRegistry.find( (ent) => ent.platform === "script" && ent.unique_id === this.scriptId ); this.currentEntityId = entity?.entity_id; } protected updated(changedProps: PropertyValues): void { super.updated(changedProps); const oldScript = changedProps.get("scriptId"); if ( changedProps.has("scriptId") && this.scriptId && !this.entityId && this.hass && // Only refresh config if we picked a new script. If same ID, don't fetch it. (!oldScript || oldScript !== this.scriptId) ) { this._setEntityId(); this.loadConfig(this.scriptId); } if ( (changedProps.has("scriptId") || changedProps.has("entityRegistry")) && this.scriptId && this.entityRegistry ) { this._setEntityId(); } if (changedProps.has("scriptId") && !this.scriptId && this.hass) { const initData = getScriptEditorInitData(); this.dirty = !!initData; const baseConfig: Partial = {}; if (!initData || !("use_blueprint" in initData)) { baseConfig.sequence = []; } this.config = { ...baseConfig, ...initData, } as ScriptConfig; this.readOnly = false; } if (changedProps.has("entityId") && this.entityId) { getScriptStateConfig(this.hass, this.entityId).then((c) => { this.config = normalizeScriptConfig(c.config); this._checkValidation(); }); const regEntry = this.entityRegistry.find( (ent) => ent.entity_id === this.entityId ); if (regEntry?.unique_id) { this.scriptId = regEntry.unique_id; } this.currentEntityId = this.entityId; this.dirty = false; this.readOnly = true; } } private async _checkValidation() { this.validationErrors = undefined; if (!this.currentEntityId || !this.config) { return; } const stateObj = this.hass.states[this.currentEntityId]; if (stateObj?.state !== UNAVAILABLE) { return; } const validation = await validateConfig(this.hass, { actions: this.config.sequence, }); this.validationErrors = ( Object.entries(validation) as Entries ).map(([key, value]) => value.valid ? "" : html`${this.hass.localize( `ui.panel.config.automation.editor.${key}.name` )}: ${value.error}
` ); } private _valueChanged(ev) { if (this.config) { this._undoRedoController.commit(this.config); } this.config = ev.detail.value; this.errors = undefined; this.dirty = true; } private async _runScript() { if (hasScriptFields(this.hass, this.currentEntityId!)) { showMoreInfoDialog(this, { entityId: this.currentEntityId!, }); return; } await triggerScript(this.hass, this.scriptId!); showToast(this, { message: this.hass.localize("ui.notification_toast.triggered", { name: this.config!.alias, }), }); } private _editCategory() { if (!this.registryEntry) { showAlertDialog(this, { title: this.hass.localize( "ui.panel.config.scene.picker.no_category_support" ), text: this.hass.localize( "ui.panel.config.scene.picker.no_category_entity_reg" ), }); return; } showAssignCategoryDialog(this, { scope: "script", entityReg: this.registryEntry, }); } private _computeEntityIdFromAlias(alias: string) { const aliasSlugify = slugify(alias); let id = aliasSlugify; let i = 2; while (this._idIsUsed(id)) { id = `${aliasSlugify}_${i}`; i++; } return id; } private _idIsUsed(id: string): boolean { return ( `script.${id}` in this.hass.states || this.entityRegistry.some((ent) => ent.unique_id === id) ); } private async _showInfo() { if (!this.scriptId) { return; } const entity = this.entityRegistry.find( (entry) => entry.unique_id === this.scriptId ); if (!entity) { return; } fireEvent(this, "hass-more-info", { entityId: entity.entity_id }); } private async _showTrace() { if (this.scriptId) { const result = await this.confirmUnsavedChanged(); if (result) { navigate(`/config/script/trace/${this.scriptId}`); } } } private _addFields() { if ("fields" in this.config!) { return; } if (this.config) { this._undoRedoController.commit(this.config); } this._manualEditor?.addFields(); this.dirty = true; } private _preprocessYaml() { return this.config; } private _yamlChanged(ev: CustomEvent) { ev.stopPropagation(); this.dirty = true; if (!ev.detail.isValid) { this.yamlErrors = ev.detail.errorMsg; return; } this.yamlErrors = undefined; this.config = ev.detail.value; this.errors = undefined; } protected async confirmUnsavedChanged(): Promise { if (!this.dirty) { return true; } return new Promise((resolve) => { showAutomationSaveDialog(this, { config: this.config!, domain: "script", updateConfig: async (config, entityRegistryUpdate) => { this.config = config; this.entityRegistryUpdate = entityRegistryUpdate; this.dirty = true; this.requestUpdate(); const id = this.scriptId || String(Date.now()); try { await this._saveScript(id); } catch (_err: any) { this.requestUpdate(); resolve(false); return; } resolve(true); }, onClose: () => resolve(false), onDiscard: () => resolve(true), entityRegistryUpdate: this.entityRegistryUpdate, entityRegistryEntry: this.registryEntry, title: this.hass.localize( this.scriptId ? "ui.panel.config.script.editor.leave.unsaved_confirm_title" : "ui.panel.config.script.editor.leave.unsaved_new_title" ), description: this.hass.localize( this.scriptId ? "ui.panel.config.script.editor.leave.unsaved_confirm_text" : "ui.panel.config.script.editor.leave.unsaved_new_text" ), hideInputs: this.scriptId !== null, }); }); } private async _takeControl() { const config = this.config as BlueprintScriptConfig; try { const result = await substituteBlueprint( this.hass, "script", config.use_blueprint.path, config.use_blueprint.input || {} ); const newConfig = { ...normalizeScriptConfig(result.substituted_config), alias: config.alias, description: config.description, }; this.blueprintConfig = config; this.config = newConfig; if (this.mode === "yaml") { this.renderRoot.querySelector("ha-yaml-editor")?.setValue(this.config); } this.readOnly = true; this.errors = undefined; } catch (err: any) { this.errors = err.message; } } private async _duplicate() { const result = this.readOnly ? await showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.script.picker.migrate_script" ), text: this.hass.localize( "ui.panel.config.script.picker.migrate_script_description" ), }) : await this.confirmUnsavedChanged(); if (result) { this.currentEntityId = undefined; showScriptEditor({ ...this.config, alias: this.readOnly ? this.config?.alias : `${this.config?.alias} (${this.hass.localize( "ui.panel.config.script.picker.duplicate" )})`, }); } } private async _deleteConfirm() { showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.script.editor.delete_confirm_title" ), text: this.hass.localize( "ui.panel.config.script.editor.delete_confirm_text", { name: this.config?.alias } ), confirmText: this.hass!.localize("ui.common.delete"), destructive: true, dismissText: this.hass!.localize("ui.common.cancel"), confirm: () => this._delete(), }); } private async _delete() { await deleteScript(this.hass, this.scriptId!); goBack("/config"); } private async _promptScriptAlias(): Promise { return new Promise((resolve) => { showAutomationSaveDialog(this, { config: this.config!, domain: "script", updateConfig: async (config, entityRegistryUpdate) => { this.config = config; this.entityRegistryUpdate = entityRegistryUpdate; this.dirty = true; this.requestUpdate(); resolve(true); }, onClose: () => resolve(false), entityRegistryUpdate: this.entityRegistryUpdate, entityRegistryEntry: this.entityRegistry.find( (entry) => entry.unique_id === this.scriptId ), }); }); } private async _promptScriptMode(): Promise { return new Promise((resolve) => { showAutomationModeDialog(this, { config: this.config!, updateConfig: (config) => { this.config = config; this.dirty = true; this.requestUpdate(); resolve(); }, onClose: () => resolve(), }); }); } private async _handleSaveScript() { if (this.yamlErrors) { showToast(this, { message: this.yamlErrors, }); return; } this._manualEditor?.resetPastedConfig(); if (!this.scriptId) { const saved = await this._promptScriptAlias(); if (!saved) { return; } this.currentEntityId = this._computeEntityIdFromAlias(this.config!.alias); } const id = this.scriptId || this.currentEntityId || Date.now(); await this._saveScript(id); if (!this.scriptId) { navigate(`/config/script/edit/${id}`, { replace: true }); } } private async _saveScript(id): Promise { this.saving = true; let entityRegPromise: Promise | undefined; if (this.entityRegistryUpdate !== undefined && !this.scriptId) { this._newScriptId = id.toString(); entityRegPromise = new Promise((resolve) => { this.entityRegCreated = resolve; }); } try { await this.hass!.callApi( "POST", "config/script/config/" + id, this.config ); if (this.entityRegistryUpdate !== undefined) { let entityId = this.currentEntityId; // wait for new script to appear in entity registry if (entityRegPromise) { try { const script = await promiseTimeout(5000, entityRegPromise); entityId = script.entity_id; } catch (e) { entityId = undefined; if (e instanceof Error && e.name === "TimeoutError") { // Show the dialog and give user a chance to wait for the registry // to respond. await showAutomationSaveTimeoutDialog(this, { savedPromise: entityRegPromise, type: "script", }); try { // We already gave the user a chance to wait once, so if they skipped // the dialog and it's still not there just immediately timeout. const automation = await promiseTimeout(0, entityRegPromise); entityId = automation.entity_id; } catch (e2) { if (!(e2 instanceof Error && e2.name === "TimeoutError")) { throw e2; } } } else { throw e; } } } if (entityId) { await updateEntityRegistryEntry(this.hass, entityId, { categories: { script: this.entityRegistryUpdate.category || null, }, labels: this.entityRegistryUpdate.labels || [], area_id: this.entityRegistryUpdate.area || null, }); } } this.dirty = false; } catch (errors: any) { this.errors = errors.body?.message || errors.error || errors.body; showToast(this, { message: errors.body?.message || errors.error || errors.body, }); throw errors; } finally { this.saving = false; } } protected supportedShortcuts(): SupportedShortcuts { return { s: () => this._handleSaveScript(), c: () => this._copySelectedRow(), x: () => this._cutSelectedRow(), Delete: () => this._deleteSelectedRow(), Backspace: () => this._deleteSelectedRow(), z: () => this._undo(), Z: () => this._redo(), y: () => this._redo(), }; } // @ts-ignore private _collapseAll() { this._manualEditor?.collapseAll(); } // @ts-ignore private _expandAll() { this._manualEditor?.expandAll(); } private _copySelectedRow() { this._manualEditor?.copySelectedRow(); } private _cutSelectedRow() { this._manualEditor?.cutSelectedRow(); } private _deleteSelectedRow() { this._manualEditor?.deleteSelectedRow(); } private _applyUndoRedo(config: ScriptConfig) { this._manualEditor?.triggerCloseSidebar(); this.config = config; this.dirty = true; } private _undo() { this._undoRedoController.undo(); } private _redo() { this._undoRedoController.redo(); } private _handleDropdownSelect(ev: HaDropdownSelectEvent) { const action = ev.detail?.item?.value; if (!action) { return; } switch (action) { case "undo": this._undo(); break; case "redo": this._redo(); break; case "info": this._showInfo(); break; case "settings": this.showSettings(); break; case "category": this._editCategory(); break; case "run": this._runScript(); break; case "add_fields": this._addFields(); break; case "rename": this._promptScriptAlias(); break; case "change_mode": this._promptScriptMode(); break; case "duplicate": this._duplicate(); break; case "take_control": this._takeControl(); break; case "toggle_yaml_mode": if (this.mode === "gui") { this.switchYamlMode(); break; } this.switchUiMode(); break; case "delete": this._deleteConfirm(); break; case "trace": this._showTrace(); break; } } static get styles(): CSSResultGroup { return [ haStyle, automationScriptEditorStyles, css` manual-script-editor, blueprint-script-editor { margin: 0 auto; max-width: 1040px; padding: 28px 20px 0; display: block; } :not(.yaml-mode) > .error-wrapper { position: absolute; top: 4px; z-index: 3; width: 100%; display: flex; flex-direction: column; align-items: center; } :not(.yaml-mode) > .error-wrapper ha-alert { background-color: var(--card-background-color); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); border-radius: var(--ha-border-radius-sm); } .alert-wrapper { position: sticky; top: 0; margin-top: 0; margin-bottom: 8px; z-index: 1; width: 100%; display: flex; flex-direction: column; align-items: center; gap: var(--ha-space-2); pointer-events: none; } .alert-wrapper ha-alert { background-color: var(--card-background-color); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); border-radius: var(--ha-border-radius-sm); margin-bottom: 0; pointer-events: auto; } manual-script-editor { max-width: var(--ha-automation-editor-max-width); padding: 0 12px; } span[slot="introduction"] a { color: var(--primary-color); } .header { display: flex; margin: 16px 0; align-items: center; } .header .name { font-size: var(--ha-font-size-xl); font-weight: var(--ha-font-weight-normal); flex: 1; } .header a { color: var(--secondary-text-color); } `, ]; } } declare global { interface HTMLElementTagNameMap { "ha-script-editor": HaScriptEditor; } interface HASSDomEvents { "save-script": undefined; } }