From 61d9b0d2a3b27d57afb6a4d93e255e05aa5e82f5 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 28 May 2025 15:03:17 +0200 Subject: [PATCH] Improve action picker UI and search (#25525) --- src/common/entity/valid_service_id.ts | 4 + src/components/entity/ha-entity-picker.ts | 9 +- src/components/ha-service-control.ts | 8 +- src/components/ha-service-picker.ts | 239 +++++++++++------- .../action/developer-tools-action.ts | 2 + .../state/developer-tools-state.ts | 1 + src/translations/en.json | 3 +- 7 files changed, 177 insertions(+), 89 deletions(-) create mode 100644 src/common/entity/valid_service_id.ts diff --git a/src/common/entity/valid_service_id.ts b/src/common/entity/valid_service_id.ts new file mode 100644 index 0000000000..97c88f6907 --- /dev/null +++ b/src/common/entity/valid_service_id.ts @@ -0,0 +1,4 @@ +const validServiceId = /^(\w+)\.(\w+)$/; + +export const isValidServiceId = (actionId: string) => + validServiceId.test(actionId); diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index ef39a4ef14..8d29711e52 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -51,6 +51,9 @@ export class HaEntityPicker extends LitElement { @property({ type: Boolean, attribute: "allow-custom-entity" }) public allowCustomEntity; + @property({ type: Boolean, attribute: "show-entity-id" }) + public showEntityId = false; + @property() public label?: string; @property() public value?: string; @@ -166,11 +169,15 @@ export class HaEntityPicker extends LitElement { `; }; + private get _showEntityId() { + return this.showEntityId || this.hass.userData?.showEntityIdPicker; + } + private _rowRenderer: ComboBoxLitRenderer = ( item, { index } ) => { - const showEntityId = this.hass.userData?.showEntityIdPicker; + const showEntityId = this._showEntityId; return html` diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 82f7ae547e..a31382ac2f 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -85,8 +85,11 @@ export class HaServiceControl extends LitElement { @property({ type: Boolean }) public narrow = false; - @property({ attribute: "show-advanced", type: Boolean }) public showAdvanced = - false; + @property({ attribute: "show-advanced", type: Boolean }) + public showAdvanced = false; + + @property({ attribute: "show-service-id", type: Boolean }) + public showServiceId = false; @property({ attribute: "hide-picker", type: Boolean, reflect: true }) public hidePicker = false; @@ -435,6 +438,7 @@ export class HaServiceControl extends LitElement { .value=${this._value?.action} .disabled=${this.disabled} @value-changed=${this._serviceChanged} + .showServiceId=${this.showServiceId} >`} ${this.hideDescription ? nothing diff --git a/src/components/ha-service-picker.ts b/src/components/ha-service-picker.ts index a72a1a463c..2d9c6c0853 100644 --- a/src/components/ha-service-picker.ts +++ b/src/components/ha-service-picker.ts @@ -1,15 +1,25 @@ +import { mdiRoomService } from "@mdi/js"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; -import { html, LitElement } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { html, LitElement, nothing, type TemplateResult } from "lit"; +import { customElement, property, query } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; +import { isValidServiceId } from "../common/entity/valid_service_id"; import type { LocalizeFunc } from "../common/translations/localize"; -import { domainToName } from "../data/integration"; -import type { HomeAssistant } from "../types"; -import "./ha-combo-box"; -import "./ha-combo-box-item"; -import "./ha-service-icon"; import { getServiceIcons } from "../data/icons"; +import { domainToName } from "../data/integration"; +import type { HomeAssistant, ValueChangedEvent } from "../types"; +import "./ha-combo-box-item"; +import "./ha-generic-picker"; +import type { HaGenericPicker } from "./ha-generic-picker"; +import type { PickerComboBoxItem } from "./ha-picker-combo-box"; +import type { PickerValueRenderer } from "./ha-picker-field"; +import "./ha-service-icon"; + +interface ServiceComboBoxItem extends PickerComboBoxItem { + domain_name?: string; + service_id?: string; +} @customElement("ha-service-picker") class HaServicePicker extends LitElement { @@ -17,66 +27,121 @@ class HaServicePicker extends LitElement { @property({ type: Boolean }) public disabled = false; + @property() public label?: string; + + @property() public placeholder?: string; + @property() public value?: string; - @state() private _filter?: string; + @property({ attribute: "show-service-id", type: Boolean }) + public showServiceId = false; - protected willUpdate() { - if (!this.hasUpdated) { - this.hass.loadBackendTranslation("services"); - getServiceIcons(this.hass); - } + @query("ha-generic-picker") private _picker?: HaGenericPicker; + + public async open() { + await this.updateComplete; + await this._picker?.open(); } - private _rowRenderer: ComboBoxLitRenderer<{ service: string; name: string }> = - (item) => html` - - - ${item.name} - ${item.name === item.service ? "" : item.service} - - `; + protected firstUpdated(props) { + super.firstUpdated(props); + this.hass.loadBackendTranslation("services"); + getServiceIcons(this.hass); + } - protected render() { - return html` - = ( + item, + { index } + ) => html` + + + ${item.primary} + ${item.secondary} + ${item.service_id && this.showServiceId + ? html` + ${item.service_id} + ` + : nothing} + ${item.domain_name + ? html` +
+ ${item.domain_name} +
+ ` + : nothing} +
+ `; + + private _valueRenderer: PickerValueRenderer = (value) => { + const serviceId = value; + const [domain, service] = serviceId.split("."); + + if (!this.hass.services[domain]?.[service]) { + return html` + + ${value} + `; + } + + const serviceName = + this.hass.localize(`component.${domain}.services.${service}.name`) || + this.hass.services[domain][service].name || + service; + + return html` + + ${serviceName} + ${this.showServiceId + ? html`${serviceId}` + : nothing} + `; + }; + + protected render(): TemplateResult { + const placeholder = + this.placeholder ?? + this.hass.localize("ui.components.service-picker.action"); + + return html` +
+ > + `; } + private _getItems = () => + this._services(this.hass.localize, this.hass.services); + private _services = memoizeOne( ( localize: LocalizeFunc, services: HomeAssistant["services"] - ): { - service: string; - name: string; - }[] => { + ): ServiceComboBoxItem[] => { if (!services) { return []; } - const result: { service: string; name: string }[] = []; + const items: ServiceComboBoxItem[] = []; Object.keys(services) .sort() @@ -84,56 +149,60 @@ class HaServicePicker extends LitElement { const services_keys = Object.keys(services[domain]).sort(); for (const service of services_keys) { - result.push({ - service: `${domain}.${service}`, - name: `${domainToName(localize, domain)}: ${ - this.hass.localize( - `component.${domain}.services.${service}.name` - ) || - services[domain][service].name || - service - }`, + const serviceId = `${domain}.${service}`; + const domainName = domainToName(localize, domain); + + const name = + this.hass.localize( + `component.${domain}.services.${service}.name` + ) || + services[domain][service].name || + service; + + const description = + this.hass.localize( + `component.${domain}.services.${service}.description` + ) || services[domain][service].description; + + items.push({ + id: serviceId, + primary: name, + secondary: description, + domain_name: domainName, + service_id: serviceId, + search_labels: [serviceId, domainName, name, description].filter( + Boolean + ), + sorting_label: serviceId, }); } }); - return result; + return items; } ); - private _filteredServices = memoizeOne( - ( - localize: LocalizeFunc, - services: HomeAssistant["services"], - filter?: string - ) => { - if (!services) { - return []; - } - const processedServices = this._services(localize, services); + private _valueChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + const value = ev.detail.value; - if (!filter) { - return processedServices; - } - const split_filter = filter.split(" "); - return processedServices.filter((service) => { - const lower_service_name = service.name.toLowerCase(); - const lower_service = service.service.toLowerCase(); - return split_filter.every( - (f) => lower_service_name.includes(f) || lower_service.includes(f) - ); - }); + if (!value) { + this._setValue(undefined); + return; } - ); - private _filterChanged(ev: CustomEvent): void { - this._filter = ev.detail.value.toLowerCase(); + if (!isValidServiceId(value)) { + return; + } + + this._setValue(value); } - private _valueChanged(ev) { - this.value = ev.detail.value; + private _setValue(value: string | undefined) { + this.value = value; + + fireEvent(this, "value-changed", { value }); fireEvent(this, "change"); - fireEvent(this, "value-changed", { value: this.value }); } } diff --git a/src/panels/developer-tools/action/developer-tools-action.ts b/src/panels/developer-tools/action/developer-tools-action.ts index 610b53be71..d964555e27 100644 --- a/src/panels/developer-tools/action/developer-tools-action.ts +++ b/src/panels/developer-tools/action/developer-tools-action.ts @@ -142,6 +142,7 @@ class HaPanelDevAction extends LitElement { .hass=${this.hass} .value=${this._serviceData?.action} @value-changed=${this._serviceChanged} + show-service-id > diff --git a/src/panels/developer-tools/state/developer-tools-state.ts b/src/panels/developer-tools/state/developer-tools-state.ts index 5b9596b3a0..30c76c952e 100644 --- a/src/panels/developer-tools/state/developer-tools-state.ts +++ b/src/panels/developer-tools/state/developer-tools-state.ts @@ -130,6 +130,7 @@ class HaPanelDevState extends LitElement { .value=${this._entityId} @value-changed=${this._entityIdChanged} allow-custom-entity + show-entity-id > ${this._entityId ? html` diff --git a/src/translations/en.json b/src/translations/en.json index c24fbd49b8..7fab97a767 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -873,7 +873,8 @@ } }, "service-picker": { - "action": "Action" + "action": "Action", + "no_match": "No matching actions found" }, "service-control": { "required": "This field is required",