From 6092af8de6f577d42b52aefb4cadaebd1fab1143 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 22 Feb 2021 19:53:52 +0100 Subject: [PATCH] Re-do developer tools service (#8410) --- package.json | 2 +- src/components/device/ha-device-picker.ts | 7 +- src/components/ha-area-picker.ts | 4 + src/components/ha-combo-box.ts | 8 +- .../ha-selector/ha-selector-action.ts | 7 + .../ha-selector/ha-selector-area.ts | 3 + .../ha-selector/ha-selector-boolean.ts | 3 + .../ha-selector/ha-selector-device.ts | 11 +- .../ha-selector/ha-selector-entity.ts | 9 +- .../ha-selector/ha-selector-number.ts | 7 + .../ha-selector/ha-selector-object.ts | 6 + .../ha-selector/ha-selector-select.ts | 9 +- .../ha-selector/ha-selector-target.ts | 3 + .../ha-selector/ha-selector-text.ts | 12 +- .../ha-selector/ha-selector-time.ts | 3 + src/components/ha-selector/ha-selector.ts | 6 + src/components/ha-service-control.ts | 159 +++++++- src/components/ha-service-picker.ts | 84 ++-- src/components/ha-settings-row.ts | 28 +- src/components/ha-target-picker.ts | 10 +- src/components/ha-yaml-editor.ts | 14 +- .../types/ha-automation-action-service.ts | 12 + .../service/developer-tools-service.js | 371 ------------------ .../service/developer-tools-service.ts | 350 +++++++++++++++++ .../lovelace/components/hui-action-editor.ts | 3 + .../config-elements/config-elements-style.ts | 6 +- src/translations/en.json | 22 +- yarn.lock | 8 +- 28 files changed, 693 insertions(+), 474 deletions(-) delete mode 100644 src/panels/developer-tools/service/developer-tools-service.js create mode 100644 src/panels/developer-tools/service/developer-tools-service.ts diff --git a/package.json b/package.json index 41c0c61752..0956efd2c2 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "fuse.js": "^6.0.0", "google-timezones-json": "^1.0.2", "hls.js": "^0.13.2", - "home-assistant-js-websocket": "^5.8.1", + "home-assistant-js-websocket": "^5.9.0", "idb-keyval": "^3.2.0", "intl-messageformat": "^8.3.9", "js-yaml": "^3.13.1", diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index 9ef2319aa0..201efb3164 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -6,6 +6,7 @@ import { CSSResult, customElement, html, + internalProperty, LitElement, property, PropertyValues, @@ -107,8 +108,9 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { @property() public deviceFilter?: HaDevicePickerDeviceFilterFunc; - @property({ type: Boolean }) - private _opened?: boolean; + @property({ type: Boolean }) public disabled?: boolean; + + @internalProperty() private _opened?: boolean; @query("ha-combo-box", true) private _comboBox!: HaComboBox; @@ -290,6 +292,7 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { : this.label} .value=${this._value} .renderer=${rowRenderer} + .disabled=${this.disabled} item-value-path="id" item-id-path="id" item-label-path="name" diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index c6b263fd54..793a426e8f 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -117,6 +117,8 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { @property() public entityFilter?: (entity: EntityRegistryEntry) => boolean; + @property({ type: Boolean }) public disabled?: boolean; + @internalProperty() private _areas?: AreaRegistryEntry[]; @internalProperty() private _devices?: DeviceRegistryEntry[]; @@ -339,6 +341,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { item-label-path="name" .value=${this._value} .renderer=${rowRenderer} + .disabled=${this.disabled} @opened-changed=${this._openedChanged} @value-changed=${this._areaChanged} > @@ -349,6 +352,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { .placeholder=${this.placeholder ? this._area(this.placeholder)?.name : undefined} + .disabled=${this.disabled} class="input" autocapitalize="none" autocomplete="off" diff --git a/src/components/ha-combo-box.ts b/src/components/ha-combo-box.ts index ca40f37be9..6f6acd3346 100644 --- a/src/components/ha-combo-box.ts +++ b/src/components/ha-combo-box.ts @@ -10,6 +10,7 @@ import { CSSResult, customElement, html, + internalProperty, LitElement, property, query, @@ -67,8 +68,9 @@ export class HaComboBox extends LitElement { model: { item: any } ) => void; - @property({ type: Boolean }) - private _opened?: boolean; + @property({ type: Boolean }) public disabled?: boolean; + + @internalProperty() private _opened?: boolean; @query("vaadin-combo-box-light", true) private _comboBox!: HTMLElement; @@ -95,12 +97,14 @@ export class HaComboBox extends LitElement { .filteredItems=${this.filteredItems} .renderer=${this.renderer || defaultRowRenderer} .allowCustomValue=${this.allowCustomValue} + .disabled=${this.disabled} @opened-changed=${this._openedChanged} @filter-changed=${this._filterChanged} @value-changed=${this._valueChanged} > `; @@ -34,6 +37,10 @@ export class HaActionSelector extends LitElement { display: block; margin-bottom: 16px; } + :host([disabled]) ha-automation-action { + opacity: var(--light-disabled-opacity); + pointer-events: none; + } `; } } diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index 8023dc4844..c3443291d6 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -24,6 +24,8 @@ export class HaAreaSelector extends LitElement { @internalProperty() public _configEntries?: ConfigEntry[]; + @property({ type: Boolean }) public disabled = false; + protected updated(changedProperties) { if (changedProperties.has("selector")) { const oldSelector = changedProperties.get("selector"); @@ -50,6 +52,7 @@ export class HaAreaSelector extends LitElement { .includeDomains=${this.selector.area.entity?.domain ? [this.selector.area.entity.domain] : undefined} + .disabled=${this.disabled} >`; } diff --git a/src/components/ha-selector/ha-selector-boolean.ts b/src/components/ha-selector/ha-selector-boolean.ts index 8339763f81..0f56180fe5 100644 --- a/src/components/ha-selector/ha-selector-boolean.ts +++ b/src/components/ha-selector/ha-selector-boolean.ts @@ -19,11 +19,14 @@ export class HaBooleanSelector extends LitElement { @property() public label?: string; + @property({ type: Boolean }) public disabled = false; + protected render() { return html` `; } diff --git a/src/components/ha-selector/ha-selector-device.ts b/src/components/ha-selector/ha-selector-device.ts index d9ec80655c..9446c16a51 100644 --- a/src/components/ha-selector/ha-selector-device.ts +++ b/src/components/ha-selector/ha-selector-device.ts @@ -23,10 +23,12 @@ export class HaDeviceSelector extends LitElement { @internalProperty() public _configEntries?: ConfigEntry[]; + @property({ type: Boolean }) public disabled = false; + protected updated(changedProperties) { if (changedProperties.has("selector")) { const oldSelector = changedProperties.get("selector"); - if (oldSelector !== this.selector && this.selector.device.integration) { + if (oldSelector !== this.selector && this.selector.device?.integration) { this._loadConfigEntries(); } } @@ -44,24 +46,25 @@ export class HaDeviceSelector extends LitElement { .includeDomains=${this.selector.device.entity?.domain ? [this.selector.device.entity.domain] : undefined} + .disabled=${this.disabled} allow-custom-entity >`; } private _filterDevices(device: DeviceRegistryEntry): boolean { if ( - this.selector.device.manufacturer && + this.selector.device?.manufacturer && device.manufacturer !== this.selector.device.manufacturer ) { return false; } if ( - this.selector.device.model && + this.selector.device?.model && device.model !== this.selector.device.model ) { return false; } - if (this.selector.device.integration) { + if (this.selector.device?.integration) { if ( this._configEntries && !this._configEntries.some((entry) => diff --git a/src/components/ha-selector/ha-selector-entity.ts b/src/components/ha-selector/ha-selector-entity.ts index 78c7003e1f..21977aa46c 100644 --- a/src/components/ha-selector/ha-selector-entity.ts +++ b/src/components/ha-selector/ha-selector-entity.ts @@ -25,12 +25,15 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { @property() public label?: string; + @property({ type: Boolean }) public disabled = false; + protected render() { return html` this._filterEntities(entity)} + .disabled=${this.disabled} allow-custom-entity >`; } @@ -51,12 +54,12 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { } private _filterEntities(entity: HassEntity): boolean { - if (this.selector.entity.domain) { + if (this.selector.entity?.domain) { if (computeStateDomain(entity) !== this.selector.entity.domain) { return false; } } - if (this.selector.entity.device_class) { + if (this.selector.entity?.device_class) { if ( !entity.attributes.device_class || entity.attributes.device_class !== this.selector.entity.device_class @@ -64,7 +67,7 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { return false; } } - if (this.selector.entity.integration) { + if (this.selector.entity?.integration) { if ( !this._entityPlaformLookup || this._entityPlaformLookup[entity.entity_id] !== diff --git a/src/components/ha-selector/ha-selector-number.ts b/src/components/ha-selector/ha-selector-number.ts index 5daacda2d9..6360ed1568 100644 --- a/src/components/ha-selector/ha-selector-number.ts +++ b/src/components/ha-selector/ha-selector-number.ts @@ -21,8 +21,12 @@ export class HaNumberSelector extends LitElement { @property() public value?: number; + @property() public placeholder?: number; + @property() public label?: string; + @property({ type: Boolean }) public disabled = false; + protected render() { return html`${this.label} ${this.selector.number.mode === "slider" @@ -31,6 +35,7 @@ export class HaNumberSelector extends LitElement { .max=${this.selector.number.max} .value=${this._value} .step=${this.selector.number.step} + .disabled=${this.disabled} pin ignore-bar-touch @change=${this._handleSliderChange} @@ -42,12 +47,14 @@ export class HaNumberSelector extends LitElement { .label=${this.selector.number.mode === "slider" ? undefined : this.label} + .placeholder=${this.placeholder} .noLabelFloat=${this.selector.number.mode === "slider"} class=${classMap({ single: this.selector.number.mode === "box" })} .min=${this.selector.number.min} .max=${this.selector.number.max} .value=${this.value} .step=${this.selector.number.step} + .disabled=${this.disabled} type="number" auto-validate @value-changed=${this._handleInputChange} diff --git a/src/components/ha-selector/ha-selector-object.ts b/src/components/ha-selector/ha-selector-object.ts index 29159e3e8f..208bbaa6d4 100644 --- a/src/components/ha-selector/ha-selector-object.ts +++ b/src/components/ha-selector/ha-selector-object.ts @@ -11,8 +11,14 @@ export class HaObjectSelector extends LitElement { @property() public label?: string; + @property() public placeholder?: string; + + @property({ type: Boolean }) public disabled = false; + protected render() { return html``; diff --git a/src/components/ha-selector/ha-selector-select.ts b/src/components/ha-selector/ha-selector-select.ts index 0c89ec8bfd..448138234d 100644 --- a/src/components/ha-selector/ha-selector-select.ts +++ b/src/components/ha-selector/ha-selector-select.ts @@ -21,8 +21,13 @@ export class HaSelectSelector extends LitElement { @property() public label?: string; + @property({ type: Boolean }) public disabled = false; + protected render() { - return html` + return html` { @@ -84,6 +86,7 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { .includeDomains=${this.selector.target.entity?.domain ? [this.selector.target.entity.domain] : undefined} + .disabled=${this.disabled} >`; } diff --git a/src/components/ha-selector/ha-selector-text.ts b/src/components/ha-selector/ha-selector-text.ts index 32fa638ff0..9d2fbbd248 100644 --- a/src/components/ha-selector/ha-selector-text.ts +++ b/src/components/ha-selector/ha-selector-text.ts @@ -13,14 +13,20 @@ export class HaTextSelector extends LitElement { @property() public label?: string; + @property() public placeholder?: string; + @property() public selector!: StringSelector; + @property({ type: Boolean }) public disabled = false; + protected render() { if (this.selector.text?.multiline) { return html``; diff --git a/src/components/ha-selector/ha-selector-time.ts b/src/components/ha-selector/ha-selector-time.ts index 688b23dad3..f573773868 100644 --- a/src/components/ha-selector/ha-selector-time.ts +++ b/src/components/ha-selector/ha-selector-time.ts @@ -17,6 +17,8 @@ export class HaTimeSelector extends LitElement { @property() public label?: string; + @property({ type: Boolean }) public disabled = false; + protected render() { const parts = this.value?.split(":") || []; const hours = useAMPM ? parts[0] ?? "12" : parts[0] ?? "0"; @@ -29,6 +31,7 @@ export class HaTimeSelector extends LitElement { .sec=${parts[2] ?? "00"} .format=${useAMPM ? 12 : 24} .amPm=${useAMPM && (Number(hours) > 12 ? "PM" : "AM")} + .disabled=${this.disabled} @change=${this._timeChanged} @am-pm-changed=${this._timeChanged} hide-label diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 410d46f883..db071febdc 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -24,6 +24,10 @@ export class HaSelector extends LitElement { @property() public label?: string; + @property() public placeholder?: any; + + @property({ type: Boolean }) public disabled = false; + public focus() { const input = this.shadowRoot!.getElementById("selector"); if (!input) { @@ -43,6 +47,8 @@ export class HaSelector extends LitElement { selector: this.selector, value: this.value, label: this.label, + placeholder: this.placeholder, + disabled: this.disabled, id: "selector", })} `; diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 7a874164b8..3997bfcf52 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -22,6 +22,7 @@ import "./ha-selector/ha-selector"; import "./ha-service-picker"; import "./ha-settings-row"; import "./ha-yaml-editor"; +import "./ha-checkbox"; import type { HaYamlEditor } from "./ha-yaml-editor"; interface ExtHassService extends Omit { @@ -30,6 +31,7 @@ interface ExtHassService extends Omit { name?: string; description: string; required?: boolean; + advanced?: boolean; default?: any; example?: any; selector?: Selector; @@ -48,14 +50,26 @@ export class HaServiceControl extends LitElement { @property({ reflect: true, type: Boolean }) public narrow!: boolean; + @property({ type: Boolean }) public showAdvanced?: boolean; + @internalProperty() private _serviceData?: ExtHassService; + @internalProperty() private _checkedKeys = new Set(); + @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; protected updated(changedProperties: PropertyValues) { if (!changedProperties.has("value")) { return; } + const oldValue = changedProperties.get("value") as + | undefined + | this["value"]; + + if (oldValue?.service !== this.value?.service) { + this._checkedKeys = new Set(); + } + this._serviceData = this.value?.service ? this._getServiceInfo(this.value.service) : undefined; @@ -63,13 +77,33 @@ export class HaServiceControl extends LitElement { if ( this._serviceData && "target" in this._serviceData && - this.value?.data?.entity_id + (this.value?.data?.entity_id || + this.value?.data?.area_id || + this.value?.data?.device_id) ) { + const target = { + ...this.value.target, + }; + + if (this.value.data.entity_id && !this.value.target?.entity_id) { + target.entity_id = this.value.data.entity_id; + } + if (this.value.data.area_id && !this.value.target?.area_id) { + target.area_id = this.value.data.area_id; + } + if (this.value.data.device_id && !this.value.target?.device_id) { + target.device_id = this.value.data.device_id; + } + this.value = { ...this.value, - target: { ...this.value.target, entity_id: this.value.data.entity_id }, + target, + data: { ...this.value.data }, }; + delete this.value.data!.entity_id; + delete this.value.data!.device_id; + delete this.value.data!.area_id; } if (this.value?.data) { @@ -125,24 +159,46 @@ export class HaServiceControl extends LitElement { legacy && this._serviceData?.fields.find((field) => field.key === "entity_id"); + const hasOptional = Boolean( + !legacy && + this._serviceData?.fields.some( + (field) => field.selector && !field.required + ) + ); + return html` +

${this._serviceData?.description}

${this._serviceData && "target" in this._serviceData - ? html`` + ? html` + ${hasOptional + ? html`
` + : ""} + ${this.hass.localize( + "ui.components.service-control.target" + )} + ${this.hass.localize( + "ui.components.service-control.target_description" + )}
` : entityId ? html`` : this._serviceData?.fields.map((dataField) => - dataField.selector + dataField.selector && (!dataField.advanced || this.showAdvanced) ? html` + ${dataField.required + ? hasOptional + ? html`
` + : "" + : html``} ${dataField.name || dataField.key} ${dataField?.description}
` : "" )} `; } + private _checkboxChanged(ev) { + const checked = ev.currentTarget.checked; + const key = ev.currentTarget.key; + if (checked) { + this._checkedKeys.add(key); + } else { + this._checkedKeys.delete(key); + const data = { ...this.value?.data }; + + delete data[key]; + + fireEvent(this, "value-changed", { + value: { + ...this.value, + data, + }, + }); + } + this.requestUpdate("_checkedKeys"); + } + private _serviceChanged(ev: PolymerChangedEvent) { ev.stopPropagation(); if (ev.detail.value === this.value?.service) { return; } fireEvent(this, "value-changed", { - value: { service: ev.detail.value || "", data: {} }, + value: { service: ev.detail.value || "" }, }); } @@ -268,10 +362,27 @@ export class HaServiceControl extends LitElement { static get styles(): CSSResult { return css` ha-settings-row { - padding: 0; + padding: var(--service-control-padding, 0 16px); } ha-settings-row { --paper-time-input-justify-content: flex-end; + border-top: var( + --service-control-items-border-top, + 1px solid var(--divider-color) + ); + } + ha-service-picker, + ha-entity-picker, + ha-yaml-editor { + display: block; + margin: var(--service-control-padding, 0 16px); + } + ha-yaml-editor { + padding: 16px 0; + } + p { + margin: var(--service-control-padding, 0 16px); + padding: 16px 0; } :host(:not([narrow])) ha-settings-row paper-input { width: 60%; @@ -279,6 +390,12 @@ export class HaServiceControl extends LitElement { :host(:not([narrow])) ha-settings-row ha-selector { width: 60%; } + .checkbox-spacer { + width: 32px; + } + ha-checkbox { + margin-left: -16px; + } `; } } diff --git a/src/components/ha-service-picker.ts b/src/components/ha-service-picker.ts index 03379bb218..17bff6027f 100644 --- a/src/components/ha-service-picker.ts +++ b/src/components/ha-service-picker.ts @@ -1,13 +1,15 @@ import { html, internalProperty, LitElement, property } from "lit-element"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; +import { LocalizeFunc } from "../common/translations/localize"; +import { domainToName } from "../data/integration"; import { HomeAssistant } from "../types"; import "./ha-combo-box"; const rowRenderer = ( root: HTMLElement, _owner, - model: { item: { service: string; description: string } } + model: { item: { service: string; name: string } } ) => { if (!root.firstElementChild) { root.innerHTML = ` @@ -19,15 +21,16 @@ const rowRenderer = ( -
[[item.description]]
+
[[item.name]]
[[item.service]]
`; } - root.querySelector(".name")!.textContent = model.item.description; - root.querySelector("[secondary]")!.textContent = model.item.service; + root.querySelector(".name")!.textContent = model.item.name; + root.querySelector("[secondary]")!.textContent = + model.item.name === model.item.service ? "" : model.item.service; }; class HaServicePicker extends LitElement { @@ -43,13 +46,14 @@ class HaServicePicker extends LitElement { .hass=${this.hass} .label=${this.hass.localize("ui.components.service-picker.service")} .filteredItems=${this._filteredServices( + this.hass.localize, this.hass.services, this._filter )} .value=${this.value} .renderer=${rowRenderer} item-value-path="service" - item-label-path="description" + item-label-path="name" allow-custom-value @filter-changed=${this._filterChanged} @value-changed=${this._valueChanged} @@ -57,38 +61,48 @@ class HaServicePicker extends LitElement { `; } - private _services = memoizeOne((services: HomeAssistant["services"]): { - service: string; - description: string; - }[] => { - if (!services) { - return []; - } - const result: { service: string; description: string }[] = []; - - Object.keys(services) - .sort() - .forEach((domain) => { - const services_keys = Object.keys(services[domain]).sort(); - - for (const service of services_keys) { - result.push({ - service: `${domain}.${service}`, - description: - services[domain][service].description || `${domain}.${service}`, - }); - } - }); - - return result; - }); - - private _filteredServices = memoizeOne( - (services: HomeAssistant["services"], filter?: string) => { + private _services = memoizeOne( + ( + localize: LocalizeFunc, + services: HomeAssistant["services"] + ): { + service: string; + name: string; + }[] => { if (!services) { return []; } - const processedServices = this._services(services); + const result: { service: string; name: string }[] = []; + + Object.keys(services) + .sort() + .forEach((domain) => { + const services_keys = Object.keys(services[domain]).sort(); + + for (const service of services_keys) { + result.push({ + service: `${domain}.${service}`, + name: `${domainToName(localize, domain)}: ${ + services[domain][service].name || service + }`, + }); + } + }); + + return result; + } + ); + + private _filteredServices = memoizeOne( + ( + localize: LocalizeFunc, + services: HomeAssistant["services"], + filter?: string + ) => { + if (!services) { + return []; + } + const processedServices = this._services(localize, services); if (!filter) { return processedServices; @@ -96,7 +110,7 @@ class HaServicePicker extends LitElement { return processedServices.filter( (service) => service.service.toLowerCase().includes(filter) || - service.description.toLowerCase().includes(filter) + service.name?.toLowerCase().includes(filter) ); } ); diff --git a/src/components/ha-settings-row.ts b/src/components/ha-settings-row.ts index 8500813910..12f37bfe79 100644 --- a/src/components/ha-settings-row.ts +++ b/src/components/ha-settings-row.ts @@ -6,7 +6,7 @@ import { html, LitElement, property, - SVGTemplateResult, + TemplateResult, } from "lit-element"; @customElement("ha-settings-row") @@ -16,15 +16,18 @@ export class HaSettingsRow extends LitElement { @property({ type: Boolean, attribute: "three-line" }) public threeLine = false; - protected render(): SVGTemplateResult { + protected render(): TemplateResult { return html` - - -
-
+
+ + + +
+
+
`; } @@ -59,6 +62,13 @@ export class HaSettingsRow extends LitElement { div[secondary] { white-space: normal; } + .prefix-wrap { + display: contents; + } + :host([narrow]) .prefix-wrap { + display: flex; + align-items: center; + } `; } } diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 7c3aec553d..574c1b36f2 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -84,6 +84,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { @property() public entityFilter?: HaEntityPickerEntityFilterFunc; + @property({ type: Boolean, reflect: true }) public disabled = false; + @internalProperty() private _areas?: { [areaId: string]: AreaRegistryEntry }; @internalProperty() private _devices?: { @@ -438,7 +440,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { type: string, id: string ): this["value"] { - const newVal = ensureArray(value![type])!.filter((val) => val !== id); + const newVal = ensureArray(value![type])!.filter( + (val) => String(val) !== id + ); if (newVal.length) { return { ...value, @@ -599,6 +603,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { paper-tooltip.expand { min-width: 200px; } + :host([disabled]) .mdc-chip { + opacity: var(--light-disabled-opacity); + pointer-events: none; + } `; } } diff --git a/src/components/ha-yaml-editor.ts b/src/components/ha-yaml-editor.ts index 473a7dd41a..2c300f4433 100644 --- a/src/components/ha-yaml-editor.ts +++ b/src/components/ha-yaml-editor.ts @@ -44,14 +44,14 @@ export class HaYamlEditor extends LitElement { @internalProperty() private _yaml = ""; - @query("ha-code-editor", true) private _editor?: HaCodeEditor; + @query("ha-code-editor") private _editor?: HaCodeEditor; public setValue(value): void { try { this._yaml = value && !isEmpty(value) ? safeDump(value) : ""; } catch (err) { // eslint-disable-next-line no-console - console.error(err); + console.error(err, value); alert(`There was an error converting to YAML: ${err}`); } afterNextRender(() => { @@ -73,7 +73,7 @@ export class HaYamlEditor extends LitElement { return html``; } return html` - ${this.label ? html`

${this.label}

` : ""} + ${this.label ? html`

${this.label}

` : ""} `; @@ -72,6 +75,15 @@ export class HaServiceAction extends LitElement implements ActionElement { ev.stopPropagation(); } } + + static get styles(): CSSResult { + return css` + ha-service-control { + display: block; + margin: 0 -16px; + } + `; + } } declare global { diff --git a/src/panels/developer-tools/service/developer-tools-service.js b/src/panels/developer-tools/service/developer-tools-service.js deleted file mode 100644 index 73eee72788..0000000000 --- a/src/panels/developer-tools/service/developer-tools-service.js +++ /dev/null @@ -1,371 +0,0 @@ -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { safeDump, safeLoad } from "js-yaml"; -import { computeRTL } from "../../../common/util/compute_rtl"; -import "../../../components/buttons/ha-progress-button"; -import "../../../components/entity/ha-entity-picker"; -import "../../../components/ha-card"; -import "../../../components/ha-code-editor"; -import "../../../components/ha-service-picker"; -import { ENTITY_COMPONENT_DOMAINS } from "../../../data/entity"; -import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; -import LocalizeMixin from "../../../mixins/localize-mixin"; -import "../../../styles/polymer-ha-style"; -import "../../../util/app-localstorage-document"; - -const ERROR_SENTINEL = {}; -/* - * @appliesMixin LocalizeMixin - */ -class HaPanelDevService extends LocalizeMixin(PolymerElement) { - static get template() { - return html` - - - - - - - -
-

- [[localize('ui.panel.developer-tools.tabs.services.description')]] -

- -
- - -

[[localize('ui.panel.developer-tools.tabs.services.data')]]

- - - [[localize('ui.panel.developer-tools.tabs.services.call_service')]] - -
- - -
- - - -
-
- - -
-
-
- `; - } - - static get properties() { - return { - hass: { - type: Object, - }, - - domainService: { - type: String, - observer: "_domainServiceChanged", - }, - - _domain: { - type: String, - computed: "_computeDomain(domainService)", - }, - - _service: { - type: String, - computed: "_computeService(domainService)", - }, - - serviceData: { - type: String, - value: "", - }, - - parsedJSON: { - type: Object, - computed: "_computeParsedServiceData(serviceData)", - }, - - validJSON: { - type: Boolean, - computed: "_computeValidJSON(parsedJSON)", - }, - - _attributes: { - type: Array, - computed: "_computeAttributesArray(hass, _domain, _service)", - }, - - _description: { - type: String, - computed: "_computeDescription(hass, _domain, _service)", - }, - - rtl: { - reflectToAttribute: true, - computed: "_computeRTL(hass)", - }, - }; - } - - _domainServiceChanged() { - this.serviceData = ""; - } - - _computeAttributesArray(hass, domain, service) { - const serviceDomains = hass.services; - if (!(domain in serviceDomains)) return []; - if (!(service in serviceDomains[domain])) return []; - - const fields = serviceDomains[domain][service].fields; - return Object.keys(fields).map(function (field) { - return { key: field, ...fields[field] }; - }); - } - - _computeDescription(hass, domain, service) { - const serviceDomains = hass.services; - if (!(domain in serviceDomains)) return undefined; - if (!(service in serviceDomains[domain])) return undefined; - return serviceDomains[domain][service].description; - } - - _computeServiceDataKey(domainService) { - return `panel-dev-service-state-servicedata.${domainService}`; - } - - _computeDomain(domainService) { - return domainService.split(".", 1)[0]; - } - - _computeService(domainService) { - return domainService.split(".", 2)[1] || null; - } - - _computeParsedServiceData(serviceData) { - try { - return serviceData.trim() ? safeLoad(serviceData) : {}; - } catch (err) { - return ERROR_SENTINEL; - } - } - - _computeValidJSON(parsedJSON) { - return parsedJSON !== ERROR_SENTINEL; - } - - _computeHasEntity(attributes) { - return attributes.some((attr) => attr.key === "entity_id"); - } - - _computeEntityValue(parsedJSON) { - return parsedJSON === ERROR_SENTINEL ? "" : parsedJSON.entity_id; - } - - _computeEntityDomainFilter(domain) { - return ENTITY_COMPONENT_DOMAINS.includes(domain) ? [domain] : null; - } - - _callService(ev) { - const button = ev.target; - if (this.parsedJSON === ERROR_SENTINEL) { - showAlertDialog(this, { - text: this.hass.localize( - "ui.panel.developer-tools.tabs.services.alert_parsing_yaml", - "data", - this.serviceData - ), - }); - button.actionError(); - return; - } - this.hass - .callService(this._domain, this._service, this.parsedJSON) - .then(() => { - button.actionSuccess(); - }) - .catch(() => { - button.actionError(); - }); - } - - _fillExampleData() { - const example = {}; - this._attributes.forEach((attribute) => { - if (attribute.example) { - let value = ""; - try { - value = safeLoad(attribute.example); - } catch (err) { - value = attribute.example; - } - example[attribute.key] = value; - } - }); - this.serviceData = safeDump(example); - } - - _entityPicked(ev) { - this.serviceData = safeDump({ - ...this.parsedJSON, - entity_id: ev.target.value, - }); - } - - _yamlChanged(ev) { - this.serviceData = ev.detail.value; - } - - _computeRTL(hass) { - return computeRTL(hass); - } -} - -customElements.define("developer-tools-service", HaPanelDevService); diff --git a/src/panels/developer-tools/service/developer-tools-service.ts b/src/panels/developer-tools/service/developer-tools-service.ts new file mode 100644 index 0000000000..22abadf25f --- /dev/null +++ b/src/panels/developer-tools/service/developer-tools-service.ts @@ -0,0 +1,350 @@ +import { safeLoad } from "js-yaml"; +import { + css, + CSSResultArray, + html, + LitElement, + property, + query, +} from "lit-element"; +import memoizeOne from "memoize-one"; +import { LocalStorage } from "../../../common/decorators/local-storage"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { computeObjectId } from "../../../common/entity/compute_object_id"; +import "../../../components/buttons/ha-progress-button"; +import "../../../components/entity/ha-entity-picker"; +import "../../../components/ha-card"; +import "../../../components/ha-expansion-panel"; +import "../../../components/ha-service-control"; +import "../../../components/ha-service-picker"; +import "../../../components/ha-yaml-editor"; +import type { HaYamlEditor } from "../../../components/ha-yaml-editor"; +import { ServiceAction } from "../../../data/script"; +import { haStyle } from "../../../resources/styles"; +import "../../../styles/polymer-ha-style"; +import { HomeAssistant } from "../../../types"; +import "../../../util/app-localstorage-document"; + +class HaPanelDevService extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public narrow!: boolean; + + @LocalStorage("panel-dev-service-state-service-data", true) + private _serviceData?: ServiceAction = { service: "", target: {}, data: {} }; + + @LocalStorage("panel-dev-service-state-yaml-mode", true) + private _yamlMode = false; + + @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; + + protected firstUpdated(params) { + super.firstUpdated(params); + if (!this._serviceData?.service) { + const domain = Object.keys(this.hass.services).sort()[0]; + const service = Object.keys(this.hass.services[domain]).sort()[0]; + this._serviceData = { + service: `${domain}.${service}`, + target: {}, + data: {}, + }; + } + } + + protected render() { + const { target, fields } = this._fields( + this.hass.services, + this._serviceData?.service + ); + + const isValid = this._isValid(this._serviceData, fields, target); + + return html` +
+

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.description" + )} +

+ + ${this._yamlMode + ? html`` + : html`
+
`} +
+
+
+ + ${this._yamlMode + ? this.hass.localize( + "ui.panel.developer-tools.tabs.services.ui_mode" + ) + : this.hass.localize( + "ui.panel.developer-tools.tabs.services.yaml_mode" + )} + + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.call_service" + )} + +
+
+ + ${(this._yamlMode ? fields : this._filterSelectorFields(fields)).length + ? html`
+ + ${this._yamlMode && target + ? html`

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.accepts_target" + )} +

` + : ""} + + + + + + + ${fields.map( + (field) => html` + + + + ` + )} +
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.column_parameter" + )} + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.column_description" + )} + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.column_example" + )} +
${field.key}
${field.description}${field.example}
+ ${this._yamlMode + ? html`${this.hass.localize( + "ui.panel.developer-tools.tabs.services.fill_example_data" + )}` + : ""} +
+
` + : ""} + `; + } + + private _filterSelectorFields = memoizeOne((fields) => + fields.filter((field) => !field.selector) + ); + + private _isValid = memoizeOne((serviceData, fields, target): boolean => { + if (!serviceData?.service) { + return false; + } + const domain = computeDomain(serviceData.service); + const service = computeObjectId(serviceData.service); + if (!domain || !service) { + return false; + } + if ( + target && + !serviceData.target && + !serviceData.data?.entity_id && + !serviceData.data?.device_id && + !serviceData.data?.area_id + ) { + return false; + } + for (const field of fields) { + if ( + field.required && + (!serviceData.data || serviceData.data[field.key] === undefined) + ) { + return false; + } + } + return true; + }); + + private _fields = memoizeOne( + ( + serviceDomains: HomeAssistant["services"], + domainService: string | undefined + ): { target: boolean; fields: any[] } => { + if (!domainService) { + return { target: false, fields: [] }; + } + const domain = computeDomain(domainService); + const service = computeObjectId(domainService); + if (!(domain in serviceDomains)) { + return { target: false, fields: [] }; + } + if (!(service in serviceDomains[domain])) { + return { target: false, fields: [] }; + } + const target = "target" in serviceDomains[domain][service]; + const fields = serviceDomains[domain][service].fields; + const result = Object.keys(fields).map((field) => { + return { key: field, ...fields[field] }; + }); + + return { + target, + fields: result, + }; + } + ); + + private _callService() { + const domain = computeDomain(this._serviceData!.service); + const service = computeObjectId(this._serviceData!.service); + if (!domain || !service) { + return; + } + this.hass.callService( + domain, + service, + this._serviceData!.data, + this._serviceData!.target + ); + } + + private _toggleYaml() { + this._yamlMode = !this._yamlMode; + } + + private _yamlChanged(ev) { + if (!ev.detail.isValid) { + return; + } + this._serviceChanged(ev); + } + + private _serviceChanged(ev) { + this._serviceData = ev.detail.value; + } + + private _fillExampleData() { + const { fields } = this._fields( + this.hass.services, + this._serviceData?.service + ); + const example = {}; + fields.forEach((field) => { + if (field.example) { + let value = ""; + try { + value = safeLoad(field.example); + } catch (err) { + value = field.example; + } + example[field.key] = value; + } + }); + this._serviceData = { ...this._serviceData!, data: example }; + this._yamlEditor?.setValue(this._serviceData); + } + + static get styles(): CSSResultArray { + return [ + haStyle, + css` + .content { + padding: 16px; + max-width: 1200px; + margin: auto; + } + .button-row { + padding: 8px 16px; + border-top: 1px solid var(--divider-color); + border-bottom: 1px solid var(--divider-color); + background: var(--card-background-color); + position: sticky; + bottom: 0; + box-sizing: border-box; + width: 100%; + } + + .button-row .buttons { + display: flex; + justify-content: space-between; + max-width: 1200px; + margin: auto; + } + + .attributes { + width: 100%; + } + + .attributes th { + text-align: left; + background-color: var(--card-background-color); + border-bottom: 1px solid var(--primary-text-color); + } + + :host([rtl]) .attributes th { + text-align: right; + } + + .attributes tr { + vertical-align: top; + direction: ltr; + } + + .attributes tr:nth-child(odd) { + background-color: var(--table-row-background-color, #eee); + } + + .attributes tr:nth-child(even) { + background-color: var(--table-row-alternative-background-color, #eee); + } + + .attributes td:nth-child(3) { + white-space: pre-wrap; + word-break: break-word; + } + + .attributes td { + padding: 4px; + vertical-align: middle; + } + `, + ]; + } +} + +customElements.define("developer-tools-service", HaPanelDevService); + +declare global { + interface HTMLElementTagNameMap { + "developer-tools-service": HaPanelDevService; + } +} diff --git a/src/panels/lovelace/components/hui-action-editor.ts b/src/panels/lovelace/components/hui-action-editor.ts index 831c493735..d6b57b239e 100644 --- a/src/panels/lovelace/components/hui-action-editor.ts +++ b/src/panels/lovelace/components/hui-action-editor.ts @@ -194,6 +194,9 @@ export class HuiActionEditor extends LitElement { .dropdown { display: flex; } + ha-service-control { + --service-control-padding: 0; + } `; } } diff --git a/src/panels/lovelace/editor/config-elements/config-elements-style.ts b/src/panels/lovelace/editor/config-elements/config-elements-style.ts index 0b5f7a0314..4a1c4bf12b 100644 --- a/src/panels/lovelace/editor/config-elements/config-elements-style.ts +++ b/src/panels/lovelace/editor/config-elements/config-elements-style.ts @@ -9,7 +9,11 @@ export const configElementStyle = css` } .side-by-side > * { flex: 1; - padding-right: 4px; + padding-right: 8px; + } + .side-by-side > *:last-child { + flex: 1; + padding-right: 0; } .suffix { margin: 0 8px; diff --git a/src/translations/en.json b/src/translations/en.json index ef30609c90..c8da5c3475 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -424,6 +424,12 @@ "service-picker": { "service": "Service" }, + "service-control": { + "required": "This field is required", + "target": "Target", + "target_description": "What should this service call target", + "service_data": "Service data" + }, "related-items": { "no_related_found": "No related items found.", "integration": "Integration", @@ -1401,8 +1407,7 @@ "type_select": "Action type", "type": { "service": { - "label": "Call service", - "service_data": "Service data" + "label": "Call service" }, "delay": { "label": "Delay", @@ -1425,7 +1430,7 @@ "event": { "label": "Fire event", "event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]", - "service_data": "[%key:ui::panel::config::automation::editor::actions::type::service::service_data%]" + "service_data": "[%key:ui::components::service-control::service_data%]" }, "device_id": { "label": "Device", @@ -2694,7 +2699,6 @@ "action-editor": { "navigation_path": "Navigation Path", "url_path": "URL Path", - "editor_service_data": "Service data can only be entered in the code editor", "actions": { "default_action": "Default Action", "call-service": "Call Service", @@ -3273,16 +3277,16 @@ "services": { "title": "Services", "description": "The service dev tool allows you to call any available service in Home Assistant.", - "data": "Service Data (YAML, optional)", "call_service": "Call Service", - "select_service": "Select a service to see the description", - "no_description": "No description is available", - "no_parameters": "This service takes no parameters.", "column_parameter": "Parameter", "column_description": "Description", "column_example": "Example", "fill_example_data": "Fill Example Data", - "alert_parsing_yaml": "Error parsing YAML: {data}" + "yaml_mode": "Go to YAML mode", + "ui_mode": "Go to UI mode", + "yaml_parameters": "Parameters only available in YAML mode", + "all_parameters": "All available parameters", + "accepts_target": "This service accepts a target, for example: `entity_id: light.bed_light`" }, "states": { "title": "States", diff --git a/yarn.lock b/yarn.lock index 201b93b60e..dd3bdf8903 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8174,10 +8174,10 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -home-assistant-js-websocket@^5.8.1: - version "5.8.1" - resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-5.8.1.tgz#4c5930aa47e7089f5806bb3d190ebe53697d2edc" - integrity sha512-2H3q8NK3WrT50iYODv95iz0E2E+nAUOD452V6lhBxhUTQlVFBsuxNMRTTbIZp+6Xab7ad84uF0z+hHFmBMq/Sw== +home-assistant-js-websocket@^5.9.0: + version "5.9.0" + resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-5.9.0.tgz#85f73cc7aa23362e93d7e8208026fbcf25934022" + integrity sha512-HSAhX+s2JgsE77sYKKqcNsukiO6Zm4CcCIwugq17MwHcEyLoecChsbQtgtbvg1dHctUAk+IHxuZ0JBx10B1YGQ== homedir-polyfill@^1.0.1: version "1.0.3"