Files
frontend/src/panels/config/script/ha-script-editor.ts
Bram Kragten 55b66250f4 Take convert of blueprint automation and script (#21151)
* substituteBlueprint

* WIP ux

* Simplify feature

* Add take control to scripts

* Add translations and catch error

* Clean import

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2024-06-26 11:24:47 +02:00

878 lines
26 KiB
TypeScript

import "@material/mwc-button";
import {
mdiCheck,
mdiContentDuplicate,
mdiContentSave,
mdiDebugStepOver,
mdiDelete,
mdiDotsVertical,
mdiFileEdit,
mdiFormTextbox,
mdiInformationOutline,
mdiPlay,
mdiRenameBox,
mdiRobotConfused,
mdiTransitConnection,
} from "@mdi/js";
import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
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 { EntityRegistryEntry } from "../../../data/entity_registry";
import {
BlueprintScriptConfig,
ScriptConfig,
deleteScript,
fetchScriptFileConfig,
getScriptEditorInitData,
getScriptStateConfig,
hasScriptFields,
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() public scriptId: string | null = null;
@property() public entityId: string | null = null;
@property({ attribute: false }) public entityRegistry!: EntityRegistryEntry[];
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _config?: ScriptConfig;
@state() private _idError = false;
@state() private _dirty = false;
@state() private _errors?: 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)[];
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`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.backCallback=${this._backTapped}
.header=${!this._config.alias ? "" : this._config.alias}
>
${this.scriptId && !this.narrow
? html`
<mwc-button @click=${this._showTrace} slot="toolbar-icon">
${this.hass.localize(
"ui.panel.config.script.editor.show_trace"
)}
</mwc-button>
`
: ""}
<ha-button-menu slot="toolbar-icon">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item
graphic="icon"
.disabled=${!this.scriptId}
@click=${this._showInfo}
>
${this.hass.localize("ui.panel.config.script.editor.show_info")}
<ha-svg-icon
slot="graphic"
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item>
<ha-list-item
graphic="icon"
.disabled=${!this.scriptId}
@click=${this._runScript}
>
${this.hass.localize("ui.panel.config.script.picker.run_script")}
<ha-svg-icon slot="graphic" .path=${mdiPlay}></ha-svg-icon>
</ha-list-item>
${this.scriptId && this.narrow
? html`
<a href="/config/script/trace/${this.scriptId}">
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.script.editor.show_trace"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiTransitConnection}
></ha-svg-icon>
</ha-list-item>
</a>
`
: nothing}
${!useBlueprint && !("fields" in this._config)
? html`
<ha-list-item
graphic="icon"
.disabled=${this._readOnly || this._mode === "yaml"}
@click=${this._addFields}
>
${this.hass.localize(
"ui.panel.config.script.editor.field.add_fields"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiFormTextbox}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
<ha-list-item
graphic="icon"
@click=${this._promptScriptAlias}
.disabled=${!this.scriptId ||
this._readOnly ||
this._mode === "yaml"}
>
${this.hass.localize("ui.panel.config.script.editor.rename")}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-list-item>
${!useBlueprint
? html`
<ha-list-item
graphic="icon"
@click=${this._promptScriptMode}
.disabled=${this._readOnly || this._mode === "yaml"}
>
${this.hass.localize(
"ui.panel.config.script.editor.change_mode"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiDebugStepOver}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
<ha-list-item
.disabled=${!this._readOnly && !this.scriptId}
graphic="icon"
@click=${this._duplicate}
>
${this.hass.localize(
this._readOnly
? "ui.panel.config.script.editor.migrate"
: "ui.panel.config.script.editor.duplicate"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-list-item>
${useBlueprint
? html`
<ha-list-item
graphic="icon"
@click=${this._takeControl}
.disabled=${this._readOnly || this._mode === "yaml"}
>
${this.hass.localize(
"ui.panel.config.script.editor.take_control"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiFileEdit}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
<li divider role="separator"></li>
<ha-list-item graphic="icon" @click=${this._switchUiMode}>
${this.hass.localize("ui.panel.config.automation.editor.edit_ui")}
${this._mode === "gui"
? html`<ha-svg-icon
class="selected_menu_item"
slot="graphic"
.path=${mdiCheck}
></ha-svg-icon> `
: ``}
</ha-list-item>
<ha-list-item graphic="icon" @click=${this._switchYamlMode}>
${this.hass.localize("ui.panel.config.automation.editor.edit_yaml")}
${this._mode === "yaml"
? html`<ha-svg-icon
class="selected_menu_item"
slot="graphic"
.path=${mdiCheck}
></ha-svg-icon>`
: ``}
</ha-list-item>
<li divider role="separator"></li>
<ha-list-item
.disabled=${this._readOnly || !this.scriptId}
class=${classMap({ warning: Boolean(this.scriptId) })}
graphic="icon"
@click=${this._deleteConfirm}
>
${this.hass.localize("ui.panel.config.script.picker.delete")}
<ha-svg-icon
class=${classMap({ warning: Boolean(this.scriptId) })}
slot="graphic"
.path=${mdiDelete}
>
</ha-svg-icon>
</ha-list-item>
</ha-button-menu>
<div
class="content ${classMap({
"yaml-mode": this._mode === "yaml",
})}"
>
${this._errors || stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
.title=${stateObj?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.script.editor.unavailable"
)
: undefined}
>
${this._errors || this._validationErrors}
${stateObj?.state === UNAVAILABLE
? html`<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>`
: nothing}
</ha-alert>`
: ""}
${this._mode === "gui"
? html`
<div
class=${classMap({
rtl: computeRTL(this.hass),
})}
>
${useBlueprint
? html`
<blueprint-script-editor
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
.config=${this._config}
.disabled=${this._readOnly}
@value-changed=${this._valueChanged}
@duplicate=${this._duplicate}
></blueprint-script-editor>
`
: html`
<manual-script-editor
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
.config=${this._config}
.disabled=${this._readOnly}
@value-changed=${this._valueChanged}
@duplicate=${this._duplicate}
></manual-script-editor>
`}
</div>
`
: this._mode === "yaml"
? html` ${this._readOnly
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.script.editor.read_only"
)}
<mwc-button slot="action" @click=${this._duplicate}>
${this.hass.localize(
"ui.panel.config.script.editor.migrate"
)}
</mwc-button>
</ha-alert>`
: nothing}
<ha-yaml-editor
copyClipboard
.hass=${this.hass}
.defaultValue=${this._preprocessYaml()}
.readOnly=${this._readOnly}
@value-changed=${this._yamlChanged}
></ha-yaml-editor>`
: nothing}
</div>
<ha-fab
slot="fab"
class=${classMap({
dirty: this._dirty,
})}
.label=${this.hass.localize(
"ui.panel.config.script.editor.save_script"
)}
extended
@click=${this._saveScript}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
</hass-subpage>
`;
}
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") && !this.scriptId && this.hass) {
const initData = getScriptEditorInitData();
this._dirty = !!initData;
const baseConfig: Partial<ScriptConfig> = {
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 _setEntityId(id?: string) {
this._entityId = id;
if (this.hass.states[`script.${this._entityId}`]) {
this._idError = true;
} else {
this._idError = false;
}
}
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, {
action: this._config.sequence,
});
this._validationErrors = (
Object.entries(validation) as Entries<typeof validation>
).map(([key, value]) =>
value.valid
? ""
: html`${this.hass.localize(
`ui.panel.config.automation.editor.${key}s.name`
)}:
${value.error}<br />`
);
}
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];
}
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();
if (!ev.detail.isValid) {
return;
}
this._config = ev.detail.value;
this._errors = undefined;
this._dirty = true;
}
private async confirmUnsavedChanged(): Promise<boolean> {
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;
const confirmation = await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.script.editor.take_control_confirmation.title"
),
text: this.hass!.localize(
"ui.panel.config.script.editor.take_control_confirmation.text"
),
confirmText: this.hass!.localize(
"ui.panel.config.script.editor.take_control_confirmation.action"
),
});
if (!confirmation) return;
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._config = newConfig;
this._dirty = true;
this._errors = undefined;
} catch (err: any) {
this._errors = err.message;
}
}
private async _duplicate() {
const result = this._readOnly
? await showConfirmationDialog(this, {
title: "Migrate script?",
text: "You can migrate this script, so it can be edited from the UI. After it is migrated and you have saved it, you will have to manually delete your old script from your configuration. Do you want to migrate this script?",
})
: 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 _switchUiMode() {
this._mode = "gui";
}
private _switchYamlMode() {
this._mode = "yaml";
}
private async _promptScriptAlias(): Promise<boolean> {
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<void> {
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<void> {
if (this._idError) {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.script.editor.id_already_exists_save_error"
),
dismissable: false,
duration: -1,
action: {
action: () => {},
text: this.hass.localize("ui.dialogs.generic.ok"),
},
});
return;
}
if (!this.scriptId) {
const saved = await this._promptScriptAlias();
if (!saved) {
return;
}
const entityId = this._computeEntityIdFromAlias(this._config!.alias);
this._setEntityId(entityId);
}
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 {
margin: 0 auto;
max-width: 1040px;
padding: 28px 20px 0;
}
.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;
}
}