import "@material/mwc-button"; import { mdiCheck, mdiContentDuplicate, mdiContentSave, mdiDebugStepOver, mdiDelete, mdiDotsVertical, mdiFileEdit, mdiFormTextbox, mdiInformationOutline, mdiPlay, mdiRenameBox, mdiRobotConfused, mdiTransitConnection, } from "@mdi/js"; 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 { 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 "../../../components/ha-button-menu"; import "../../../components/ha-fab"; import "../../../components/ha-icon-button"; import "../../../components/ha-list-item"; import "../../../components/ha-svg-icon"; import "../../../components/ha-yaml-editor"; import { validateConfig } from "../../../data/config"; import { UNAVAILABLE } from "../../../data/entity"; import type { EntityRegistryEntry } from "../../../data/entity_registry"; import type { BlueprintScriptConfig, ScriptConfig } from "../../../data/script"; import { deleteScript, fetchScriptFileConfig, getScriptEditorInitData, getScriptStateConfig, hasScriptFields, migrateAutomationAction, showScriptEditor, triggerScript, } from "../../../data/script"; import { 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 { 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 { showAutomationRenameDialog } from "../automation/automation-rename-dialog/show-dialog-automation-rename"; import "./blueprint-script-editor"; import "./manual-script-editor"; import type { HaManualScriptEditor } from "./manual-script-editor"; import { substituteBlueprint } from "../../../data/blueprint"; export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public scriptId: string | null = null; @property({ attribute: false }) public entityId: string | null = null; @property({ attribute: false }) public entityRegistry!: EntityRegistryEntry[]; @property({ attribute: false, type: Boolean }) public isWide = false; @property({ type: Boolean }) public narrow = false; @property({ attribute: false }) public route!: Route; @state() private _config?: ScriptConfig; @state() private _dirty = false; @state() private _errors?: string; @state() private _yamlErrors?: string; @state() private _entityId?: string; @state() private _mode: "gui" | "yaml" = "gui"; @state() private _readOnly = false; @query("manual-script-editor") private _manualEditor?: HaManualScriptEditor; @state() private _validationErrors?: (string | TemplateResult)[]; @state() private _blueprintConfig?: BlueprintScriptConfig; protected render(): TemplateResult | typeof nothing { if (!this._config) { return nothing; } const stateObj = this._entityId ? this.hass.states[this._entityId] : undefined; const useBlueprint = "use_blueprint" in this._config; return html` ${this.scriptId && !this.narrow ? html` ${this.hass.localize( "ui.panel.config.script.editor.show_trace" )} ` : ""} ${this.hass.localize("ui.panel.config.script.editor.show_info")} ${this.hass.localize("ui.panel.config.script.picker.run_script")} ${this.scriptId && this.narrow ? html` ${this.hass.localize( "ui.panel.config.script.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_ui")} ${this._mode === "gui" ? html` ` : ``} ${this.hass.localize("ui.panel.config.automation.editor.edit_yaml")} ${this._mode === "yaml" ? html`` : ``}
  • ${this.hass.localize("ui.panel.config.script.picker.delete")}
    ${this._errors || stateObj?.state === UNAVAILABLE ? html` ${this._errors || this._validationErrors} ${stateObj?.state === UNAVAILABLE ? html`` : 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 === "gui" ? html`
    ${useBlueprint ? html` ` : html` `}
    ` : this._mode === "yaml" ? html`` : nothing}
    `; } 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._loadConfig(); } if ( (changedProps.has("scriptId") || changedProps.has("entityRegistry")) && this.scriptId && this.entityRegistry ) { // find entity for when script entity id changed const entity = this.entityRegistry.find( (ent) => ent.platform === "script" && ent.unique_id === this.scriptId ); this._entityId = entity?.entity_id; } if (changedProps.has("scriptId") && !this.scriptId && this.hass) { const initData = getScriptEditorInitData(); this._dirty = !!initData; const baseConfig: Partial = { alias: this.hass.localize("ui.panel.config.script.editor.default_name"), }; if (!initData || !("use_blueprint" in initData)) { baseConfig.sequence = []; } this._config = { ...baseConfig, ...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._checkValidation(); }); const regEntry = this.entityRegistry.find( (ent) => ent.entity_id === this.entityId ); if (regEntry?.unique_id) { this.scriptId = regEntry.unique_id; } this._entityId = this.entityId; this._dirty = false; this._readOnly = true; } } private async _checkValidation() { this._validationErrors = undefined; if (!this._entityId || !this._config) { return; } const stateObj = this.hass.states[this._entityId]; 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 _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); const entity = this.entityRegistry.find( (ent) => ent.platform === "script" && ent.unique_id === this.scriptId ); this._entityId = entity?.entity_id; this._checkValidation(); }, (resp) => { const entity = this.entityRegistry.find( (ent) => ent.platform === "script" && ent.unique_id === this.scriptId ); if (entity) { navigate(`/config/script/show/${entity.entity_id}`, { replace: true, }); return; } alert( resp.status_code === 404 ? this.hass.localize( "ui.panel.config.script.editor.load_error_not_editable" ) : this.hass.localize( "ui.panel.config.script.editor.load_error_unknown", { err_no: resp.status_code || resp.code } ) ); history.back(); } ); } private _valueChanged(ev) { this._config = ev.detail.value; this._errors = undefined; this._dirty = true; } private async _runScript(ev: CustomEvent) { ev.stopPropagation(); if (hasScriptFields(this.hass, this._entityId!)) { showMoreInfoDialog(this, { entityId: this._entityId!, }); return; } await triggerScript(this.hass, this.scriptId!); showToast(this, { message: this.hass.localize("ui.notification_toast.triggered", { name: this._config!.alias, }), }); } 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; } 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; } private async _confirmUnsavedChanged(): Promise { if (this._dirty) { return showConfirmationDialog(this, { title: this.hass!.localize( "ui.panel.config.automation.editor.unsaved_confirm_title" ), text: this.hass!.localize( "ui.panel.config.automation.editor.unsaved_confirm_text" ), confirmText: this.hass!.localize("ui.common.leave"), dismissText: this.hass!.localize("ui.common.stay"), destructive: true, }); } return true; } private _backTapped = async () => { const result = await this._confirmUnsavedChanged(); if (result) { afterNextRender(() => history.back()); } }; 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 = { ...this._normalizeConfig(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 _revertBlueprint() { this._config = this._blueprintConfig; if (this._mode === "yaml") { this.renderRoot.querySelector("ha-yaml-editor")?.setValue(this._config); } this._blueprintConfig = undefined; this._readOnly = false; } private _takeControlSave() { this._readOnly = false; this._dirty = true; this._blueprintConfig = undefined; } 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._entityId = 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!); history.back(); } private async _switchUiMode() { if (this._yamlErrors) { const result = await showConfirmationDialog(this, { text: html`${this.hass.localize( "ui.panel.config.automation.editor.switch_ui_yaml_error" )}

    ${this._yamlErrors}`, confirmText: this.hass!.localize("ui.common.continue"), destructive: true, dismissText: this.hass!.localize("ui.common.cancel"), }); if (!result) { return; } } this._yamlErrors = undefined; this._mode = "gui"; } private _switchYamlMode() { this._mode = "yaml"; } private async _promptScriptAlias(): Promise { return new Promise((resolve) => { showAutomationRenameDialog(this, { config: this._config!, domain: "script", updateConfig: (config) => { this._config = config; this._dirty = true; this.requestUpdate(); resolve(true); }, onClose: () => resolve(false), }); }); } 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 _saveScript(): Promise { if (this._yamlErrors) { showToast(this, { message: this._yamlErrors, }); return; } if (!this.scriptId) { const saved = await this._promptScriptAlias(); if (!saved) { return; } this._entityId = this._computeEntityIdFromAlias(this._config!.alias); } const id = this.scriptId || this._entityId || Date.now(); try { await this.hass!.callApi( "POST", "config/script/config/" + id, this._config ); } catch (errors: any) { this._errors = errors.body.message || errors.error || errors.body; showToast(this, { message: errors.body.message || errors.error || errors.body, }); throw errors; } this._dirty = false; if (!this.scriptId) { navigate(`/config/script/edit/${id}`, { replace: true }); } } protected handleKeyboardSave() { this._saveScript(); } static get styles(): CSSResultGroup { return [ haStyle, css` p { margin-bottom: 0; } .errors { padding: 20px; font-weight: bold; color: var(--error-color); } .yaml-mode { height: 100%; display: flex; flex-direction: column; padding-bottom: 0; } .config-container, manual-script-editor, blueprint-script-editor, :not(.yaml-mode) > ha-alert { margin: 0 auto; max-width: 1040px; padding: 28px 20px 0; display: block; } .config-container ha-alert { margin-bottom: 16px; display: block; } ha-yaml-editor { flex-grow: 1; --actions-border-radius: 0; --code-mirror-height: 100%; min-height: 0; display: flex; flex-direction: column; } span[slot="introduction"] a { color: var(--primary-color); } ha-fab { position: relative; bottom: calc(-80px - env(safe-area-inset-bottom)); transition: bottom 0.3s; } ha-fab.dirty { bottom: 0; } .selected_menu_item { color: var(--primary-color); } li[role="separator"] { border-bottom-color: var(--divider-color); } .header { display: flex; margin: 16px 0; align-items: center; } .header .name { font-size: 20px; font-weight: 400; flex: 1; } .header a { color: var(--secondary-text-color); } ha-button-menu a { text-decoration: none; color: var(--primary-color); } `, ]; } } customElements.define("ha-script-editor", HaScriptEditor); declare global { interface HTMLElementTagNameMap { "ha-script-editor": HaScriptEditor; } }