Add Fields to Script UI (#18250)

This commit is contained in:
karwosts 2023-10-25 00:23:15 -07:00 committed by GitHub
parent 7ce7cbb755
commit 80112bb662
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 681 additions and 21 deletions

View File

@ -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}
>

View File

@ -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`
<div class="input">

View File

@ -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`
<div class="input">
@ -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}
>
</ha-textfield>

View File

@ -23,7 +23,7 @@ export class HaTimeSelector extends LitElement {
protected render() {
return html`
<ha-time-input
.value=${this.value}
.value=${typeof this.value === "string" ? this.value : undefined}
.locale=${this.hass.locale}
.disabled=${this.disabled}
.required=${this.required}

View File

@ -93,12 +93,27 @@ export interface ManualScriptConfig {
icon?: string;
mode?: (typeof MODES)[number];
max?: number;
fields?: Fields;
}
export interface BlueprintScriptConfig extends ManualScriptConfig {
use_blueprint: { path: string; input?: BlueprintInput };
}
export interface Fields {
[key: string]: Field;
}
export interface Field {
name?: string;
description?: string;
advanced?: boolean;
required?: boolean;
example?: string;
default?: any;
selector?: any;
}
interface BaseAction {
alias?: string;
continue_on_error?: boolean;

View File

@ -5,17 +5,18 @@ import {
mdiContentSave,
mdiDelete,
mdiDotsVertical,
mdiFormTextbox,
mdiInformationOutline,
mdiPlay,
mdiTransitConnection,
} from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { property, query, state } from "lit/decorators";
@ -38,16 +39,18 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../components/ha-yaml-editor";
import { validateConfig } from "../../../data/config";
import { UNAVAILABLE } from "../../../data/entity";
import { EntityRegistryEntry } from "../../../data/entity_registry";
import {
MODES,
MODES_MAX,
ScriptConfig,
deleteScript,
fetchScriptFileConfig,
getScriptEditorInitData,
getScriptStateConfig,
isMaxMode,
MODES,
MODES_MAX,
ScriptConfig,
showScriptEditor,
triggerScript,
} from "../../../data/script";
@ -60,8 +63,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import "./blueprint-script-editor";
import "./manual-script-editor";
import { UNAVAILABLE } from "../../../data/entity";
import { validateConfig } from "../../../data/config";
import type { HaManualScriptEditor } from "./manual-script-editor";
export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -92,7 +94,10 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
@state() private _readOnly = false;
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
@query("manual-script-editor")
private _manualEditor?: HaManualScriptEditor;
@state() private _validationErrors?: (string | TemplateResult)[];
@ -231,6 +236,19 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
<ha-svg-icon slot="graphic" .path=${mdiPlay}></ha-svg-icon>
</mwc-list-item>
${!useBlueprint && !("fields" in this._config)
? html`
<mwc-list-item graphic="icon" @click=${this._addFields}>
${this.hass.localize(
"ui.panel.config.script.editor.field.add_fields"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiFormTextbox}
></ha-svg-icon>
</mwc-list-item>
`
: nothing}
${this.scriptId && this.narrow
? html`
<a href="/config/script/trace/${this.scriptId}">
@ -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) {

View File

@ -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<string, string>;
@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`
<ha-card outlined>
<ha-expansion-panel leftChevron>
<h3 slot="header">${this.key}</h3>
<slot name="icons" slot="icons"></slot>
<ha-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item graphic="icon">
${this.hass.localize("ui.panel.config.automation.editor.edit_ui")}
${!this._yamlMode
? html` <ha-svg-icon
class="selected_menu_item"
slot="graphic"
.path=${mdiCheck}
></ha-svg-icon>`
: ``}
</mwc-list-item>
<mwc-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.automation.editor.edit_yaml"
)}
${this._yamlMode
? html`<ha-svg-icon
class="selected_menu_item"
slot="graphic"
.path=${mdiCheck}
></ha-svg-icon>`
: ``}
</mwc-list-item>
<mwc-list-item
class="warning"
graphic="icon"
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</mwc-list-item>
</ha-button-menu>
<div
class=${classMap({
"card-content": true,
})}
>
${this._yamlMode
? html` ${this._yamlError
? html`<ha-alert alert-type="error">
${this.hass.localize(
`ui.panel.config.script.editor.field.${this._yamlError}`
)}
</ha-alert>`
: nothing}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${yamlValue}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>`
: html`<ha-form
.schema=${schema}
.data=${data}
.error=${this._uiError}
.hass=${this.hass}
.disabled=${this.disabled}
.computeLabel=${this._computeLabelCallback}
.computeError=${this._computeError}
@value-changed=${this._valueChanged}
></ha-form>`}
</div>
</ha-expansion-panel>
</ha-card>
`;
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
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<ReturnType<typeof this._schema>>
): 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;
}
}

View File

@ -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`<div class="fields">
${Object.entries(this.fields).map(
([key, field]) => html`
<ha-script-field-row
.key=${key}
.excludeKeys=${Object.keys(this.fields).filter(
(k) => k !== key
)}
.field=${field}
.disabled=${this.disabled}
@value-changed=${this._fieldChanged}
.hass=${this.hass}
>
</ha-script-field-row>
`
)}
</div> `
: nothing}
<ha-button
outlined
@click=${this._addField}
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.script.editor.field.add_field"
)}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
`;
}
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<HaScriptFieldRow>(
"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;
}
}

View File

@ -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 {
</mwc-button>
</ha-alert>`
: ""}
${this.config.fields
? html`<div class="header">
<h2 id="fields-heading" class="name">
${this.hass.localize(
"ui.panel.config.script.editor.field.fields"
)}
</h2>
<a
href=${documentationUrl(
this.hass,
"/integrations/script/#fields"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.script.editor.field.link_help_fields"
)}
></ha-icon-button>
</a>
</div>
<ha-script-fields
role="region"
aria-labelledby="fields-heading"
.fields=${this.config.fields}
@value-changed=${this._fieldsChanged}
.hass=${this.hass}
.disabled=${this.disabled}
></ha-script-fields>`
: nothing}
<div class="header">
<h2 id="sequence-heading" class="name">
${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", {

View File

@ -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": {