From 80112bb662948de1c29832b4295295034c2c34da Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 25 Oct 2023 00:23:15 -0700 Subject: [PATCH] Add Fields to Script UI (#18250) --- .../ha-selector/ha-selector-date.ts | 2 +- .../ha-selector/ha-selector-datetime.ts | 3 +- .../ha-selector/ha-selector-number.ts | 13 +- .../ha-selector/ha-selector-time.ts | 2 +- src/data/script.ts | 15 + src/panels/config/script/ha-script-editor.ts | 42 +- .../config/script/ha-script-field-row.ts | 359 ++++++++++++++++++ src/panels/config/script/ha-script-fields.ts | 165 ++++++++ .../config/script/manual-script-editor.ts | 80 +++- src/translations/en.json | 21 +- 10 files changed, 681 insertions(+), 21 deletions(-) create mode 100644 src/panels/config/script/ha-script-field-row.ts create mode 100644 src/panels/config/script/ha-script-fields.ts diff --git a/src/components/ha-selector/ha-selector-date.ts b/src/components/ha-selector/ha-selector-date.ts index cfde72537e1d..8484e29a76ed 100644 --- a/src/components/ha-selector/ha-selector-date.ts +++ b/src/components/ha-selector/ha-selector-date.ts @@ -26,7 +26,7 @@ export class HaDateSelector extends LitElement { .label=${this.label} .locale=${this.hass.locale} .disabled=${this.disabled} - .value=${this.value} + .value=${typeof this.value === "string" ? this.value : undefined} .required=${this.required} .helper=${this.helper} > diff --git a/src/components/ha-selector/ha-selector-datetime.ts b/src/components/ha-selector/ha-selector-datetime.ts index 94d4ad9e041e..0ad02213f975 100644 --- a/src/components/ha-selector/ha-selector-datetime.ts +++ b/src/components/ha-selector/ha-selector-datetime.ts @@ -30,7 +30,8 @@ export class HaDateTimeSelector extends LitElement { @query("ha-time-input") private _timeInput!: HaTimeInput; protected render() { - const values = this.value?.split(" "); + const values = + typeof this.value === "string" ? this.value.split(" ") : undefined; return html`
diff --git a/src/components/ha-selector/ha-selector-number.ts b/src/components/ha-selector/ha-selector-number.ts index d7242e70759e..1e913e09b416 100644 --- a/src/components/ha-selector/ha-selector-number.ts +++ b/src/components/ha-selector/ha-selector-number.ts @@ -38,7 +38,10 @@ export class HaNumberSelector extends LitElement { } protected render() { - const isBox = this.selector.number?.mode === "box"; + const isBox = + this.selector.number?.mode === "box" || + this.selector.number?.min === undefined || + this.selector.number?.max === undefined; return html`
@@ -67,11 +70,9 @@ export class HaNumberSelector extends LitElement { (this.selector.number?.step ?? 1) % 1 !== 0 ? "decimal" : "numeric"} - .label=${this.selector.number?.mode !== "box" - ? undefined - : this.label} + .label=${!isBox ? undefined : this.label} .placeholder=${this.placeholder} - class=${classMap({ single: this.selector.number?.mode === "box" })} + class=${classMap({ single: isBox })} .min=${this.selector.number?.min} .max=${this.selector.number?.max} .value=${this._valueStr ?? ""} @@ -83,7 +84,7 @@ export class HaNumberSelector extends LitElement { .suffix=${this.selector.number?.unit_of_measurement} type="number" autoValidate - ?no-spinner=${this.selector.number?.mode !== "box"} + ?no-spinner=${!isBox} @input=${this._handleInputChange} > diff --git a/src/components/ha-selector/ha-selector-time.ts b/src/components/ha-selector/ha-selector-time.ts index 40982435a2b8..ceb8d9fe93eb 100644 --- a/src/components/ha-selector/ha-selector-time.ts +++ b/src/components/ha-selector/ha-selector-time.ts @@ -23,7 +23,7 @@ export class HaTimeSelector extends LitElement { protected render() { return html` + ${!useBlueprint && !("fields" in this._config) + ? html` + + ${this.hass.localize( + "ui.panel.config.script.editor.field.add_fields" + )} + + + ` + : nothing} ${this.scriptId && this.narrow ? html` @@ -661,6 +679,14 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) { } } + private _addFields() { + if ("fields" in this._config!) { + return; + } + this._manualEditor?.addFields(); + this._dirty = true; + } + private _valueChanged(ev: CustomEvent) { ev.stopPropagation(); if (this._readOnly) { diff --git a/src/panels/config/script/ha-script-field-row.ts b/src/panels/config/script/ha-script-field-row.ts new file mode 100644 index 000000000000..119bae74ac5d --- /dev/null +++ b/src/panels/config/script/ha-script-field-row.ts @@ -0,0 +1,359 @@ +import { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; +import "@material/mwc-list/mwc-list-item"; +import { mdiCheck, mdiDelete, mdiDotsVertical } from "@mdi/js"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { slugify } from "../../../common/string/slugify"; +import "../../../components/ha-alert"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-card"; +import "../../../components/ha-expansion-panel"; +import type { SchemaUnion } from "../../../components/ha-form/types"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-yaml-editor"; +import { Field } from "../../../data/script"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; + +const preventDefault = (ev) => ev.preventDefault(); + +@customElement("ha-script-field-row") +export default class HaScriptFieldRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public key!: string; + + @property() public excludeKeys: string[] = []; + + @property() public field!: Field; + + @property({ type: Boolean }) public disabled = false; + + @state() private _uiError?: Record; + + @state() private _yamlError?: undefined | "yaml_error" | "key_not_unique"; + + @state() private _yamlMode: boolean = false; + + private _errorKey?: string; + + private _schema = memoizeOne( + (selector: any) => + [ + { + name: "name", + selector: { text: {} }, + }, + { + name: "key", + selector: { text: {} }, + }, + { + name: "description", + selector: { text: {} }, + }, + { + name: "selector", + selector: { object: {} }, + }, + { + name: "default", + selector: selector && typeof selector === "object" ? selector : {}, + }, + { + name: "required", + selector: { boolean: {} }, + }, + ] as const + ); + + protected render() { + const schema = this._schema(this.field.selector); + const data = { ...this.field, key: this._errorKey ?? this.key }; + + const yamlValue = { [this.key]: this.field }; + + return html` + + +

${this.key}

+ + + + + + + ${this.hass.localize("ui.panel.config.automation.editor.edit_ui")} + ${!this._yamlMode + ? html` ` + : ``} + + + + ${this.hass.localize( + "ui.panel.config.automation.editor.edit_yaml" + )} + ${this._yamlMode + ? html`` + : ``} + + + + ${this.hass.localize( + "ui.panel.config.automation.editor.actions.delete" + )} + + + +
+ ${this._yamlMode + ? html` ${this._yamlError + ? html` + ${this.hass.localize( + `ui.panel.config.script.editor.field.${this._yamlError}` + )} + ` + : nothing} + ` + : html``} +
+
+
+ `; + } + + private async _handleAction(ev: CustomEvent) { + switch (ev.detail.index) { + case 0: + this._yamlMode = false; + break; + case 1: + this._yamlMode = true; + break; + case 2: + this._onDelete(); + break; + } + } + + private _onDelete() { + showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.script.editor.field_delete_confirm_title" + ), + text: this.hass.localize( + "ui.panel.config.script.editor.field_delete_confirm_text" + ), + dismissText: this.hass.localize("ui.common.cancel"), + confirmText: this.hass.localize("ui.common.delete"), + destructive: true, + confirm: () => { + fireEvent(this, "value-changed", { value: null }); + }, + }); + } + + private _onYamlChange(ev: CustomEvent) { + ev.stopPropagation(); + const value = { ...ev.detail.value }; + + if (typeof value !== "object" || Object.keys(value).length !== 1) { + this._yamlError = "yaml_error"; + return; + } + const key = Object.keys(value)[0]; + if (this.excludeKeys.includes(key)) { + this._yamlError = "key_not_unique"; + return; + } + this._yamlError = undefined; + + const newValue = { ...value[key], key }; + + fireEvent(this, "value-changed", { value: newValue }); + } + + private _maybeSetKey(value): void { + const nameChanged = value.name !== this.field.name; + const keyChanged = value.key !== this.key; + if (!nameChanged || keyChanged) { + return; + } + const slugifyName = this.field.name + ? slugify(this.field.name) + : this.hass.localize("ui.panel.config.script.editor.field.field") || + "field"; + const regex = new RegExp(`^${slugifyName}(_\\d)?$`); + if (regex.test(this.key)) { + let key = !value.name + ? this.hass.localize("ui.panel.config.script.editor.field.field") || + "field" + : slugify(value.name); + if (this.excludeKeys.includes(key)) { + let uniqueKey = key; + let i = 2; + do { + uniqueKey = `${key}_${i}`; + i++; + } while (this.excludeKeys.includes(uniqueKey)); + key = uniqueKey; + } + value.key = key; + } + } + + private _valueChanged(ev: CustomEvent) { + ev.stopPropagation(); + const value = { ...ev.detail.value }; + + this._maybeSetKey(value); + + // Don't allow to set an empty key, or duplicate an existing key. + if (!value.key || this.excludeKeys.includes(value.key)) { + this._uiError = value.key + ? { + key: "key_not_unique", + } + : { + key: "key_not_null", + }; + this._errorKey = value.key ?? ""; + return; + } + this._errorKey = undefined; + this._uiError = undefined; + + fireEvent(this, "value-changed", { value }); + } + + public expand() { + this.updateComplete.then(() => { + this.shadowRoot!.querySelector("ha-expansion-panel")!.expanded = true; + }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ): string => { + switch (schema.name) { + default: + return this.hass.localize( + `ui.panel.config.script.editor.field.${schema.name}` + ); + } + }; + + private _computeError = (error: string) => + this.hass.localize(`ui.panel.config.script.editor.field.${error}` as any) || + error; + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + ha-button-menu, + ha-icon-button { + --mdc-theme-text-primary-on-background: var(--primary-text-color); + } + .disabled { + opacity: 0.5; + pointer-events: none; + } + ha-expansion-panel { + --expansion-panel-summary-padding: 0 0 0 8px; + --expansion-panel-content-padding: 0; + } + h3 { + margin: 0; + font-size: inherit; + font-weight: inherit; + } + .action-icon { + display: none; + } + @media (min-width: 870px) { + .action-icon { + display: inline-block; + color: var(--secondary-text-color); + opacity: 0.9; + margin-right: 8px; + } + } + .card-content { + padding: 16px; + } + .disabled-bar { + background: var(--divider-color, #e0e0e0); + text-align: center; + border-top-right-radius: var(--ha-card-border-radius); + border-top-left-radius: var(--ha-card-border-radius); + } + + mwc-list-item[disabled] { + --mdc-theme-text-primary-on-background: var(--disabled-text-color); + } + .warning ul { + margin: 4px 0; + } + .selected_menu_item { + color: var(--primary-color); + } + li[role="separator"] { + border-bottom-color: var(--divider-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-script-field-row": HaScriptFieldRow; + } +} diff --git a/src/panels/config/script/ha-script-fields.ts b/src/panels/config/script/ha-script-fields.ts new file mode 100644 index 000000000000..b50a28a4b23b --- /dev/null +++ b/src/panels/config/script/ha-script-fields.ts @@ -0,0 +1,165 @@ +import "@material/mwc-button"; +import { mdiPlus } from "@mdi/js"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + css, + html, + nothing, +} from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-button"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-svg-icon"; +import { Fields } from "../../../data/script"; +import { sortableStyles } from "../../../resources/ha-sortable-style"; +import { HomeAssistant } from "../../../types"; +import "./ha-script-field-row"; +import type HaScriptFieldRow from "./ha-script-field-row"; + +@customElement("ha-script-fields") +export default class HaScriptFields extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public disabled = false; + + @property() public fields!: Fields; + + private _focusLastActionOnChange = false; + + protected render() { + return html` + ${this.fields + ? html`
+ ${Object.entries(this.fields).map( + ([key, field]) => html` + k !== key + )} + .field=${field} + .disabled=${this.disabled} + @value-changed=${this._fieldChanged} + .hass=${this.hass} + > + + ` + )} +
` + : nothing} + + + + `; + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + + if (changedProps.has("fields") && this._focusLastActionOnChange) { + this._focusLastActionOnChange = false; + this.focusLastField(); + } + } + + public focusLastField() { + const row = this.shadowRoot!.querySelector( + "ha-script-field-row:last-of-type" + )!; + row.updateComplete.then(() => { + row.expand(); + row.scrollIntoView(); + row.focus(); + }); + } + + private _addField() { + const key = this._getUniqueKey( + this.hass.localize("ui.panel.config.script.editor.field.field") || + "field", + this.fields || {} + ); + const fields = { + ...(this.fields || {}), + [key]: { + selector: { + text: null, + }, + }, + }; + this._focusLastActionOnChange = true; + fireEvent(this, "value-changed", { value: fields }); + } + + private _fieldChanged(ev: CustomEvent) { + ev.stopPropagation(); + const key = (ev.target as any).key; + let fields: Fields = {}; + if (ev.detail.value === null) { + fields = { ...this.fields }; + delete fields[key]; + } else { + const newValue = { ...ev.detail.value }; + const newKey = newValue.key; + delete newValue.key; + const keyChanged = key !== newKey; + + // If key is changed, recreate the object to maintain the same insertion order. + if (keyChanged) { + Object.entries(this.fields).forEach(([k, v]) => { + if (k === key) { + fields[newKey] = newValue; + } else fields[k] = v; + }); + } else { + fields = { ...this.fields }; + fields[key] = newValue; + } + } + fireEvent(this, "value-changed", { value: fields }); + } + + private _getUniqueKey(base: string, fields: Fields): string { + let key = base; + if (base in fields) { + let i = 2; + do { + key = `${base}_${i}`; + i++; + } while (key in fields); + } + return key; + } + + static get styles(): CSSResultGroup { + return [ + sortableStyles, + css` + ha-script-field-row { + display: block; + margin-bottom: 16px; + scroll-margin-top: 48px; + } + ha-svg-icon { + height: 20px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-script-fields": HaScriptFields; + } +} diff --git a/src/panels/config/script/manual-script-editor.ts b/src/panels/config/script/manual-script-editor.ts index d7d7ced9cf17..a648ec8bbcbc 100644 --- a/src/panels/config/script/manual-script-editor.ts +++ b/src/panels/config/script/manual-script-editor.ts @@ -1,15 +1,17 @@ import "@material/mwc-button/mwc-button"; import { mdiHelpCircle } from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, query } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-card"; import "../../../components/ha-icon-button"; -import { Action, ScriptConfig } from "../../../data/script"; +import { Action, Fields, ScriptConfig } from "../../../data/script"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; import "../automation/action/ha-automation-action"; +import "./ha-script-fields"; +import type HaScriptFields from "./ha-script-fields"; @customElement("manual-script-editor") export class HaManualScriptEditor extends LitElement { @@ -23,6 +25,37 @@ export class HaManualScriptEditor extends LitElement { @property({ attribute: false }) public config!: ScriptConfig; + @query("ha-script-fields") + private _scriptFields?: HaScriptFields; + + private _openFields = false; + + public addFields() { + this._openFields = true; + fireEvent(this, "value-changed", { + value: { + ...this.config, + fields: { + [this.hass.localize("ui.panel.config.script.editor.field.field") || + "field"]: { + selector: { + text: null, + }, + }, + }, + }, + }); + } + + protected updated(changedProps) { + if (this._openFields && changedProps.has("config")) { + this._openFields = false; + this._scriptFields?.updateComplete.then( + () => this._scriptFields?.focusLastField() + ); + } + } + protected render() { return html` ${this.disabled @@ -33,6 +66,40 @@ export class HaManualScriptEditor extends LitElement { ` : ""} + ${this.config.fields + ? html`
+ + ` + : nothing} +

${this.hass.localize("ui.panel.config.script.editor.sequence")} @@ -63,6 +130,13 @@ export class HaManualScriptEditor extends LitElement { `; } + private _fieldsChanged(ev: CustomEvent): void { + ev.stopPropagation(); + fireEvent(this, "value-changed", { + value: { ...this.config!, fields: ev.detail.value as Fields }, + }); + } + private _sequenceChanged(ev: CustomEvent): void { ev.stopPropagation(); fireEvent(this, "value-changed", { diff --git a/src/translations/en.json b/src/translations/en.json index a8fed37060d9..e2a37d7c161e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -311,7 +311,8 @@ "successfully_deleted": "Successfully deleted", "error_required": "Required", "copied": "Copied", - "copied_clipboard": "Copied to clipboard" + "copied_clipboard": "Copied to clipboard", + "name": "Name" }, "components": { "selectors": { @@ -3006,6 +3007,24 @@ "unavailable": "Script is unavailable", "migrate": "Migrate", "duplicate": "[%key:ui::common::duplicate%]", + "field": { + "name": "[%key:ui::common::name%]", + "key": "Field variable key name", + "description": "Description", + "required": "Required", + "default": "Default", + "selector": "Selector", + "yaml_error": "Field yaml has invalid format.", + "key_not_null": "The field key must not be empty.", + "key_not_unique": "The field key must not be the same value as another field.", + "fields": "Fields", + "link_help_fields": "Learn more about fields.", + "add_fields": "Add fields", + "add_field": "Add field", + "field": "field" + }, + "field_delete_confirm_title": "Delete field?", + "field_delete_confirm_text": "[%key:ui::panel::config::automation::editor::triggers::delete_confirm_text%]", "header": "Script: {name}", "default_name": "New Script", "modes": {