mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-14 05:20:31 +00:00
1143 lines
33 KiB
TypeScript
1143 lines
33 KiB
TypeScript
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||
import { consume } from "@lit/context";
|
||
// @ts-ignore
|
||
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
|
||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||
import Fuse from "fuse.js";
|
||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
|
||
import { customElement, property, state } from "lit/decorators";
|
||
import { styleMap } from "lit/directives/style-map";
|
||
import memoizeOne from "memoize-one";
|
||
import { ensureArray } from "../common/array/ensure-array";
|
||
import { fireEvent } from "../common/dom/fire_event";
|
||
import { isValidEntityId } from "../common/entity/valid_entity_id";
|
||
import { computeRTL } from "../common/util/compute_rtl";
|
||
import {
|
||
getAreasAndFloors,
|
||
type AreaFloorValue,
|
||
type FloorComboBoxItem,
|
||
} from "../data/area_floor";
|
||
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
|
||
import { labelsContext } from "../data/context";
|
||
import { getDevices, type DevicePickerItem } from "../data/device_registry";
|
||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity";
|
||
import { getEntities, type EntityComboBoxItem } from "../data/entity_registry";
|
||
import { domainToName } from "../data/integration";
|
||
import { getLabels, type LabelRegistryEntry } from "../data/label_registry";
|
||
import {
|
||
areaMeetsFilter,
|
||
deviceMeetsFilter,
|
||
entityRegMeetsFilter,
|
||
type TargetType,
|
||
type TargetTypeFloorless,
|
||
} from "../data/target";
|
||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||
import { isHelperDomain } from "../panels/config/helpers/const";
|
||
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
|
||
import { HaFuse } from "../resources/fuse";
|
||
import type { HomeAssistant } from "../types";
|
||
import { brandsUrl } from "../util/brands-url";
|
||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||
import "./ha-generic-picker";
|
||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||
import "./ha-svg-icon";
|
||
import "./ha-tree-indicator";
|
||
import "./target-picker/ha-target-picker-item-group";
|
||
import "./target-picker/ha-target-picker-value-chip";
|
||
|
||
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
|
||
const SEPARATOR = "________";
|
||
const CREATE_ID = "___create-new-entity___";
|
||
|
||
@customElement("ha-target-picker")
|
||
export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||
|
||
@property({ attribute: false }) public value?: HassServiceTarget;
|
||
|
||
@property() public helper?: string;
|
||
|
||
@property({ type: Boolean, reflect: true }) public compact = false;
|
||
|
||
@property({ attribute: false, type: Array }) public createDomains?: string[];
|
||
|
||
/**
|
||
* Show only targets with entities from specific domains.
|
||
* @type {Array}
|
||
* @attr include-domains
|
||
*/
|
||
@property({ type: Array, attribute: "include-domains" })
|
||
public includeDomains?: string[];
|
||
|
||
/**
|
||
* Show only targets with entities of these device classes.
|
||
* @type {Array}
|
||
* @attr include-device-classes
|
||
*/
|
||
@property({ type: Array, attribute: "include-device-classes" })
|
||
public includeDeviceClasses?: string[];
|
||
|
||
@property({ attribute: false })
|
||
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
|
||
|
||
@property({ attribute: false })
|
||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||
|
||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||
|
||
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
|
||
|
||
@state() private _selectedSection?: TargetTypeFloorless;
|
||
|
||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||
|
||
@state()
|
||
@consume({ context: labelsContext, subscribe: true })
|
||
private _labelRegistry!: LabelRegistryEntry[];
|
||
|
||
private _newTarget?: { type: TargetType; id: string };
|
||
|
||
private _getDevicesMemoized = memoizeOne(getDevices);
|
||
|
||
private _getLabelsMemoized = memoizeOne(getLabels);
|
||
|
||
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||
|
||
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
|
||
|
||
private get _showEntityId() {
|
||
return this.hass.userData?.showEntityIdPicker;
|
||
}
|
||
|
||
private _fuseIndexes = {
|
||
area: memoizeOne((states: FloorComboBoxItem[]) =>
|
||
this._createFuseIndex(states)
|
||
),
|
||
entity: memoizeOne((states: EntityComboBoxItem[]) =>
|
||
this._createFuseIndex(states)
|
||
),
|
||
device: memoizeOne((states: DevicePickerItem[]) =>
|
||
this._createFuseIndex(states)
|
||
),
|
||
label: memoizeOne((states: PickerComboBoxItem[]) =>
|
||
this._createFuseIndex(states)
|
||
),
|
||
};
|
||
|
||
public willUpdate(changedProps: PropertyValues) {
|
||
super.willUpdate(changedProps);
|
||
|
||
if (!this.hasUpdated) {
|
||
this._loadConfigEntries();
|
||
}
|
||
}
|
||
|
||
private _createFuseIndex = (states) =>
|
||
Fuse.createIndex(["search_labels"], states);
|
||
|
||
protected render() {
|
||
if (this.addOnTop) {
|
||
return html` ${this._renderPicker()} ${this._renderItems()} `;
|
||
}
|
||
return html` ${this._renderItems()} ${this._renderPicker()} `;
|
||
}
|
||
|
||
private _renderValueChips() {
|
||
const entityIds = this.value?.entity_id
|
||
? ensureArray(this.value.entity_id)
|
||
: [];
|
||
const deviceIds = this.value?.device_id
|
||
? ensureArray(this.value.device_id)
|
||
: [];
|
||
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
|
||
const floorIds = this.value?.floor_id
|
||
? ensureArray(this.value.floor_id)
|
||
: [];
|
||
const labelIds = this.value?.label_id
|
||
? ensureArray(this.value.label_id)
|
||
: [];
|
||
|
||
if (
|
||
!entityIds.length &&
|
||
!deviceIds.length &&
|
||
!areaIds.length &&
|
||
!floorIds.length &&
|
||
!labelIds.length
|
||
) {
|
||
return nothing;
|
||
}
|
||
|
||
return html`
|
||
<div class="mdc-chip-set items">
|
||
${floorIds.length
|
||
? floorIds.map(
|
||
(floor_id) => html`
|
||
<ha-target-picker-value-chip
|
||
.hass=${this.hass}
|
||
type="floor"
|
||
.itemId=${floor_id}
|
||
@remove-target-item=${this._handleRemove}
|
||
@expand-target-item=${this._handleExpand}
|
||
></ha-target-picker-value-chip>
|
||
`
|
||
)
|
||
: nothing}
|
||
${areaIds.length
|
||
? areaIds.map(
|
||
(area_id) => html`
|
||
<ha-target-picker-value-chip
|
||
.hass=${this.hass}
|
||
type="area"
|
||
.itemId=${area_id}
|
||
@remove-target-item=${this._handleRemove}
|
||
@expand-target-item=${this._handleExpand}
|
||
></ha-target-picker-value-chip>
|
||
`
|
||
)
|
||
: nothing}
|
||
${deviceIds.length
|
||
? deviceIds.map(
|
||
(device_id) => html`
|
||
<ha-target-picker-value-chip
|
||
.hass=${this.hass}
|
||
type="device"
|
||
.itemId=${device_id}
|
||
@remove-target-item=${this._handleRemove}
|
||
@expand-target-item=${this._handleExpand}
|
||
></ha-target-picker-value-chip>
|
||
`
|
||
)
|
||
: nothing}
|
||
${entityIds.length
|
||
? entityIds.map(
|
||
(entity_id) => html`
|
||
<ha-target-picker-value-chip
|
||
.hass=${this.hass}
|
||
type="entity"
|
||
.itemId=${entity_id}
|
||
@remove-target-item=${this._handleRemove}
|
||
@expand-target-item=${this._handleExpand}
|
||
></ha-target-picker-value-chip>
|
||
`
|
||
)
|
||
: nothing}
|
||
${labelIds.length
|
||
? labelIds.map(
|
||
(label_id) => html`
|
||
<ha-target-picker-value-chip
|
||
.hass=${this.hass}
|
||
type="label"
|
||
.itemId=${label_id}
|
||
@remove-target-item=${this._handleRemove}
|
||
@expand-target-item=${this._handleExpand}
|
||
></ha-target-picker-value-chip>
|
||
`
|
||
)
|
||
: nothing}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private _renderValueGroups() {
|
||
const entityIds = this.value?.entity_id
|
||
? ensureArray(this.value.entity_id)
|
||
: [];
|
||
const deviceIds = this.value?.device_id
|
||
? ensureArray(this.value.device_id)
|
||
: [];
|
||
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
|
||
const floorIds = this.value?.floor_id
|
||
? ensureArray(this.value.floor_id)
|
||
: [];
|
||
const labelIds = this.value?.label_id
|
||
? ensureArray(this.value?.label_id)
|
||
: [];
|
||
|
||
if (
|
||
!entityIds.length &&
|
||
!deviceIds.length &&
|
||
!areaIds.length &&
|
||
!floorIds.length &&
|
||
!labelIds.length
|
||
) {
|
||
return nothing;
|
||
}
|
||
|
||
return html`
|
||
<div class="item-groups">
|
||
${entityIds.length
|
||
? html`
|
||
<ha-target-picker-item-group
|
||
@remove-target-item=${this._handleRemove}
|
||
type="entity"
|
||
.hass=${this.hass}
|
||
.items=${{ entity: entityIds }}
|
||
.deviceFilter=${this.deviceFilter}
|
||
.entityFilter=${this.entityFilter}
|
||
.includeDomains=${this.includeDomains}
|
||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||
>
|
||
</ha-target-picker-item-group>
|
||
`
|
||
: nothing}
|
||
${deviceIds.length
|
||
? html`
|
||
<ha-target-picker-item-group
|
||
@remove-target-item=${this._handleRemove}
|
||
type="device"
|
||
.hass=${this.hass}
|
||
.items=${{ device: deviceIds }}
|
||
.deviceFilter=${this.deviceFilter}
|
||
.entityFilter=${this.entityFilter}
|
||
.includeDomains=${this.includeDomains}
|
||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||
>
|
||
</ha-target-picker-item-group>
|
||
`
|
||
: nothing}
|
||
${floorIds.length || areaIds.length
|
||
? html`
|
||
<ha-target-picker-item-group
|
||
@remove-target-item=${this._handleRemove}
|
||
type="area"
|
||
.hass=${this.hass}
|
||
.items=${{
|
||
floor: floorIds,
|
||
area: areaIds,
|
||
}}
|
||
.deviceFilter=${this.deviceFilter}
|
||
.entityFilter=${this.entityFilter}
|
||
.includeDomains=${this.includeDomains}
|
||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||
>
|
||
</ha-target-picker-item-group>
|
||
`
|
||
: nothing}
|
||
${labelIds.length
|
||
? html`
|
||
<ha-target-picker-item-group
|
||
@remove-target-item=${this._handleRemove}
|
||
type="label"
|
||
.hass=${this.hass}
|
||
.items=${{ label: labelIds }}
|
||
.deviceFilter=${this.deviceFilter}
|
||
.entityFilter=${this.entityFilter}
|
||
.includeDomains=${this.includeDomains}
|
||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||
>
|
||
</ha-target-picker-item-group>
|
||
`
|
||
: nothing}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private _renderItems() {
|
||
return html`
|
||
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
|
||
`;
|
||
}
|
||
|
||
private _renderPicker() {
|
||
const sections = [
|
||
{
|
||
id: "entity",
|
||
label: this.hass.localize("ui.components.target-picker.type.entities"),
|
||
},
|
||
{
|
||
id: "device",
|
||
label: this.hass.localize("ui.components.target-picker.type.devices"),
|
||
},
|
||
{
|
||
id: "area",
|
||
label: this.hass.localize("ui.components.target-picker.type.areas"),
|
||
},
|
||
"separator" as const,
|
||
{
|
||
id: "label",
|
||
label: this.hass.localize("ui.components.target-picker.type.labels"),
|
||
},
|
||
];
|
||
|
||
return html`
|
||
<div class="add-target-wrapper">
|
||
<ha-generic-picker
|
||
.hass=${this.hass}
|
||
.disabled=${this.disabled}
|
||
.autofocus=${this.autofocus}
|
||
.helper=${this.helper}
|
||
.sections=${sections}
|
||
.notFoundLabel=${this._noTargetFoundLabel}
|
||
.emptyLabel=${this.hass.localize(
|
||
"ui.components.target-picker.no_targets"
|
||
)}
|
||
.sectionTitleFunction=${this._sectionTitleFunction}
|
||
.selectedSection=${this._selectedSection}
|
||
.rowRenderer=${this._renderRow}
|
||
.getItems=${this._getItems}
|
||
@value-changed=${this._targetPicked}
|
||
.addButtonLabel=${this.hass.localize(
|
||
"ui.components.target-picker.add_target"
|
||
)}
|
||
.getAdditionalItems=${this._getAdditionalItems}
|
||
>
|
||
</ha-generic-picker>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private _targetPicked(ev: CustomEvent<{ value: string }>) {
|
||
ev.stopPropagation();
|
||
const value = ev.detail.value;
|
||
if (value.startsWith(CREATE_ID)) {
|
||
this._createNewDomainElement(value.substring(CREATE_ID.length));
|
||
return;
|
||
}
|
||
|
||
const [type, id] = ev.detail.value.split(SEPARATOR);
|
||
this._addTarget(id, type as TargetType);
|
||
}
|
||
|
||
private _addTarget(id: string, type: TargetType) {
|
||
const typeId = `${type}_id`;
|
||
|
||
if (typeId === "entity_id" && !isValidEntityId(id)) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
this.value &&
|
||
this.value[typeId] &&
|
||
ensureArray(this.value[typeId]).includes(id)
|
||
) {
|
||
return;
|
||
}
|
||
fireEvent(this, "value-changed", {
|
||
value: this.value
|
||
? {
|
||
...this.value,
|
||
[typeId]: this.value[typeId]
|
||
? [...ensureArray(this.value[typeId]), id]
|
||
: id,
|
||
}
|
||
: { [typeId]: id },
|
||
});
|
||
|
||
this.shadowRoot
|
||
?.querySelector(
|
||
`ha-target-picker-item-group[type='${this._newTarget?.type}']`
|
||
)
|
||
?.removeAttribute("collapsed");
|
||
}
|
||
|
||
private _createNewDomainElement = (domain: string) => {
|
||
showHelperDetailDialog(this, {
|
||
domain,
|
||
dialogClosedCallback: (item) => {
|
||
if (item.entityId) {
|
||
// prevent error that new entity_id isn't in hass object
|
||
requestAnimationFrame(() => {
|
||
this._addTarget(item.entityId!, "entity");
|
||
});
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
private _handleRemove(ev) {
|
||
const { type, id } = ev.detail;
|
||
fireEvent(this, "value-changed", {
|
||
value: this._removeItem(this.value, type, id),
|
||
});
|
||
}
|
||
|
||
private _handleExpand(ev) {
|
||
const type = ev.detail.type;
|
||
const itemId = ev.detail.id;
|
||
const newAreas: string[] = [];
|
||
const newDevices: string[] = [];
|
||
const newEntities: string[] = [];
|
||
|
||
if (type === "floor") {
|
||
Object.values(this.hass.areas).forEach((area) => {
|
||
if (
|
||
area.floor_id === itemId &&
|
||
!this.value!.area_id?.includes(area.area_id) &&
|
||
areaMeetsFilter(
|
||
area,
|
||
this.hass.devices,
|
||
this.hass.entities,
|
||
this.deviceFilter,
|
||
this.includeDomains,
|
||
this.includeDeviceClasses,
|
||
this.hass.states,
|
||
this.entityFilter
|
||
)
|
||
) {
|
||
newAreas.push(area.area_id);
|
||
}
|
||
});
|
||
} else if (type === "area") {
|
||
Object.values(this.hass.devices).forEach((device) => {
|
||
if (
|
||
device.area_id === itemId &&
|
||
!this.value!.device_id?.includes(device.id) &&
|
||
deviceMeetsFilter(
|
||
device,
|
||
this.hass.entities,
|
||
this.deviceFilter,
|
||
this.includeDomains,
|
||
this.includeDeviceClasses,
|
||
this.hass.states,
|
||
this.entityFilter
|
||
)
|
||
) {
|
||
newDevices.push(device.id);
|
||
}
|
||
});
|
||
Object.values(this.hass.entities).forEach((entity) => {
|
||
if (
|
||
entity.area_id === itemId &&
|
||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||
entityRegMeetsFilter(
|
||
entity,
|
||
false,
|
||
this.includeDomains,
|
||
this.includeDeviceClasses,
|
||
this.hass.states,
|
||
this.entityFilter
|
||
)
|
||
) {
|
||
newEntities.push(entity.entity_id);
|
||
}
|
||
});
|
||
} else if (type === "device") {
|
||
Object.values(this.hass.entities).forEach((entity) => {
|
||
if (
|
||
entity.device_id === itemId &&
|
||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||
entityRegMeetsFilter(
|
||
entity,
|
||
false,
|
||
this.includeDomains,
|
||
this.includeDeviceClasses,
|
||
this.hass.states,
|
||
this.entityFilter
|
||
)
|
||
) {
|
||
newEntities.push(entity.entity_id);
|
||
}
|
||
});
|
||
} else if (type === "label") {
|
||
Object.values(this.hass.areas).forEach((area) => {
|
||
if (
|
||
area.labels.includes(itemId) &&
|
||
!this.value!.area_id?.includes(area.area_id) &&
|
||
areaMeetsFilter(
|
||
area,
|
||
this.hass.devices,
|
||
this.hass.entities,
|
||
this.deviceFilter,
|
||
this.includeDomains,
|
||
this.includeDeviceClasses,
|
||
this.hass.states,
|
||
this.entityFilter
|
||
)
|
||
) {
|
||
newAreas.push(area.area_id);
|
||
}
|
||
});
|
||
Object.values(this.hass.devices).forEach((device) => {
|
||
if (
|
||
device.labels.includes(itemId) &&
|
||
!this.value!.device_id?.includes(device.id) &&
|
||
deviceMeetsFilter(
|
||
device,
|
||
this.hass.entities,
|
||
this.deviceFilter,
|
||
this.includeDomains,
|
||
this.includeDeviceClasses,
|
||
this.hass.states,
|
||
this.entityFilter
|
||
)
|
||
) {
|
||
newDevices.push(device.id);
|
||
}
|
||
});
|
||
Object.values(this.hass.entities).forEach((entity) => {
|
||
if (
|
||
entity.labels.includes(itemId) &&
|
||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||
entityRegMeetsFilter(
|
||
entity,
|
||
true,
|
||
this.includeDomains,
|
||
this.includeDeviceClasses,
|
||
this.hass.states,
|
||
this.entityFilter
|
||
)
|
||
) {
|
||
newEntities.push(entity.entity_id);
|
||
}
|
||
});
|
||
} else {
|
||
return;
|
||
}
|
||
let value = this.value;
|
||
if (newEntities.length) {
|
||
value = this._addItems(value, "entity_id", newEntities);
|
||
}
|
||
if (newDevices.length) {
|
||
value = this._addItems(value, "device_id", newDevices);
|
||
}
|
||
if (newAreas.length) {
|
||
value = this._addItems(value, "area_id", newAreas);
|
||
}
|
||
value = this._removeItem(value, type, itemId);
|
||
fireEvent(this, "value-changed", { value });
|
||
}
|
||
|
||
private _addItems(
|
||
value: this["value"],
|
||
type: string,
|
||
ids: string[]
|
||
): this["value"] {
|
||
return {
|
||
...value,
|
||
[type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids,
|
||
};
|
||
}
|
||
|
||
private _removeItem(
|
||
value: this["value"],
|
||
type: TargetType,
|
||
id: string
|
||
): this["value"] {
|
||
const typeId = `${type}_id`;
|
||
|
||
const newVal = ensureArray(value![typeId])!.filter(
|
||
(val) => String(val) !== id
|
||
);
|
||
if (newVal.length) {
|
||
return {
|
||
...value,
|
||
[typeId]: newVal,
|
||
};
|
||
}
|
||
const val = { ...value }!;
|
||
delete val[typeId];
|
||
if (Object.keys(val).length) {
|
||
return val;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
private _getRowType = (
|
||
item:
|
||
| PickerComboBoxItem
|
||
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||
| EntityComboBoxItem
|
||
| DevicePickerItem
|
||
) => {
|
||
if (
|
||
(item as FloorComboBoxItem).type === "area" ||
|
||
(item as FloorComboBoxItem).type === "floor"
|
||
) {
|
||
return (item as FloorComboBoxItem).type;
|
||
}
|
||
|
||
if ("domain" in item) {
|
||
return "device";
|
||
}
|
||
|
||
if ("stateObj" in item) {
|
||
return "entity";
|
||
}
|
||
|
||
if (item.id === EMPTY_SEARCH) {
|
||
return "empty";
|
||
}
|
||
|
||
return "label";
|
||
};
|
||
|
||
private _sectionTitleFunction = ({
|
||
firstIndex,
|
||
lastIndex,
|
||
firstItem,
|
||
secondItem,
|
||
itemsCount,
|
||
}: {
|
||
firstIndex: number;
|
||
lastIndex: number;
|
||
firstItem: PickerComboBoxItem | string;
|
||
secondItem: PickerComboBoxItem | string;
|
||
itemsCount: number;
|
||
}) => {
|
||
if (
|
||
firstItem === undefined ||
|
||
secondItem === undefined ||
|
||
typeof firstItem === "string" ||
|
||
(typeof secondItem === "string" && secondItem !== "padding") ||
|
||
(firstIndex === 0 && lastIndex === itemsCount - 1)
|
||
) {
|
||
return undefined;
|
||
}
|
||
|
||
const type = this._getRowType(firstItem as PickerComboBoxItem);
|
||
const translationType:
|
||
| "areas"
|
||
| "entities"
|
||
| "devices"
|
||
| "labels"
|
||
| undefined =
|
||
type === "area" || type === "floor"
|
||
? "areas"
|
||
: type === "entity"
|
||
? "entities"
|
||
: type && type !== "empty"
|
||
? `${type}s`
|
||
: undefined;
|
||
|
||
return translationType
|
||
? this.hass.localize(
|
||
`ui.components.target-picker.type.${translationType}`
|
||
)
|
||
: undefined;
|
||
};
|
||
|
||
private _getItems = (searchString: string, section: string) => {
|
||
this._selectedSection = section as TargetTypeFloorless | undefined;
|
||
|
||
return this._getItemsMemoized(
|
||
this.entityFilter,
|
||
this.deviceFilter,
|
||
this.includeDomains,
|
||
this.includeDeviceClasses,
|
||
this.value,
|
||
searchString,
|
||
this._configEntryLookup,
|
||
this._selectedSection
|
||
);
|
||
};
|
||
|
||
private _getItemsMemoized = memoizeOne(
|
||
(
|
||
entityFilter: this["entityFilter"],
|
||
deviceFilter: this["deviceFilter"],
|
||
includeDomains: this["includeDomains"],
|
||
includeDeviceClasses: this["includeDeviceClasses"],
|
||
targetValue: this["value"],
|
||
searchTerm: string,
|
||
configEntryLookup: Record<string, ConfigEntry>,
|
||
filterType?: TargetTypeFloorless
|
||
) => {
|
||
const items: (
|
||
| string
|
||
| FloorComboBoxItem
|
||
| EntityComboBoxItem
|
||
| PickerComboBoxItem
|
||
)[] = [];
|
||
|
||
if (!filterType || filterType === "entity") {
|
||
let entities = this._getEntitiesMemoized(
|
||
this.hass,
|
||
includeDomains,
|
||
undefined,
|
||
entityFilter,
|
||
includeDeviceClasses,
|
||
undefined,
|
||
undefined,
|
||
targetValue?.entity_id
|
||
? ensureArray(targetValue.entity_id)
|
||
: undefined,
|
||
undefined,
|
||
`entity${SEPARATOR}`
|
||
);
|
||
|
||
if (searchTerm) {
|
||
entities = this._filterGroup(
|
||
"entity",
|
||
entities,
|
||
searchTerm,
|
||
(item: EntityComboBoxItem) =>
|
||
item.stateObj?.entity_id === searchTerm
|
||
) as EntityComboBoxItem[];
|
||
}
|
||
|
||
if (!filterType && entities.length) {
|
||
// show group title
|
||
items.push(
|
||
this.hass.localize("ui.components.target-picker.type.entities")
|
||
);
|
||
}
|
||
|
||
items.push(...entities);
|
||
}
|
||
|
||
if (!filterType || filterType === "device") {
|
||
let devices = this._getDevicesMemoized(
|
||
this.hass,
|
||
configEntryLookup,
|
||
includeDomains,
|
||
undefined,
|
||
includeDeviceClasses,
|
||
deviceFilter,
|
||
entityFilter,
|
||
targetValue?.device_id
|
||
? ensureArray(targetValue.device_id)
|
||
: undefined,
|
||
undefined,
|
||
`device${SEPARATOR}`
|
||
);
|
||
|
||
if (searchTerm) {
|
||
devices = this._filterGroup("device", devices, searchTerm);
|
||
}
|
||
|
||
if (!filterType && devices.length) {
|
||
// show group title
|
||
items.push(
|
||
this.hass.localize("ui.components.target-picker.type.devices")
|
||
);
|
||
}
|
||
|
||
items.push(...devices);
|
||
}
|
||
|
||
if (!filterType || filterType === "area") {
|
||
let areasAndFloors = this._getAreasAndFloorsMemoized(
|
||
this.hass.states,
|
||
this.hass.floors,
|
||
this.hass.areas,
|
||
this.hass.devices,
|
||
this.hass.entities,
|
||
memoizeOne((value: AreaFloorValue): string =>
|
||
[value.type, value.id].join(SEPARATOR)
|
||
),
|
||
includeDomains,
|
||
undefined,
|
||
includeDeviceClasses,
|
||
deviceFilter,
|
||
entityFilter,
|
||
targetValue?.area_id ? ensureArray(targetValue.area_id) : undefined,
|
||
targetValue?.floor_id ? ensureArray(targetValue.floor_id) : undefined
|
||
);
|
||
|
||
if (searchTerm) {
|
||
areasAndFloors = this._filterGroup(
|
||
"area",
|
||
areasAndFloors,
|
||
searchTerm
|
||
) as FloorComboBoxItem[];
|
||
}
|
||
|
||
if (!filterType && areasAndFloors.length) {
|
||
// show group title
|
||
items.push(
|
||
this.hass.localize("ui.components.target-picker.type.areas")
|
||
);
|
||
}
|
||
|
||
items.push(
|
||
...areasAndFloors.map((item, index) => {
|
||
const nextItem = areasAndFloors[index + 1];
|
||
|
||
if (
|
||
!nextItem ||
|
||
(item.type === "area" && nextItem.type === "floor")
|
||
) {
|
||
return {
|
||
...item,
|
||
last: true,
|
||
};
|
||
}
|
||
|
||
return item;
|
||
})
|
||
);
|
||
}
|
||
|
||
if (!filterType || filterType === "label") {
|
||
let labels = this._getLabelsMemoized(
|
||
this.hass,
|
||
this._labelRegistry,
|
||
includeDomains,
|
||
undefined,
|
||
includeDeviceClasses,
|
||
deviceFilter,
|
||
entityFilter,
|
||
targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined,
|
||
`label${SEPARATOR}`
|
||
);
|
||
|
||
if (searchTerm) {
|
||
labels = this._filterGroup("label", labels, searchTerm);
|
||
}
|
||
|
||
if (!filterType && labels.length) {
|
||
// show group title
|
||
items.push(
|
||
this.hass.localize("ui.components.target-picker.type.labels")
|
||
);
|
||
}
|
||
|
||
items.push(...labels);
|
||
}
|
||
|
||
return items;
|
||
}
|
||
);
|
||
|
||
private _filterGroup(
|
||
type: TargetType,
|
||
items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[],
|
||
searchTerm: string,
|
||
checkExact?: (
|
||
item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem
|
||
) => boolean
|
||
) {
|
||
const fuseIndex = this._fuseIndexes[type](items);
|
||
const fuse = new HaFuse(
|
||
items,
|
||
{
|
||
shouldSort: false,
|
||
minMatchCharLength: Math.min(searchTerm.length, 2),
|
||
},
|
||
fuseIndex
|
||
);
|
||
|
||
const results = fuse.multiTermsSearch(searchTerm);
|
||
let filteredItems = items;
|
||
if (results) {
|
||
filteredItems = results.map((result) => result.item);
|
||
}
|
||
|
||
if (!checkExact) {
|
||
return filteredItems;
|
||
}
|
||
|
||
// If there is exact match for entity id, put it first
|
||
const index = filteredItems.findIndex((item) => checkExact(item));
|
||
if (index === -1) {
|
||
return filteredItems;
|
||
}
|
||
|
||
const [exactMatch] = filteredItems.splice(index, 1);
|
||
filteredItems.unshift(exactMatch);
|
||
|
||
return filteredItems;
|
||
}
|
||
|
||
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
|
||
|
||
private _getCreateItems = memoizeOne(
|
||
(createDomains: this["createDomains"]) => {
|
||
if (!createDomains?.length) {
|
||
return [];
|
||
}
|
||
|
||
return createDomains.map((domain) => {
|
||
const primary = this.hass.localize(
|
||
"ui.components.entity.entity-picker.create_helper",
|
||
{
|
||
domain: isHelperDomain(domain)
|
||
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
|
||
: domainToName(this.hass.localize, domain),
|
||
}
|
||
);
|
||
|
||
return {
|
||
id: CREATE_ID + domain,
|
||
primary: primary,
|
||
secondary: this.hass.localize(
|
||
"ui.components.entity.entity-picker.new_entity"
|
||
),
|
||
icon_path: mdiPlus,
|
||
} satisfies EntityComboBoxItem;
|
||
});
|
||
}
|
||
);
|
||
|
||
private async _loadConfigEntries() {
|
||
const configEntries = await getConfigEntries(this.hass);
|
||
this._configEntryLookup = Object.fromEntries(
|
||
configEntries.map((entry) => [entry.entry_id, entry])
|
||
);
|
||
}
|
||
|
||
private _renderRow = (
|
||
item:
|
||
| PickerComboBoxItem
|
||
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||
| EntityComboBoxItem
|
||
| DevicePickerItem,
|
||
index: number
|
||
) => {
|
||
if (!item) {
|
||
return nothing;
|
||
}
|
||
|
||
const type = this._getRowType(item);
|
||
let hasFloor = false;
|
||
let rtl = false;
|
||
let showEntityId = false;
|
||
|
||
if (type === "area" || type === "floor") {
|
||
item.id = item[type]?.[`${type}_id`];
|
||
|
||
rtl = computeRTL(this.hass);
|
||
hasFloor =
|
||
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
|
||
}
|
||
|
||
if (type === "entity") {
|
||
showEntityId = !!this._showEntityId;
|
||
}
|
||
|
||
return html`
|
||
<ha-combo-box-item
|
||
id=${`list-item-${index}`}
|
||
tabindex="-1"
|
||
.type=${type === "empty" ? "text" : "button"}
|
||
class=${type === "empty" ? "empty" : ""}
|
||
style=${(item as FloorComboBoxItem).type === "area" && hasFloor
|
||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||
: ""}
|
||
>
|
||
${(item as FloorComboBoxItem).type === "area" && hasFloor
|
||
? html`
|
||
<ha-tree-indicator
|
||
style=${styleMap({
|
||
width: "var(--ha-space-12)",
|
||
position: "absolute",
|
||
top: "var(--ha-space-0)",
|
||
left: rtl ? undefined : "var(--ha-space-1)",
|
||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||
transform: rtl ? "scaleX(-1)" : "",
|
||
})}
|
||
.end=${(
|
||
item as FloorComboBoxItem & { last?: boolean | undefined }
|
||
).last}
|
||
slot="start"
|
||
></ha-tree-indicator>
|
||
`
|
||
: nothing}
|
||
${item.icon
|
||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||
: item.icon_path
|
||
? html`<ha-svg-icon
|
||
slot="start"
|
||
.path=${item.icon_path}
|
||
></ha-svg-icon>`
|
||
: type === "entity" && (item as EntityComboBoxItem).stateObj
|
||
? html`
|
||
<state-badge
|
||
slot="start"
|
||
.stateObj=${(item as EntityComboBoxItem).stateObj}
|
||
.hass=${this.hass}
|
||
></state-badge>
|
||
`
|
||
: type === "device" && (item as DevicePickerItem).domain
|
||
? html`
|
||
<img
|
||
slot="start"
|
||
alt=""
|
||
crossorigin="anonymous"
|
||
referrerpolicy="no-referrer"
|
||
src=${brandsUrl({
|
||
domain: (item as DevicePickerItem).domain!,
|
||
type: "icon",
|
||
darkOptimized: this.hass.themes.darkMode,
|
||
})}
|
||
/>
|
||
`
|
||
: type === "floor"
|
||
? html`<ha-floor-icon
|
||
slot="start"
|
||
.floor=${(item as FloorComboBoxItem).floor!}
|
||
></ha-floor-icon>`
|
||
: type === "area"
|
||
? html`<ha-svg-icon
|
||
slot="start"
|
||
.path=${item.icon_path || mdiTextureBox}
|
||
></ha-svg-icon>`
|
||
: nothing}
|
||
<span slot="headline">${item.primary}</span>
|
||
${item.secondary
|
||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||
: nothing}
|
||
${(item as EntityComboBoxItem).stateObj && showEntityId
|
||
? html`
|
||
<span slot="supporting-text" class="code">
|
||
${(item as EntityComboBoxItem).stateObj?.entity_id}
|
||
</span>
|
||
`
|
||
: nothing}
|
||
${(item as EntityComboBoxItem).domain_name &&
|
||
(type !== "entity" || !showEntityId)
|
||
? html`
|
||
<div slot="trailing-supporting-text" class="domain">
|
||
${(item as EntityComboBoxItem).domain_name}
|
||
</div>
|
||
`
|
||
: nothing}
|
||
</ha-combo-box-item>
|
||
`;
|
||
};
|
||
|
||
private _noTargetFoundLabel = (search: string) =>
|
||
this.hass.localize("ui.components.target-picker.no_target_found", {
|
||
term: html`<b>‘${search}’</b>`,
|
||
});
|
||
|
||
static get styles(): CSSResultGroup {
|
||
return css`
|
||
.add-target-wrapper {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
margin-top: var(--ha-space-3);
|
||
}
|
||
|
||
ha-generic-picker {
|
||
width: 100%;
|
||
}
|
||
|
||
${unsafeCSS(chipStyles)}
|
||
.items {
|
||
z-index: 2;
|
||
}
|
||
.mdc-chip-set {
|
||
padding: var(--ha-space-1) var(--ha-space-0);
|
||
gap: var(--ha-space-2);
|
||
}
|
||
|
||
.item-groups {
|
||
overflow: hidden;
|
||
border: 2px solid var(--divider-color);
|
||
border-radius: var(--ha-border-radius-lg);
|
||
}
|
||
`;
|
||
}
|
||
}
|
||
|
||
declare global {
|
||
interface HTMLElementTagNameMap {
|
||
"ha-target-picker": HaTargetPicker;
|
||
}
|
||
|
||
interface HASSDomEvents {
|
||
"remove-target-item": {
|
||
type: string;
|
||
id: string;
|
||
};
|
||
"expand-target-item": {
|
||
type: string;
|
||
id: string;
|
||
};
|
||
"remove-target-group": string;
|
||
}
|
||
}
|