Simplify entity combo-box code (#25338)

This commit is contained in:
Paul Bottein 2025-05-06 20:44:12 +02:00 committed by Bram Kragten
parent 9629159ef1
commit 90710fedf2
4 changed files with 183 additions and 197 deletions

View File

@ -5,7 +5,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
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 { computeAreaName } from "../../common/entity/compute_area_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
@ -30,28 +29,17 @@ import "../ha-icon-button";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "./state-badge"; import "./state-badge";
const FAKE_ENTITY: HassEntity = { interface EntityComboBoxItem {
entity_id: "",
state: "",
last_changed: "",
last_updated: "",
context: { id: "", user_id: null, parent_id: null },
attributes: {},
};
interface EntityComboBoxItem extends HassEntity {
// Force empty label to always display empty value by default in the search field // Force empty label to always display empty value by default in the search field
id: string;
label: ""; label: "";
primary: string; primary: string;
secondary?: string; secondary?: string;
translated_domain?: string; domain_name?: string;
show_entity_id?: boolean; search_labels?: string[];
entity_name?: string;
area_name?: string;
device_name?: string;
friendly_name?: string;
sorting_label?: string; sorting_label?: string;
icon_path?: string; icon_path?: string;
stateObj?: HassEntity;
} }
export type HaEntityComboBoxEntityFilterFunc = (entity: HassEntity) => boolean; export type HaEntityComboBoxEntityFilterFunc = (entity: HassEntity) => boolean;
@ -59,22 +47,6 @@ export type HaEntityComboBoxEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___"; const CREATE_ID = "___create-new-entity___";
const NO_ENTITIES_ID = "___no-entities___"; const NO_ENTITIES_ID = "___no-entities___";
const DOMAIN_STYLE = styleMap({
fontSize: "var(--ha-font-size-s)",
fontWeight: "var(--ha-font-weight-normal)",
lineHeight: "var(--ha-line-height-normal)",
alignSelf: "flex-end",
maxWidth: "30%",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
});
const ENTITY_ID_STYLE = styleMap({
fontFamily: "var(--ha-font-family-code)",
fontSize: "var(--ha-font-size-xs)",
});
@customElement("ha-entity-combo-box") @customElement("ha-entity-combo-box")
export class HaEntityComboBox extends LitElement { export class HaEntityComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -177,33 +149,41 @@ export class HaEntityComboBox extends LitElement {
private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = ( private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
item, item,
{ index } { index }
) => html` ) => {
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}> const showEntityId = this.hass.userData?.showEntityIdPicker;
${item.icon_path
? html`<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>` return html`
: html` <ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
<state-badge ${item.icon_path
slot="start" ? html`
.stateObj=${item} <ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>
.hass=${this.hass} `
></state-badge> : html`
`} <state-badge
<span slot="headline">${item.primary}</span> slot="start"
${item.secondary .stateObj=${item.stateObj}
? html`<span slot="supporting-text">${item.secondary}</span>` .hass=${this.hass}
: nothing} ></state-badge>
${item.entity_id && item.show_entity_id `}
? html`<span slot="supporting-text" style=${ENTITY_ID_STYLE} <span slot="headline">${item.primary}</span>
>${item.entity_id}</span ${item.secondary
>` ? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing} : nothing}
${item.translated_domain && !item.show_entity_id ${item.stateObj && showEntityId
? html`<div slot="trailing-supporting-text" style=${DOMAIN_STYLE}> ? html`
${item.translated_domain} <span slot="supporting-text" class="code">
</div>` ${item.stateObj.entity_id}
: nothing} </span>
</ha-combo-box-item> `
`; : nothing}
${item.domain_name && !showEntityId
? html`
<div slot="trailing-supporting-text">${item.domain_name}</div>
`
: nothing}
</ha-combo-box-item>
`;
};
private _getItems = memoizeOne( private _getItems = memoizeOne(
( (
@ -218,7 +198,7 @@ export class HaEntityComboBox extends LitElement {
excludeEntities: this["excludeEntities"], excludeEntities: this["excludeEntities"],
createDomains: this["createDomains"] createDomains: this["createDomains"]
): EntityComboBoxItem[] => { ): EntityComboBoxItem[] => {
let states: EntityComboBoxItem[] = []; let items: EntityComboBoxItem[] = [];
let entityIds = Object.keys(hass.states); let entityIds = Object.keys(hass.states);
@ -236,9 +216,8 @@ export class HaEntityComboBox extends LitElement {
); );
return { return {
...FAKE_ENTITY, id: CREATE_ID + domain,
label: "", label: "",
entity_id: CREATE_ID + domain,
primary: primary, primary: primary,
secondary: this.hass.localize( secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity" "ui.components.entity.entity-picker.new_entity"
@ -251,9 +230,8 @@ export class HaEntityComboBox extends LitElement {
if (!entityIds.length) { if (!entityIds.length) {
return [ return [
{ {
...FAKE_ENTITY, id: NO_ENTITIES_ID,
label: "", label: "",
entity_id: NO_ENTITIES_ID,
primary: this.hass!.localize( primary: this.hass!.localize(
"ui.components.entity.entity-picker.no_entities" "ui.components.entity.entity-picker.no_entities"
), ),
@ -289,7 +267,7 @@ export class HaEntityComboBox extends LitElement {
const isRTL = computeRTL(this.hass); const isRTL = computeRTL(this.hass);
states = entityIds items = entityIds
.map<EntityComboBoxItem>((entityId) => { .map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId]; const stateObj = hass!.states[entityId];
@ -300,28 +278,32 @@ export class HaEntityComboBox extends LitElement {
const deviceName = device ? computeDeviceName(device) : undefined; const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined; const areaName = area ? computeAreaName(area) : undefined;
const domainName = domainToName(
this.hass.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId; const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean) .filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ "); .join(isRTL ? " ◂ " : " ▸ ");
const translatedDomain = domainToName(
this.hass.localize,
computeDomain(entityId)
);
return { return {
...hass!.states[entityId], id: entityId,
label: "", label: "",
primary: primary, primary: primary,
secondary: secondary, secondary: secondary,
translated_domain: translatedDomain, domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("-"), sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
entity_name: entityName || deviceName, search_labels: [
area_name: areaName, entityName,
device_name: deviceName, deviceName,
friendly_name: friendlyName, areaName,
show_entity_id: hass.userData?.showEntityIdPicker, domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
stateObj: stateObj,
}; };
}) })
.sort((entityA, entityB) => .sort((entityA, entityB) =>
@ -333,41 +315,43 @@ export class HaEntityComboBox extends LitElement {
); );
if (includeDeviceClasses) { if (includeDeviceClasses) {
states = states.filter( items = items.filter(
(stateObj) => (item) =>
// We always want to include the entity of the current value // We always want to include the entity of the current value
stateObj.entity_id === this.value || item.id === this.value ||
(stateObj.attributes.device_class && (item.stateObj?.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)) includeDeviceClasses.includes(
item.stateObj.attributes.device_class
))
); );
} }
if (includeUnitOfMeasurement) { if (includeUnitOfMeasurement) {
states = states.filter( items = items.filter(
(stateObj) => (item) =>
// We always want to include the entity of the current value // We always want to include the entity of the current value
stateObj.entity_id === this.value || item.id === this.value ||
(stateObj.attributes.unit_of_measurement && (item.stateObj?.attributes.unit_of_measurement &&
includeUnitOfMeasurement.includes( includeUnitOfMeasurement.includes(
stateObj.attributes.unit_of_measurement item.stateObj.attributes.unit_of_measurement
)) ))
); );
} }
if (entityFilter) { if (entityFilter) {
states = states.filter( items = items.filter(
(stateObj) => (item) =>
// We always want to include the entity of the current value // We always want to include the entity of the current value
stateObj.entity_id === this.value || entityFilter!(stateObj) item.id === this.value ||
(item.stateObj && entityFilter!(item.stateObj))
); );
} }
if (!states.length) { if (!items.length) {
return [ return [
{ {
...FAKE_ENTITY, id: NO_ENTITIES_ID,
label: "", label: "",
entity_id: NO_ENTITIES_ID,
primary: this.hass!.localize( primary: this.hass!.localize(
"ui.components.entity.entity-picker.no_match" "ui.components.entity.entity-picker.no_match"
), ),
@ -378,10 +362,10 @@ export class HaEntityComboBox extends LitElement {
} }
if (createItems?.length) { if (createItems?.length) {
states.push(...createItems); items.push(...createItems);
} }
return states; return items;
} }
); );
@ -424,7 +408,7 @@ export class HaEntityComboBox extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<ha-combo-box <ha-combo-box
item-value-path="entity_id" item-value-path="id"
.hass=${this.hass} .hass=${this.hass}
.value=${this._value} .value=${this._value}
.label=${this.label === undefined .label=${this.label === undefined
@ -476,17 +460,7 @@ export class HaEntityComboBox extends LitElement {
} }
private _fuseIndex = memoizeOne((states: EntityComboBoxItem[]) => private _fuseIndex = memoizeOne((states: EntityComboBoxItem[]) =>
Fuse.createIndex( Fuse.createIndex(["search_labels"], states)
[
"entity_name",
"device_name",
"area_name",
"translated_domain",
"friendly_name", // for backwards compatibility
"entity_id", // for technical search
],
states
)
); );
private _filterChanged(ev: CustomEvent): void { private _filterChanged(ev: CustomEvent): void {
@ -503,9 +477,8 @@ export class HaEntityComboBox extends LitElement {
if (results.length === 0) { if (results.length === 0) {
target.filteredItems = [ target.filteredItems = [
{ {
...FAKE_ENTITY, id: NO_ENTITIES_ID,
label: "", label: "",
entity_id: NO_ENTITIES_ID,
primary: this.hass!.localize( primary: this.hass!.localize(
"ui.components.entity.entity-picker.no_match" "ui.components.entity.entity-picker.no_match"
), ),

View File

@ -1,11 +1,10 @@
import { mdiChartLine, mdiShape } from "@mdi/js"; import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
@ -26,31 +25,27 @@ import type { HaComboBox } from "../ha-combo-box";
import "../ha-combo-box-item"; import "../ha-combo-box-item";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "./state-badge"; import "./state-badge";
import { documentationUrl } from "../../util/documentation-url";
type StatisticItemType = "entity" | "external" | "no_state"; type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticItem { interface StatisticItem {
// Force empty label to always display empty value by default in the search field
id: string; id: string;
statistic_id?: string;
label: ""; label: "";
primary: string; primary: string;
secondary?: string; secondary?: string;
show_entity_id?: boolean; search_labels?: string[];
entity_name?: string;
area_name?: string;
device_name?: string;
friendly_name?: string;
sorting_label?: string; sorting_label?: string;
state?: HassEntity; icon_path?: string;
type?: StatisticItemType; type?: StatisticItemType;
iconPath?: string; stateObj?: HassEntity;
} }
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[]; const MISSING_ID = "___missing-entity___";
const ENTITY_ID_STYLE = styleMap({ const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
fontFamily: "var(--ha-font-family-code)",
fontSize: "11px",
});
@customElement("ha-statistic-combo-box") @customElement("ha-statistic-combo-box")
export class HaStatisticComboBox extends LitElement { export class HaStatisticComboBox extends LitElement {
@ -131,37 +126,39 @@ export class HaStatisticComboBox extends LitElement {
private _rowRenderer: ComboBoxLitRenderer<StatisticItem> = ( private _rowRenderer: ComboBoxLitRenderer<StatisticItem> = (
item, item,
{ index } { index }
) => html` ) => {
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}> const showEntityId = this.hass.userData?.showEntityIdPicker;
${!item.state return html`
? html` <ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
<ha-svg-icon ${item.icon_path
style="margin: 0 4px" ? html`
slot="start" <ha-svg-icon
.path=${item.iconPath} style="margin: 0 4px"
></ha-svg-icon> slot="start"
` .path=${item.icon_path}
: html` ></ha-svg-icon>
<state-badge `
slot="start" : item.stateObj
.stateObj=${item.state} ? html`
.hass=${this.hass} <state-badge
></state-badge> slot="start"
`} .stateObj=${item.stateObj}
.hass=${this.hass}
<span slot="headline">${item.primary} </span> ></state-badge>
${item.secondary `
? html`<span slot="supporting-text">${item.secondary}</span>` : nothing}
: nothing} <span slot="headline">${item.primary} </span>
${item.id && item.show_entity_id ${item.secondary
? html` ? html`<span slot="supporting-text">${item.secondary}</span>`
<span slot="supporting-text" style=${ENTITY_ID_STYLE}> : nothing}
${item.id} ${item.id && showEntityId
</span> ? html`<span slot="supporting-text" class="code">
` ${item.statistic_id}
: nothing} </span>`
</ha-combo-box-item> : nothing}
`; </ha-combo-box-item>
`;
};
private _getItems = memoizeOne( private _getItems = memoizeOne(
( (
@ -249,19 +246,22 @@ export class HaStatisticComboBox extends LitElement {
label: "", label: "",
type, type,
sorting_label: label, sorting_label: label,
iconPath: mdiShape, search_labels: [label, id],
icon_path: mdiShape,
}); });
} else if (type === "external") { } else if (type === "external") {
const domain = id.split(":")[0]; const domain = id.split(":")[0];
const domainName = domainToName(this.hass.localize, domain); const domainName = domainToName(this.hass.localize, domain);
output.push({ output.push({
id, id,
statistic_id: id,
primary: label, primary: label,
secondary: domainName, secondary: domainName,
label: "", label: "",
type, type,
sorting_label: label, sorting_label: label,
iconPath: mdiChartLine, search_labels: [label, domainName, id],
icon_path: mdiChartLine,
}); });
} }
} }
@ -283,17 +283,20 @@ export class HaStatisticComboBox extends LitElement {
output.push({ output.push({
id, id,
statistic_id: id,
label: "",
primary, primary,
secondary, secondary,
label: "", stateObj: stateObj,
state: stateObj,
type: "entity", type: "entity",
sorting_label: [deviceName, entityName].join("_"), sorting_label: [deviceName, entityName].join("_"),
entity_name: entityName || deviceName, search_labels: [
area_name: areaName, entityName,
device_name: deviceName, deviceName,
friendly_name: friendlyName, areaName,
show_entity_id: hass.userData?.showEntityIdPicker, friendlyName,
id,
].filter(Boolean) as string[],
}); });
}); });
@ -323,11 +326,12 @@ export class HaStatisticComboBox extends LitElement {
} }
output.push({ output.push({
id: "__missing", id: MISSING_ID,
primary: this.hass.localize( primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity" "ui.components.statistic-picker.missing_entity"
), ),
label: "", label: "",
icon_path: mdiHelpCircle,
}); });
return output; return output;
@ -422,8 +426,12 @@ export class HaStatisticComboBox extends LitElement {
private _statisticChanged(ev: ValueChangedEvent<string>) { private _statisticChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
let newValue = ev.detail.value; let newValue = ev.detail.value;
if (newValue === "__missing") { if (newValue === MISSING_ID) {
newValue = ""; newValue = "";
window.open(
documentationUrl(this.hass, this.helpMissingEntityUrl),
"_blank"
);
} }
if (newValue !== this._value) { if (newValue !== this._value) {
@ -436,16 +444,7 @@ export class HaStatisticComboBox extends LitElement {
} }
private _fuseIndex = memoizeOne((states: StatisticItem[]) => private _fuseIndex = memoizeOne((states: StatisticItem[]) =>
Fuse.createIndex( Fuse.createIndex(["search_labels"], states)
[
"entity_name",
"device_name",
"area_name",
"friendly_name", // for backwards compatibility
"id", // for technical search
],
states
)
); );
private _filterChanged(ev: CustomEvent): void { private _filterChanged(ev: CustomEvent): void {

View File

@ -35,6 +35,20 @@ export class HaComboBoxItem extends HaMdListItem {
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
::slotted(.code) {
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-xs);
}
[slot="trailing-supporting-text"] {
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-normal);
align-self: flex-end;
max-width: 30%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
`, `,
]; ];
} }

View File

@ -95,22 +95,6 @@ type BaseNavigationCommand = Pick<
"primaryText" | "path" "primaryText" | "path"
>; >;
const DOMAIN_STYLE = styleMap({
fontSize: "var(--ha-font-size-s)",
fontWeight: "var(--ha-font-weight-normal)",
lineHeight: "var(--ha-line-height-normal)",
alignSelf: "flex-end",
maxWidth: "30%",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
});
const ENTITY_ID_STYLE = styleMap({
fontFamily: "var(--ha-font-family-code)",
fontSize: "var(--ha-font-size-xs)",
});
@customElement("ha-quick-bar") @customElement("ha-quick-bar")
export class QuickBar extends LitElement { export class QuickBar extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -397,12 +381,12 @@ export class QuickBar extends LitElement {
? html` <span slot="supporting-text">${item.altText}</span> ` ? html` <span slot="supporting-text">${item.altText}</span> `
: nothing} : nothing}
${item.entityId && showEntityId ${item.entityId && showEntityId
? html`<span slot="supporting-text" style=${ENTITY_ID_STYLE} ? html`
>${item.entityId}</span <span slot="supporting-text" class="code">${item.entityId}</span>
>` `
: nothing} : nothing}
${item.translatedDomain && !showEntityId ${item.translatedDomain && !showEntityId
? html`<div slot="trailing-supporting-text" style=${DOMAIN_STYLE}> ? html`<div slot="trailing-supporting-text">
${item.translatedDomain} ${item.translatedDomain}
</div>` </div>`
: nothing} : nothing}
@ -1038,6 +1022,22 @@ export class QuickBar extends LitElement {
--md-list-item-bottom-space: 8px; --md-list-item-bottom-space: 8px;
} }
ha-md-list-item .code {
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-xs);
}
ha-md-list-item [slot="trailing-supporting-text"] {
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-normal);
align-self: flex-end;
max-width: 30%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
ha-tip { ha-tip {
padding: 20px; padding: 20px;
} }