Selectors and stuff

This commit is contained in:
Franck Nijhof 2023-05-25 20:29:43 +02:00
parent a6abc88007
commit 8837eede9a
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
19 changed files with 1033 additions and 10 deletions

View File

@ -74,6 +74,7 @@ export class HaDemo extends HomeAssistantAppEl {
has_entity_name: false,
unique_id: "co2_intensity",
options: null,
labels: [],
},
{
config_entry_id: "co2signal",
@ -90,6 +91,7 @@ export class HaDemo extends HomeAssistantAppEl {
has_entity_name: false,
unique_id: "grid_fossil_fuel_percentage",
options: null,
labels: [],
},
]);

View File

@ -0,0 +1,7 @@
import { LabelRegistryEntry } from "../../../src/data/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => hass.mockWS("config/label_registry/list", () => data);

View File

@ -7,6 +7,7 @@ import "../../components/demo-black-white-row";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import "../../../../src/panels/config/automation/action/ha-automation-action";
import { HaChooseAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-choose";
@ -59,6 +60,7 @@ class DemoHaAutomationEditorAction extends LitElement {
mockEntityRegistry(hass);
mockDeviceRegistry(hass);
mockAreaRegistry(hass);
mockLabelRegistry(hass);
mockHassioSupervisor(hass);
}

View File

@ -7,6 +7,7 @@ import "../../components/demo-black-white-row";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import type { ConditionWithShorthand } from "../../../../src/data/automation";
import "../../../../src/panels/config/automation/condition/ha-automation-condition";
@ -95,6 +96,7 @@ class DemoHaAutomationEditorCondition extends LitElement {
mockEntityRegistry(hass);
mockDeviceRegistry(hass);
mockAreaRegistry(hass);
mockLabelRegistry(hass);
mockHassioSupervisor(hass);
}

View File

@ -7,6 +7,7 @@ import "../../components/demo-black-white-row";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import type { Trigger } from "../../../../src/data/automation";
import { HaGeolocationTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-geo_location";
@ -141,6 +142,7 @@ class DemoHaAutomationEditorTrigger extends LitElement {
mockEntityRegistry(hass);
mockDeviceRegistry(hass);
mockAreaRegistry(hass);
mockLabelRegistry(hass);
mockHassioSupervisor(hass);
}

View File

@ -6,6 +6,7 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
import "../../../../src/components/ha-form/ha-form";
@ -58,6 +59,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: "backyard",
@ -76,6 +78,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: null,
@ -94,6 +97,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
];
@ -118,6 +122,30 @@ const AREAS = [
},
];
const LABELS = [
{
label_id: "romantic",
name: "Romantic",
icon: "mdi:heart",
color: "#ff0000",
description: "Lights that can create a romantic atmosphere",
},
{
label_id: "away",
name: "Away",
icon: "mdi:home-export-outline",
color: "#cccccc",
description: "All that can all be turned off when away from home",
},
{
label_id: "cleaning",
name: "Cleaning",
icon: "mdi:home-export-outline",
color: "#cccccc",
description: "Everything to turn on while cleaning the house",
},
];
const SCHEMAS: {
title: string;
translations?: Record<string, string>;
@ -132,6 +160,7 @@ const SCHEMAS: {
entity: "Entity",
device: "Device",
area: "Area",
label: "Label",
target: "Target",
number: "Number",
boolean: "Boolean",
@ -163,6 +192,7 @@ const SCHEMAS: {
{ name: "Config entry", selector: { config_entry: {} } },
{ name: "Duration", selector: { duration: {} } },
{ name: "area", selector: { area: {} } },
{ name: "label", selector: { label: {} } },
{ name: "target", selector: { target: {} } },
{ name: "number", selector: { number: { min: 0, max: 10 } } },
{ name: "boolean", selector: { boolean: {} } },
@ -444,6 +474,7 @@ class DemoHaForm extends LitElement {
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockAreaRegistry(hass, AREAS);
mockLabelRegistry(hass, LABELS);
mockHassioSupervisor(hass);
}

View File

@ -7,6 +7,7 @@ import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import { BlueprintInput } from "../../../../src/data/blueprint";
@ -54,6 +55,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: "backyard",
@ -72,6 +74,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: null,
@ -90,9 +93,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
},
];
const AREAS = [
{
area_id: "backyard",
@ -114,6 +115,30 @@ const AREAS = [
},
];
const LABELS = [
{
label_id: "romantic",
name: "Romantic",
icon: "mdi:heart",
color: "#ff0000",
description: "Lights that can create a romantic atmosphere",
},
{
label_id: "away",
name: "Away",
icon: "mdi:home-export-outline",
color: "#cccccc",
description: "All that can all be turned off when away from home",
},
{
label_id: "cleaning",
name: "Cleaning",
icon: "mdi:home-export-outline",
color: "#cccccc",
description: "Everything to turn on while cleaning the house",
},
];
const SCHEMAS: {
name: string;
input: Record<string, (BlueprintInput & { required?: boolean }) | null>;
@ -138,6 +163,7 @@ const SCHEMAS: {
duration: { name: "Duration", selector: { duration: {} } },
addon: { name: "Addon", selector: { addon: {} } },
area: { name: "Area", selector: { area: {} } },
label: { name: "Label", selector: { label: {} } },
target: { name: "Target", selector: { target: {} } },
number_box: {
name: "Number Box",
@ -161,8 +187,8 @@ const SCHEMAS: {
},
boolean: { name: "Boolean", selector: { boolean: {} } },
time: { name: "Time", selector: { time: {} } },
date: { name: "Date", selector: { date: {} } },
datetime: { name: "Date Time", selector: { datetime: {} } },
// date: { name: "Date", selector: { date: {} } },
// datetime: { name: "Date Time", selector: { datetime: {} } },
action: { name: "Action", selector: { action: {} } },
text: {
name: "Text",
@ -279,6 +305,7 @@ const SCHEMAS: {
entity: { name: "Entity", selector: { entity: { multiple: true } } },
device: { name: "Device", selector: { device: { multiple: true } } },
area: { name: "Area", selector: { area: { multiple: true } } },
label: { name: "Label", selector: { label: { multiple: true } } },
select: {
name: "Select Multiple",
selector: {
@ -335,6 +362,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockAreaRegistry(hass, AREAS);
mockLabelRegistry(hass, LABELS);
mockHassioSupervisor(hass);
hass.mockWS("auth/sign_path", (params) => params);
hass.mockWS("media_player/browse_media", this._browseMedia);

View File

@ -198,6 +198,7 @@ const createEntityRegistryEntries = (
has_entity_name: false,
unique_id: "updater",
options: null,
labels: [],
},
];
@ -221,6 +222,7 @@ const createDeviceRegistryEntries = (
name_by_user: null,
disabled_by: null,
configuration_url: null,
labels: [],
},
];

View File

@ -0,0 +1,465 @@
import "@material/mwc-list/mwc-list-item";
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import {
LabelRegistryEntry,
createLabelRegistryEntry,
} from "../data/label_registry";
import {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
getDeviceEntityDisplayLookup,
} from "../data/device_registry";
import { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
showAlertDialog,
showPromptDialog,
} from "../dialogs/generic/show-dialog-box";
import { ValueChangedEvent, HomeAssistant } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-icon-button";
import "./ha-svg-icon";
const rowRenderer: ComboBoxLitRenderer<LabelRegistryEntry> = (
item
) => html`<mwc-list-item
class=${classMap({ "add-new": item.label_id === "add_new" })}
>
${item.name}
</mwc-list-item>`;
@customElement("ha-label-picker")
export class HaLabelPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd?: boolean;
/**
* Show only labels with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no label with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only labels with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of labels to be excluded.
* @type {Array}
* @attr exclude-labels
*/
@property({ type: Array, attribute: "exclude-labels" })
public excludeLabels?: string[];
@property() public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property() public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled?: boolean;
@property({ type: Boolean }) public required?: boolean;
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getLabels = memoizeOne(
(
labels: LabelRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeLabels: this["excludeLabels"]
): LabelRegistryEntry[] => {
if (!labels.length) {
return [
{
label_id: "no_labels",
name: this.hass.localize("ui.components.label-picker.no_labels"),
icon: null,
color: "#CCCCCC",
description: null,
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.labels);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputLabels = labels;
let labelIds: string[] | undefined;
if (inputDevices) {
labelIds = inputDevices
.filter((device) => device.labels)
.map((device) => device.labels!)
.flat(1);
}
if (inputEntities) {
labelIds = (labelIds ?? []).concat(
inputEntities
.filter((entity) => entity.labels)
.map((entity) => entity.labels!)
.flat(1)
);
}
if (labelIds) {
outputLabels = labels.filter((label) =>
labelIds!.includes(label.label_id)
);
}
if (excludeLabels) {
outputLabels = outputLabels.filter(
(label) => !excludeLabels!.includes(label.label_id)
);
}
if (!outputLabels.length) {
outputLabels = [
{
label_id: "no_labels",
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
description: null,
color: "#CCCCCC",
},
];
}
return noAdd
? outputLabels
: [
...outputLabels,
{
label_id: "add_new",
name: this.hass.localize("ui.components.label-picker.add_new"),
icon: null,
description: null,
color: "#CCCCCC",
},
];
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const labels = this._getLabels(
Object.values(this.hass.labels),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
);
(this.comboBox as any).items = labels;
(this.comboBox as any).filteredItems = labels;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="label_id"
item-id-path="label_id"
item-label-path="name"
.value=${this.value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.label")
: this.label}
.placeholder=${this.placeholder
? this.hass.labels[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._labelChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void {
const filter = ev.detail.value;
if (!filter) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = this.comboBox.items?.filter((item) =>
item.name.toLowerCase().includes(filter!.toLowerCase())
);
if (!this.noAdd && filteredItems?.length === 0) {
this._suggestion = filter;
this.comboBox.filteredItems = [
{
label_id: "add_new_suggestion",
name: this.hass.localize(
"ui.components.label-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: null,
description: null,
color: null,
},
];
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === "no_labels") {
newValue = "";
}
if (!["add_new_suggestion", "add_new"].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value;
showPromptDialog(this, {
title: this.hass.localize("ui.components.label-picker.add_dialog.title"),
text: this.hass.localize("ui.components.label-picker.add_dialog.text"),
confirmText: this.hass.localize(
"ui.components.label-picker.add_dialog.add"
),
inputLabel: this.hass.localize(
"ui.components.label-picker.add_dialog.name"
),
defaultValue:
newValue === "add_new_suggestion" ? this._suggestion : undefined,
confirm: async (name) => {
if (!name) {
return;
}
try {
const label = await createLabelRegistryEntry(this.hass, {
name,
});
const labels = [...Object.values(this.hass.labels), label];
(this.comboBox as any).filteredItems = this._getLabels(
labels,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(label.label_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.label-picker.add_dialog.failed_create_label"
),
text: err.message,
});
}
},
cancel: () => {
this._setValue(undefined);
this._suggestion = undefined;
},
});
}
private _setValue(value?: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-label-picker": HaLabelPicker;
}
}

View File

@ -0,0 +1,166 @@
import { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import type { HomeAssistant } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-label-picker";
@customElement("ha-labels-picker")
export class HaLabelsPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string[];
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd?: boolean;
/**
* Show only labels with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no label with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only label with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
@property() public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property() public entityFilter?: (entity: HassEntity) => boolean;
@property({ attribute: "picked-label-label" })
public pickedLabelLabel?: string;
@property({ attribute: "pick-label-label" })
public pickLabelLabel?: string;
@property({ type: Boolean }) public disabled?: boolean;
@property({ type: Boolean }) public required?: boolean;
protected render() {
if (!this.hass) {
return nothing;
}
const currentLabels = this._currentLabels;
return html`
${currentLabels.map(
(label) => html`
<div>
<ha-label-picker
.curValue=${label}
.noAdd=${this.noAdd}
.hass=${this.hass}
.value=${label}
.label=${this.pickedLabelLabel}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.disabled=${this.disabled}
@value-changed=${this._labelChanged}
></ha-label-picker>
</div>
`
)}
<div>
<ha-label-picker
.noAdd=${this.noAdd}
.hass=${this.hass}
.label=${this.pickLabelLabel}
.helper=${this.helper}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.disabled=${this.disabled}
.placeholder=${this.placeholder}
.required=${this.required && !currentLabels.length}
@value-changed=${this._addLabel}
></ha-label-picker>
</div>
`;
}
private get _currentLabels(): string[] {
return this.value || [];
}
private async _updateLabels(labels) {
this.value = labels;
fireEvent(this, "value-changed", {
value: labels,
});
}
private _labelChanged(ev: CustomEvent) {
ev.stopPropagation();
const curValue = (ev.currentTarget as any).curValue;
const newValue = ev.detail.value;
if (newValue === curValue) {
return;
}
const currentLabels = this._currentLabels;
if (!newValue || currentLabels.includes(newValue)) {
this._updateLabels(currentLabels.filter((ent) => ent !== curValue));
return;
}
this._updateLabels(
currentLabels.map((ent) => (ent === curValue ? newValue : ent))
);
}
private _addLabel(ev: CustomEvent) {
ev.stopPropagation();
const toAdd = ev.detail.value;
if (!toAdd) {
return;
}
(ev.currentTarget as any).value = "";
const currentLabels = this._currentLabels;
if (currentLabels.includes(toAdd)) {
return;
}
this._updateLabels([...currentLabels, toAdd]);
}
static override styles = css`
div {
margin-top: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-labels-picker": HaLabelsPicker;
}
}

View File

@ -0,0 +1,132 @@
import { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, PropertyValues, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import type { DeviceRegistryEntry } from "../../data/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device_registry";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../data/entity_sources";
import type { LabelSelector } from "../../data/selector";
import {
filterSelectorDevices,
filterSelectorEntities,
} from "../../data/selector";
import { HomeAssistant } from "../../types";
import "../ha-label-picker";
import "../ha-labels-picker";
@customElement("ha-selector-label")
export class HaLabelSelector extends LitElement {
@property() public hass!: HomeAssistant;
@property() public selector!: LabelSelector;
@property() public value?: any;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@state() private _entitySources?: EntitySources;
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
private _hasIntegration(selector: LabelSelector) {
return (
(selector.label?.entity &&
ensureArray(selector.label.entity).some(
(filter) => filter.integration
)) ||
(selector.label?.device &&
ensureArray(selector.label.device).some((device) => device.integration))
);
}
protected updated(changedProperties: PropertyValues): void {
if (
changedProperties.has("selector") &&
this._hasIntegration(this.selector) &&
!this._entitySources
) {
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
}
}
protected render() {
if (this._hasIntegration(this.selector) && !this._entitySources) {
return nothing;
}
if (!this.selector.label?.multiple) {
return html`
<ha-label-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
no-add
.deviceFilter=${this._filterDevices}
.entityFilter=${this._filterEntities}
.disabled=${this.disabled}
.required=${this.required}
></ha-label-picker>
`;
}
return html`
<ha-labels-picker
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.pickLabelLabel=${this.label}
no-add
.deviceFilter=${this._filterDevices}
.entityFilter=${this._filterEntities}
.disabled=${this.disabled}
.required=${this.required}
></ha-labels-picker>
`;
}
private _filterEntities = (entity: HassEntity): boolean => {
if (!this.selector.label?.entity) {
return true;
}
return ensureArray(this.selector.label.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
);
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if (!this.selector.label?.device) {
return true;
}
const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup(
this._entitySources,
Object.values(this.hass.entities)
)
: undefined;
return ensureArray(this.selector.label.device).some((filter) =>
filterSelectorDevices(filter, device, deviceIntegrations)
);
};
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-label": HaLabelSelector;
}
}

View File

@ -29,6 +29,7 @@ const LOAD_ELEMENTS = {
entity: () => import("./ha-selector-entity"),
statistic: () => import("./ha-selector-statistic"),
file: () => import("./ha-selector-file"),
label: () => import("./ha-selector-label"),
language: () => import("./ha-selector-language"),
navigation: () => import("./ha-selector-navigation"),
number: () => import("./ha-selector-number"),

View File

@ -19,6 +19,7 @@ import {
import {
expandAreaTarget,
expandDeviceTarget,
expandLabelTarget,
Selector,
} from "../data/selector";
import { ValueChangedEvent, HomeAssistant } from "../types";
@ -127,7 +128,8 @@ export class HaServiceControl extends LitElement {
"target" in serviceData &&
(this.value?.data?.entity_id ||
this.value?.data?.area_id ||
this.value?.data?.device_id)
this.value?.data?.device_id ||
this.value?.data?.label_id)
) {
const target = {
...this.value.target,
@ -142,6 +144,9 @@ export class HaServiceControl extends LitElement {
if (this.value.data.device_id && !this.value.target?.device_id) {
target.device_id = this.value.data.device_id;
}
if (this.value.data.label_id && !this.value.target?.label_id) {
target.label_id = this.value.data.label_id;
}
this._value = {
...this.value,
@ -152,6 +157,7 @@ export class HaServiceControl extends LitElement {
delete this._value.data!.entity_id;
delete this._value.data!.device_id;
delete this._value.data!.area_id;
delete this._value.data!.label_id;
} else {
this._value = this.value;
}
@ -263,6 +269,9 @@ export class HaServiceControl extends LitElement {
const targetAreas = ensureArray(
value?.target?.area_id || value?.data?.area_id
)?.slice();
const targetLabels = ensureArray(
value?.target?.label_id || value?.data?.label_id
)?.slice();
if (targetAreas) {
targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget(
@ -288,6 +297,19 @@ export class HaServiceControl extends LitElement {
);
});
}
if (targetLabels) {
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
this.hass,
labelId,
this.hass.devices,
this.hass.entities,
targetSelector
);
targetEntities.push(...expanded.entities);
targetDevices.push(...expanded.devices);
});
}
if (!targetEntities.length) {
return false;
}

View File

@ -6,6 +6,7 @@ import "@material/mwc-menu/mwc-menu-surface";
import {
mdiClose,
mdiDevices,
mdiLabelMultiple,
mdiPlus,
mdiSofa,
mdiUnfoldMoreVertical,
@ -34,6 +35,7 @@ import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker";
import "./ha-area-picker";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-label-picker";
import "./ha-svg-icon";
@customElement("ha-target-picker")
@ -70,7 +72,11 @@ export class HaTargetPicker extends LitElement {
@property({ type: Boolean }) public addOnTop = false;
@state() private _addMode?: "area_id" | "entity_id" | "device_id";
@state() private _addMode?:
| "area_id"
| "entity_id"
| "device_id"
| "label_id";
@query("#input") private _inputElement?;
@ -123,6 +129,18 @@ export class HaTargetPicker extends LitElement {
);
})
: ""}
${this.value?.label_id
? ensureArray(this.value.label_id).map((labelId) => {
const label = this.hass.labels![labelId];
return this._renderChip(
"label_id",
labelId,
label?.name || labelId,
undefined,
mdiLabelMultiple
);
})
: ""}
</div>
`;
}
@ -190,6 +208,26 @@ export class HaTargetPicker extends LitElement {
</span>
</span>
</div>
<div
class="mdc-chip label_id add"
.type=${"label_id"}
@click=${this._showPicker}
>
<div class="mdc-chip__ripple"></div>
<ha-svg-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${mdiPlus}
></ha-svg-icon>
<span role="gridcell">
<span role="button" tabindex="0" class="mdc-chip__primary-action">
<span class="mdc-chip__text"
>${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}</span
>
</span>
</span>
</div>
${this._renderPicker()}
</div>
${this.helper
@ -203,7 +241,7 @@ export class HaTargetPicker extends LitElement {
}
private _renderChip(
type: "area_id" | "device_id" | "entity_id",
type: "area_id" | "device_id" | "entity_id" | "label_id",
id: string,
name: string,
entityState?: HassEntity,
@ -320,7 +358,8 @@ export class HaTargetPicker extends LitElement {
@click=${this._preventDefault}
></ha-device-picker>
`
: html`
: this._addMode === "entity_id"
? html`
<ha-entity-picker
.hass=${this.hass}
id="input"
@ -336,6 +375,24 @@ export class HaTargetPicker extends LitElement {
@click=${this._preventDefault}
allow-custom-entity
></ha-entity-picker>
`
: html`
<ha-label-picker
.hass=${this.hass}
id="input"
.type=${"label_id"}
.label=${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}
no-add
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeLabels=${ensureArray(this.value?.label_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
></ha-label-picker>
`}</mwc-menu-surface
>`;
}
@ -405,6 +462,25 @@ export class HaTargetPicker extends LitElement {
newEntities.push(entity.entity_id);
}
});
} else if (target.type === "label_id") {
Object.values(this.hass.devices).forEach((device) => {
if (
device.labels.includes(target.id) &&
!this.value!.device_id?.includes(device.id) &&
this._deviceMeetsFilter(device)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.labels!.includes(target.id) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
this._entityRegMeetsFilter(entity)
) {
newEntities.push(entity.entity_id);
}
});
} else {
return;
}
@ -662,6 +738,14 @@ export class HaTargetPicker extends LitElement {
.mdc-chip.entity_id.add {
background: #d2e7b9;
}
.mdc-chip.label_id:not(.add) {
border: 2px solid #eeefff;
background: var(--card-background-color);
}
.mdc-chip.label_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.label_id.add {
background: #eeefff;
}
.mdc-chip:hover {
z-index: 5;
}

View File

@ -41,6 +41,7 @@ const targetStruct = object({
entity_id: optional(union([string(), array(string())])),
device_id: optional(union([string(), array(string())])),
area_id: optional(union([string(), array(string())])),
label_id: optional(union([string(), array(string())])),
});
export const serviceActionStruct: Describe<ServiceAction> = assign(

View File

@ -85,6 +85,7 @@ const tryDescribeAction = <T extends ActionType>(
area_id: "areas",
device_id: "devices",
entity_id: "entities",
label_id: "labels",
})) {
if (!(key in config.target)) {
continue;
@ -146,6 +147,13 @@ const tryDescribeAction = <T extends ActionType>(
)
);
}
} else if (key === "label_id") {
const label_ = hass.labels[targetThing];
if (label_?.name) {
targets.push(label_.name);
} else {
targets.push("unknown label");
}
} else {
targets.push(targetThing);
}

View File

@ -33,6 +33,7 @@ export type Selector =
| LegacyEntitySelector
| FileSelector
| IconSelector
| LabelSelector
| LanguageSelector
| LocationSelector
| MediaSelector
@ -157,6 +158,14 @@ export interface DeviceSelector {
} | null;
}
export interface LabelSelector {
label: {
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[];
multiple?: boolean;
} | null;
}
export interface LegacyDeviceSelector {
device: DeviceSelector["device"] & {
/**
@ -463,6 +472,45 @@ export const expandDeviceTarget = (
return { entities: newEntities };
};
export const expandLabelTarget = (
hass: HomeAssistant,
labelId: string,
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"],
targetSelector: TargetSelector,
entitySources?: EntitySources
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
Object.values(devices).forEach((device) => {
if (
device.labels.includes(labelId) &&
deviceMeetsTargetSelector(
hass,
Object.values(entities),
device,
targetSelector,
entitySources
)
) {
newDevices.push(device.id);
}
});
Object.values(entities).forEach((entity) => {
if (
entity.labels!.includes(labelId) &&
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
)
) {
newEntities.push(entity.entity_id);
}
});
return { devices: newDevices, entities: newEntities };
};
const deviceMeetsTargetSelector = (
hass: HomeAssistant,
entityRegistry: EntityRegistryDisplayEntry[],

View File

@ -347,6 +347,7 @@ export const provideHass = (
areas: {},
devices: {},
entities: {},
labels: {},
formatEntityState: (stateObj, state) =>
(state !== null ? state : stateObj.state) ?? "",
formatEntityAttributeName: (_stateObj, attribute) => attribute,

View File

@ -404,13 +404,16 @@
"expand": "Expand",
"expand_area_id": "Split this area into separate devices and entities.",
"expand_device_id": "Split this device into separate entities.",
"expand_label_id": "Split this label into separate entities.",
"remove": "Remove",
"remove_area_id": "Remove area",
"remove_device_id": "Remove device",
"remove_entity_id": "Remove entity",
"remove_label_id": "Remove label",
"add_area_id": "Choose area",
"add_device_id": "Choose device",
"add_entity_id": "Choose entity"
"add_entity_id": "Choose entity",
"add_label_id": "Choose label"
},
"config-entry-picker": {
"config_entry": "Integration"
@ -476,6 +479,22 @@
"failed_create_area": "Failed to create area."
}
},
"label-picker": {
"clear": "Clear",
"show_labels": "Show labels",
"label": "Label",
"add_new_sugestion": "Add new label ''{name}''",
"add_new": "Add new label…",
"no_labels": "You don't have any labels",
"no_match": "No matching labels found",
"add_dialog": {
"title": "Add new label",
"text": "Enter the name of the new label.",
"name": "Name",
"add": "Add",
"failed_create_label": "Failed to create label."
}
},
"statistic-picker": {
"statistic": "Statistic",
"no_statistics": "You don't have any statistics",
@ -574,7 +593,7 @@
"service-control": {
"required": "This field is required",
"target": "Targets",
"target_description": "What should this service use as targeted areas, devices or entities.",
"target_description": "What should this service use as targeted areas, devices, entities or labels.",
"data": "Service data",
"integration_doc": "Integration documentation"
},