import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; import "@material/mwc-list/mwc-list-item"; import { mdiCheck, mdiContentDuplicate, mdiContentSave, mdiDelete, mdiDotsVertical, mdiHelpCircle, } from "@mdi/js"; import "@polymer/app-layout/app-header/app-header"; import "@polymer/app-layout/app-toolbar/app-toolbar"; import { css, CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, } from "lit"; import { property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; import { computeObjectId } from "../../../common/entity/compute_object_id"; import { navigate } from "../../../common/navigate"; import { slugify } from "../../../common/string/slugify"; import { computeRTL } from "../../../common/util/compute_rtl"; import { copyToClipboard } from "../../../common/util/copy-clipboard"; import "../../../components/ha-button-menu"; import "../../../components/ha-card"; import "../../../components/ha-fab"; import type { HaFormDataContainer, SchemaUnion, } from "../../../components/ha-form/types"; import "../../../components/ha-icon-button"; import "../../../components/ha-svg-icon"; import "../../../components/ha-yaml-editor"; import type { HaYamlEditor } from "../../../components/ha-yaml-editor"; import { Action, deleteScript, getScriptConfig, getScriptEditorInitData, isMaxMode, MODES, MODES_MAX, ScriptConfig, showScriptEditor, triggerScript, } from "../../../data/script"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/ha-app-layout"; import "../../../layouts/hass-subpage"; import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant, Route } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; import { showToast } from "../../../util/toast"; import { HaDeviceAction } from "../automation/action/types/ha-automation-action-device_id"; import "./blueprint-script-editor"; export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property() public scriptEntityId: string | null = null; @property({ attribute: false }) public route!: Route; @property({ type: Boolean }) public isWide = false; @property({ type: Boolean }) public narrow!: boolean; @state() private _config?: ScriptConfig; @state() private _entityId?: string; @state() private _idError = false; @state() private _dirty = false; @state() private _errors?: string; @state() private _mode: "gui" | "yaml" = "gui"; @query("ha-yaml-editor", true) private _editor?: HaYamlEditor; private _schema = memoizeOne( ( hasID: boolean, useBluePrint?: boolean, currentMode?: typeof MODES[number] ) => [ { name: "alias", selector: { text: { type: "text", }, }, }, { name: "icon", selector: { icon: {}, }, }, ...(!hasID ? ([ { name: "id", selector: { text: {}, }, }, ] as const) : []), ...(!useBluePrint ? ([ { name: "mode", selector: { select: { mode: "dropdown", options: MODES.map((mode) => ({ label: this.hass.localize( `ui.panel.config.script.editor.modes.${mode}` ), value: mode, })), }, }, }, ] as const) : []), ...(currentMode && isMaxMode(currentMode) ? ([ { name: "max", required: true, selector: { number: { mode: "box", min: 1, max: Infinity }, }, }, ] as const) : []), ] as const ); protected render(): TemplateResult { if (!this._config) { return html``; } const schema = this._schema( !!this.scriptEntityId, "use_blueprint" in this._config, this._config.mode ); const data = { mode: MODES[0], icon: undefined, max: this._config.mode && isMaxMode(this._config.mode) ? 10 : undefined, ...this._config, id: this._entityId, }; return html` ${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.duplicate")} ${this.hass.localize("ui.panel.config.script.picker.delete")}
    ${this._errors ? html`
    ${this._errors}
    ` : ""} ${this._mode === "gui" ? html`
    ${this._config ? html`
    ${this.scriptEntityId ? html`
    ${this.hass.localize( "ui.panel.config.script.editor.show_trace" )} ${this.hass.localize( "ui.panel.config.script.picker.run_script" )}
    ` : ``}
    ${"use_blueprint" in this._config ? html` ` : html`

    ${this.hass.localize( "ui.panel.config.script.editor.sequence" )}

    `} ` : ""}
    ` : this._mode === "yaml" ? html` ${!this.narrow ? html`
    ${this._config?.alias}
    ${this.hass.localize( "ui.panel.config.script.picker.run_script" )}
    ` : ``}
    ${this.hass.localize( "ui.panel.config.automation.editor.copy_to_clipboard" )}
    ` : ``}
    `; } protected updated(changedProps: PropertyValues): void { super.updated(changedProps); const oldScript = changedProps.get("scriptEntityId"); if ( changedProps.has("scriptEntityId") && this.scriptEntityId && this.hass && // Only refresh config if we picked a new script. If same ID, don't fetch it. (!oldScript || oldScript !== this.scriptEntityId) ) { getScriptConfig(this.hass, computeObjectId(this.scriptEntityId)).then( (config) => { // 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]; } this._dirty = false; this._config = config; }, (resp) => { 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 ) ); history.back(); } ); } if ( changedProps.has("scriptEntityId") && !this.scriptEntityId && 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 = [{ ...HaDeviceAction.defaultConfig }]; } this._config = { ...baseConfig, ...initData, } as ScriptConfig; } } private _computeLabelCallback = ( schema: SchemaUnion>, data: HaFormDataContainer ): string => { switch (schema.name) { case "mode": return this.hass.localize("ui.panel.config.script.editor.modes.label"); case "max": // Mode must be one of max modes per schema definition above return this.hass.localize( `ui.panel.config.script.editor.max.${ data.mode as typeof MODES_MAX[number] }` ); default: return this.hass.localize( `ui.panel.config.script.editor.${schema.name}` ); } }; private _computeHelperCallback = ( schema: SchemaUnion> ): string | undefined | TemplateResult => { if (schema.name === "mode") { return html` ${this.hass.localize( "ui.panel.config.script.editor.modes.learn_more" )} `; } return undefined; }; private async _runScript(ev: CustomEvent) { ev.stopPropagation(); await triggerScript(this.hass, this.scriptEntityId as string); showToast(this, { message: this.hass.localize( "ui.notification_toast.triggered", "name", this._config!.alias ), }); } private _modeChanged(mode) { const curMode = this._config!.mode || MODES[0]; if (mode === curMode) { return; } this._config = { ...this._config!, mode }; if (!isMaxMode(mode)) { delete this._config.max; } this._dirty = true; } private _aliasChanged(alias: string) { if ( this.scriptEntityId || (this._entityId && this._entityId !== slugify(this._config!.alias)) ) { return; } const aliasSlugify = slugify(alias); let id = aliasSlugify; let i = 2; while (this.hass.states[`script.${id}`]) { id = `${aliasSlugify}_${i}`; i++; } this._entityId = id; } private _idChanged(id: string) { this._entityId = id; if (this.hass.states[`script.${this._entityId}`]) { this._idError = true; } else { this._idError = false; } } private _valueChanged(ev: CustomEvent) { ev.stopPropagation(); const values = ev.detail.value as any; const currentId = this._entityId; let changed = false; for (const key of Object.keys(values)) { if (key === "sequence") { continue; } const value = values[key]; if ( value === this._config![key] || (key === "id" && currentId === value) ) { continue; } changed = true; switch (key) { case "id": this._idChanged(value); return; case "alias": this._aliasChanged(value); break; case "mode": this._modeChanged(value); return; } if (values[key] === undefined) { const newConfig = { ...this._config! }; delete newConfig![key]; this._config = newConfig; } else { this._config = { ...this._config!, [key]: value }; } } if (changed) { this._dirty = true; } } private _configChanged(ev) { this._config = ev.detail.value; this._dirty = true; } private _sequenceChanged(ev: CustomEvent): void { this._config = { ...this._config!, sequence: ev.detail.value as Action[], }; this._errors = undefined; this._dirty = true; } private _preprocessYaml() { return this._config; } private async _copyYaml(): Promise { if (this._editor?.yaml) { await copyToClipboard(this._editor.yaml); showToast(this, { message: this.hass.localize("ui.common.copied_clipboard"), }); } } private _yamlChanged(ev: CustomEvent) { ev.stopPropagation(); if (!ev.detail.isValid) { return; } this._config = ev.detail.value; this._errors = undefined; this._dirty = true; } private _backTapped = (): void => { if (this._dirty) { showConfirmationDialog(this, { text: this.hass!.localize( "ui.panel.config.common.editor.confirm_unsaved" ), confirmText: this.hass!.localize("ui.common.leave"), dismissText: this.hass!.localize("ui.common.stay"), confirm: () => { setTimeout(() => history.back()); }, }); } else { history.back(); } }; private async _duplicate() { if (this._dirty) { if ( !(await showConfirmationDialog(this, { text: this.hass!.localize( "ui.panel.config.common.editor.confirm_unsaved" ), confirmText: this.hass!.localize("ui.common.yes"), dismissText: this.hass!.localize("ui.common.no"), })) ) { return; } // Wait for dialog to complete closing await new Promise((resolve) => setTimeout(resolve, 0)); } showScriptEditor({ ...this._config, alias: `${this._config?.alias} (${this.hass.localize( "ui.panel.config.script.picker.duplicate" )})`, }); } private async _deleteConfirm() { showConfirmationDialog(this, { text: this.hass.localize("ui.panel.config.script.editor.delete_confirm"), confirmText: this.hass!.localize("ui.common.delete"), dismissText: this.hass!.localize("ui.common.cancel"), confirm: () => this._delete(), }); } private async _delete() { await deleteScript( this.hass, computeObjectId(this.scriptEntityId as string) ); history.back(); } private async _handleMenuAction(ev: CustomEvent) { switch (ev.detail.index) { case 0: this._mode = "gui"; break; case 1: this._mode = "yaml"; break; case 2: this._duplicate(); break; case 3: this._deleteConfirm(); break; } } private _saveScript(): void { if (this._idError) { showToast(this, { message: this.hass.localize( "ui.panel.config.script.editor.id_already_exists_save_error" ), dismissable: false, duration: 0, action: { action: () => {}, text: this.hass.localize("ui.dialogs.generic.ok"), }, }); return; } const id = this.scriptEntityId ? computeObjectId(this.scriptEntityId) : this._entityId || Date.now(); this.hass!.callApi("POST", "config/script/config/" + id, this._config).then( () => { this._dirty = false; if (!this.scriptEntityId) { navigate(`/config/script/edit/${id}`, { replace: true }); } }, (errors) => { this._errors = errors.body.message || errors.error || errors.body; showToast(this, { message: errors.body.message || errors.error || errors.body, }); throw errors; } ); } protected handleKeyboardSave() { this._saveScript(); } static get styles(): CSSResultGroup { return [ haStyle, css` ha-card { overflow: hidden; } 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; } blueprint-script-editor, .config-container, .sequence-container { margin: 0 auto; max-width: 1040px; padding: 28px 20px 0; } ha-yaml-editor { flex-grow: 1; --code-mirror-height: 100%; min-height: 0; } .yaml-mode ha-card { overflow: initial; --ha-card-border-radius: 0; border-bottom: 1px solid var(--divider-color); } 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); } `, ]; } } customElements.define("ha-script-editor", HaScriptEditor); declare global { interface HTMLElementTagNameMap { "ha-script-editor": HaScriptEditor; } }