Improve action picker UI and search (#25525)

This commit is contained in:
Paul Bottein 2025-05-28 15:03:17 +02:00 committed by Bram Kragten
parent 06270c771f
commit 61d9b0d2a3
7 changed files with 177 additions and 89 deletions

View File

@ -0,0 +1,4 @@
const validServiceId = /^(\w+)\.(\w+)$/;
export const isValidServiceId = (actionId: string) =>
validServiceId.test(actionId);

View File

@ -51,6 +51,9 @@ export class HaEntityPicker extends LitElement {
@property({ type: Boolean, attribute: "allow-custom-entity" }) @property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity; public allowCustomEntity;
@property({ type: Boolean, attribute: "show-entity-id" })
public showEntityId = false;
@property() public label?: string; @property() public label?: string;
@property() public value?: 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<EntityComboBoxItem> = ( private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
item, item,
{ index } { index }
) => { ) => {
const showEntityId = this.hass.userData?.showEntityIdPicker; const showEntityId = this._showEntityId;
return html` return html`
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}> <ha-combo-box-item type="button" compact .borderTop=${index !== 0}>

View File

@ -85,8 +85,11 @@ export class HaServiceControl extends LitElement {
@property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public narrow = false;
@property({ attribute: "show-advanced", type: Boolean }) public showAdvanced = @property({ attribute: "show-advanced", type: Boolean })
false; public showAdvanced = false;
@property({ attribute: "show-service-id", type: Boolean })
public showServiceId = false;
@property({ attribute: "hide-picker", type: Boolean, reflect: true }) @property({ attribute: "hide-picker", type: Boolean, reflect: true })
public hidePicker = false; public hidePicker = false;
@ -435,6 +438,7 @@ export class HaServiceControl extends LitElement {
.value=${this._value?.action} .value=${this._value?.action}
.disabled=${this.disabled} .disabled=${this.disabled}
@value-changed=${this._serviceChanged} @value-changed=${this._serviceChanged}
.showServiceId=${this.showServiceId}
></ha-service-picker>`} ></ha-service-picker>`}
${this.hideDescription ${this.hideDescription
? nothing ? nothing

View File

@ -1,15 +1,25 @@
import { mdiRoomService } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { html, LitElement } from "lit"; import { html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { isValidServiceId } from "../common/entity/valid_service_id";
import type { LocalizeFunc } from "../common/translations/localize"; 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 { 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") @customElement("ha-service-picker")
class HaServicePicker extends LitElement { class HaServicePicker extends LitElement {
@ -17,66 +27,121 @@ class HaServicePicker extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@property() public placeholder?: string;
@property() public value?: string; @property() public value?: string;
@state() private _filter?: string; @property({ attribute: "show-service-id", type: Boolean })
public showServiceId = false;
protected willUpdate() { @query("ha-generic-picker") private _picker?: HaGenericPicker;
if (!this.hasUpdated) {
public async open() {
await this.updateComplete;
await this._picker?.open();
}
protected firstUpdated(props) {
super.firstUpdated(props);
this.hass.loadBackendTranslation("services"); this.hass.loadBackendTranslation("services");
getServiceIcons(this.hass); getServiceIcons(this.hass);
} }
}
private _rowRenderer: ComboBoxLitRenderer<{ service: string; name: string }> = private _rowRenderer: ComboBoxLitRenderer<ServiceComboBoxItem> = (
(item) => html` item,
<ha-combo-box-item type="button"> { index }
) => html`
<ha-combo-box-item type="button" border-top .borderTop=${index !== 0}>
<ha-service-icon <ha-service-icon
slot="start" slot="start"
.hass=${this.hass} .hass=${this.hass}
.service=${item.service} .service=${item.id}
></ha-service-icon> ></ha-service-icon>
<span slot="headline">${item.name}</span> <span slot="headline">${item.primary}</span>
<span slot="supporting-text" <span slot="supporting-text">${item.secondary}</span>
>${item.name === item.service ? "" : item.service}</span ${item.service_id && this.showServiceId
> ? html`<span slot="supporting-text" class="code">
${item.service_id}
</span>`
: nothing}
${item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing}
</ha-combo-box-item> </ha-combo-box-item>
`; `;
protected render() { private _valueRenderer: PickerValueRenderer = (value) => {
const serviceId = value;
const [domain, service] = serviceId.split(".");
if (!this.hass.services[domain]?.[service]) {
return html` return html`
<ha-combo-box <ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
.hass=${this.hass} <span slot="headline">${value}</span>
.label=${this.hass.localize("ui.components.service-picker.action")}
.filteredItems=${this._filteredServices(
this.hass.localize,
this.hass.services,
this._filter
)}
.value=${this.value}
.disabled=${this.disabled}
.renderer=${this._rowRenderer}
item-value-path="service"
item-label-path="name"
allow-custom-value
@filter-changed=${this._filterChanged}
@value-changed=${this._valueChanged}
></ha-combo-box>
`; `;
} }
const serviceName =
this.hass.localize(`component.${domain}.services.${service}.name`) ||
this.hass.services[domain][service].name ||
service;
return html`
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${serviceId}
></ha-service-icon>
<span slot="headline">${serviceName}</span>
${this.showServiceId
? html`<span slot="supporting-text" class="code">${serviceId}</span>`
: nothing}
`;
};
protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.service-picker.action");
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
allow-custom-value
.notFoundLabel=${this.hass.localize(
"ui.components.service-picker.no_match"
)}
.label=${this.label}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _getItems = () =>
this._services(this.hass.localize, this.hass.services);
private _services = memoizeOne( private _services = memoizeOne(
( (
localize: LocalizeFunc, localize: LocalizeFunc,
services: HomeAssistant["services"] services: HomeAssistant["services"]
): { ): ServiceComboBoxItem[] => {
service: string;
name: string;
}[] => {
if (!services) { if (!services) {
return []; return [];
} }
const result: { service: string; name: string }[] = []; const items: ServiceComboBoxItem[] = [];
Object.keys(services) Object.keys(services)
.sort() .sort()
@ -84,56 +149,60 @@ class HaServicePicker extends LitElement {
const services_keys = Object.keys(services[domain]).sort(); const services_keys = Object.keys(services[domain]).sort();
for (const service of services_keys) { for (const service of services_keys) {
result.push({ const serviceId = `${domain}.${service}`;
service: `${domain}.${service}`, const domainName = domainToName(localize, domain);
name: `${domainToName(localize, domain)}: ${
const name =
this.hass.localize( this.hass.localize(
`component.${domain}.services.${service}.name` `component.${domain}.services.${service}.name`
) || ) ||
services[domain][service].name || services[domain][service].name ||
service 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( private _valueChanged(ev: ValueChangedEvent<string>) {
( ev.stopPropagation();
localize: LocalizeFunc, const value = ev.detail.value;
services: HomeAssistant["services"],
filter?: string
) => {
if (!services) {
return [];
}
const processedServices = this._services(localize, services);
if (!filter) { if (!value) {
return processedServices; this._setValue(undefined);
} return;
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)
);
});
}
);
private _filterChanged(ev: CustomEvent): void {
this._filter = ev.detail.value.toLowerCase();
} }
private _valueChanged(ev) { if (!isValidServiceId(value)) {
this.value = ev.detail.value; return;
}
this._setValue(value);
}
private _setValue(value: string | undefined) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
fireEvent(this, "value-changed", { value: this.value });
} }
} }

View File

@ -142,6 +142,7 @@ class HaPanelDevAction extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this._serviceData?.action} .value=${this._serviceData?.action}
@value-changed=${this._serviceChanged} @value-changed=${this._serviceChanged}
show-service-id
></ha-service-picker> ></ha-service-picker>
<ha-yaml-editor <ha-yaml-editor
id="yaml-editor" id="yaml-editor"
@ -156,6 +157,7 @@ class HaPanelDevAction extends LitElement {
.value=${this._serviceData} .value=${this._serviceData}
.narrow=${this.narrow} .narrow=${this.narrow}
show-advanced show-advanced
show-service-id
@value-changed=${this._serviceDataChanged} @value-changed=${this._serviceDataChanged}
class="card-content" class="card-content"
></ha-service-control> ></ha-service-control>

View File

@ -130,6 +130,7 @@ class HaPanelDevState extends LitElement {
.value=${this._entityId} .value=${this._entityId}
@value-changed=${this._entityIdChanged} @value-changed=${this._entityIdChanged}
allow-custom-entity allow-custom-entity
show-entity-id
></ha-entity-picker> ></ha-entity-picker>
${this._entityId ${this._entityId
? html` ? html`

View File

@ -873,7 +873,8 @@
} }
}, },
"service-picker": { "service-picker": {
"action": "Action" "action": "Action",
"no_match": "No matching actions found"
}, },
"service-control": { "service-control": {
"required": "This field is required", "required": "This field is required",