diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index 4b4d919d2b..239f9b1c28 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -72,6 +72,7 @@ export class HaDemo extends HomeAssistantAppEl { id: "sensor.co2_intensity", name: null, icon: null, + labels: [], platform: "co2signal", hidden_by: null, entity_category: null, @@ -88,6 +89,7 @@ export class HaDemo extends HomeAssistantAppEl { id: "sensor.co2_intensity", name: null, icon: null, + labels: [], platform: "co2signal", hidden_by: null, entity_category: null, diff --git a/gallery/src/pages/components/ha-form.ts b/gallery/src/pages/components/ha-form.ts index c4172d2600..0e4c9019f6 100644 --- a/gallery/src/pages/components/ha-form.ts +++ b/gallery/src/pages/components/ha-form.ts @@ -59,6 +59,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, { area_id: "backyard", @@ -77,6 +78,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, { area_id: null, @@ -95,6 +97,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, ]; @@ -106,6 +109,7 @@ const AREAS: AreaRegistryEntry[] = [ icon: null, picture: null, aliases: [], + labels: [], }, { area_id: "bedroom", @@ -114,6 +118,7 @@ const AREAS: AreaRegistryEntry[] = [ icon: "mdi:bed", picture: null, aliases: [], + labels: [], }, { area_id: "livingroom", @@ -122,6 +127,7 @@ const AREAS: AreaRegistryEntry[] = [ icon: "mdi:sofa", picture: null, aliases: [], + labels: [], }, ]; diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 91fa1afd84..15e2aae1ad 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -55,6 +55,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, { area_id: "backyard", @@ -73,6 +74,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, { area_id: null, @@ -91,6 +93,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, ]; @@ -102,6 +105,7 @@ const AREAS: AreaRegistryEntry[] = [ icon: null, picture: null, aliases: [], + labels: [], }, { area_id: "bedroom", @@ -110,6 +114,7 @@ const AREAS: AreaRegistryEntry[] = [ icon: "mdi:bed", picture: null, aliases: [], + labels: [], }, { area_id: "livingroom", @@ -118,6 +123,7 @@ const AREAS: AreaRegistryEntry[] = [ icon: "mdi:sofa", picture: null, aliases: [], + labels: [], }, ]; diff --git a/gallery/src/pages/misc/entity-state.ts b/gallery/src/pages/misc/entity-state.ts index e454553a4b..242264b5da 100644 --- a/gallery/src/pages/misc/entity-state.ts +++ b/gallery/src/pages/misc/entity-state.ts @@ -406,6 +406,7 @@ export class DemoEntityState extends LitElement { entity_id: "select.speed", translation_key: "speed", platform: "demo", + labels: [], }, }, }); diff --git a/gallery/src/pages/misc/integration-card.ts b/gallery/src/pages/misc/integration-card.ts index c2b2f2c24d..94d79dc434 100644 --- a/gallery/src/pages/misc/integration-card.ts +++ b/gallery/src/pages/misc/integration-card.ts @@ -199,6 +199,7 @@ const createEntityRegistryEntries = ( has_entity_name: false, unique_id: "updater", options: null, + labels: [], }, ]; @@ -222,6 +223,7 @@ const createDeviceRegistryEntries = ( name_by_user: null, disabled_by: null, configuration_url: null, + labels: [], }, ]; diff --git a/src/components/chips/ha-input-chip.ts b/src/components/chips/ha-input-chip.ts index 4c2311a64d..f32d89848b 100644 --- a/src/components/chips/ha-input-chip.ts +++ b/src/components/chips/ha-input-chip.ts @@ -19,12 +19,16 @@ export class HaInputChip extends MdInputChip { var(--rgb-primary-text-color), 0.15 ); + --ha-input-chip-selected-container-opacity: 1; } /** Set the size of mdc icons **/ ::slotted([slot="icon"]) { display: flex; --mdc-icon-size: var(--md-input-chip-icon-size, 18px); } + .selected::before { + opacity: var(--ha-input-chip-selected-container-opacity); + } `, ]; } diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 2cfa3596d1..3f6301060c 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -142,6 +142,7 @@ export class HaAreaPicker extends LitElement { picture: null, icon: null, aliases: [], + labels: [], }, ]; } @@ -288,6 +289,7 @@ export class HaAreaPicker extends LitElement { picture: null, icon: null, aliases: [], + labels: [], }, ]; } @@ -303,6 +305,7 @@ export class HaAreaPicker extends LitElement { picture: null, icon: "mdi:plus", aliases: [], + labels: [], }, ]; } diff --git a/src/panels/lovelace/components/hui-color-picker.ts b/src/components/ha-color-picker.ts similarity index 72% rename from src/panels/lovelace/components/hui-color-picker.ts rename to src/components/ha-color-picker.ts index 8417e9dbd5..f5af1d76a5 100644 --- a/src/panels/lovelace/components/hui-color-picker.ts +++ b/src/components/ha-color-picker.ts @@ -2,17 +2,15 @@ import "@material/mwc-list/mwc-list-item"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; -import { - computeCssColor, - THEME_COLORS, -} from "../../../common/color/compute-color"; -import { fireEvent } from "../../../common/dom/fire_event"; -import { stopPropagation } from "../../../common/dom/stop_propagation"; -import "../../../components/ha-select"; -import { HomeAssistant } from "../../../types"; +import { computeCssColor, THEME_COLORS } from "../common/color/compute-color"; +import { fireEvent } from "../common/dom/fire_event"; +import { stopPropagation } from "../common/dom/stop_propagation"; +import "./ha-select"; +import { HomeAssistant } from "../types"; +import { LocalizeKeys } from "../common/translations/localize"; -@customElement("hui-color-picker") -export class HuiColorPicker extends LitElement { +@customElement("ha-color-picker") +export class HaColorPicker extends LitElement { @property() public label?: string; @property() public helper?: string; @@ -21,6 +19,8 @@ export class HuiColorPicker extends LitElement { @property() public value?: string; + @property({ type: Boolean }) public defaultColor = false; + @property({ type: Boolean }) public disabled = false; _valueSelected(ev) { @@ -52,16 +52,16 @@ export class HuiColorPicker extends LitElement { ` : nothing} - - ${this.hass.localize( - `ui.panel.lovelace.editor.color-picker.default_color` - )} - + ${this.defaultColor + ? html` + ${this.hass.localize(`ui.components.color-picker.default_color`)} + ` + : nothing} ${Array.from(THEME_COLORS).map( (color) => html` ${this.hass.localize( - `ui.panel.lovelace.editor.color-picker.colors.${color}` + `ui.components.color-picker.colors.${color}` as LocalizeKeys ) || color} ${this.renderColorCircle(color)} @@ -100,6 +100,6 @@ export class HuiColorPicker extends LitElement { declare global { interface HTMLElementTagNameMap { - "hui-color-picker": HuiColorPicker; + "ha-color-picker": HaColorPicker; } } diff --git a/src/components/ha-label-picker.ts b/src/components/ha-label-picker.ts new file mode 100644 index 0000000000..222f660162 --- /dev/null +++ b/src/components/ha-label-picker.ts @@ -0,0 +1,484 @@ +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { LitElement, PropertyValues, TemplateResult, html, nothing } 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 { + ScorableTextItem, + fuzzyFilterSort, +} from "../common/string/filter/sequence-matching"; +import { + DeviceEntityDisplayLookup, + DeviceRegistryEntry, + getDeviceEntityDisplayLookup, +} from "../data/device_registry"; +import { EntityRegistryDisplayEntry } from "../data/entity_registry"; +import { + LabelRegistryEntry, + createLabelRegistryEntry, + subscribeLabelRegistry, +} from "../data/label_registry"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; +import { HomeAssistant, ValueChangedEvent } 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-list-item"; +import "./ha-svg-icon"; + +type ScorableLabelRegistryEntry = ScorableTextItem & LabelRegistryEntry; + +const rowRenderer: ComboBoxLitRenderer = (item) => + html` + ${item.icon + ? html`` + : nothing} + ${item.name} + `; + +@customElement("ha-label-picker") +export class HaLabelPicker 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 = false; + + /** + * Show only labels with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show no labels 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-label" }) + public excludeLabels?: string[]; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: (entity: HassEntity) => boolean; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @state() private _opened?: boolean; + + @state() private _labels?: LabelRegistryEntry[]; + + @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(); + } + + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeLabelRegistry(this.hass.connection, (labels) => { + this._labels = labels; + }), + ]; + } + + private _getLabels = memoizeOne( + ( + labels: LabelRegistryEntry[], + areas: HomeAssistant["areas"], + 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: 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.length > 0); + + 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; + const usedLabels = new Set(); + + let areaIds: string[] | undefined; + + if (inputDevices) { + areaIds = inputDevices + .filter((device) => device.area_id) + .map((device) => device.area_id!); + + inputDevices.forEach((device) => { + device.labels.forEach((label) => usedLabels.add(label)); + }); + } + + if (inputEntities) { + areaIds = (areaIds ?? []).concat( + inputEntities + .filter((entity) => entity.area_id) + .map((entity) => entity.area_id!) + ); + inputEntities.forEach((entity) => { + entity.labels.forEach((label) => usedLabels.add(label)); + }); + } + + if (areaIds) { + areaIds.forEach((areaId) => { + const area = areas[areaId]; + area.labels.forEach((label) => usedLabels.add(label)); + }); + } + + if (excludeLabels) { + outputLabels = outputLabels.filter( + (label) => !excludeLabels!.includes(label.label_id) + ); + } + + if (inputDevices || inputEntities) { + outputLabels = outputLabels.filter((label) => + usedLabels.has(label.label_id) + ); + } + + if (!outputLabels.length) { + outputLabels = [ + { + label_id: "no_labels", + name: this.hass.localize("ui.components.label-picker.no_match"), + icon: null, + color: null, + }, + ]; + } + + return noAdd + ? outputLabels + : [ + ...outputLabels, + { + label_id: "add_new", + name: this.hass.localize("ui.components.label-picker.add_new"), + icon: "mdi:plus", + color: null, + }, + ]; + } + ); + + protected updated(changedProps: PropertyValues) { + if ( + (!this._init && this.hass && this._labels) || + (this._init && changedProps.has("_opened") && this._opened) + ) { + this._init = true; + const labels = this._getLabels( + this._labels!, + this.hass.areas, + Object.values(this.hass.devices), + Object.values(this.hass.entities), + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.noAdd, + this.excludeLabels + ).map((label) => ({ + ...label, + strings: [label.label_id, label.name], + })); + + this.comboBox.items = labels; + this.comboBox.filteredItems = labels; + } + } + + protected render(): TemplateResult { + return html` + label.label_id === this.placeholder) + ?.name + : undefined} + .renderer=${rowRenderer} + @filter-changed=${this._filterChanged} + @opened-changed=${this._openedChanged} + @value-changed=${this._labelChanged} + > + + `; + } + + private _filterChanged(ev: CustomEvent): void { + const target = ev.target as HaComboBox; + const filterString = ev.detail.value; + if (!filterString) { + this.comboBox.filteredItems = this.comboBox.items; + return; + } + + const filteredItems = fuzzyFilterSort( + filterString, + target.items || [] + ); + if (!this.noAdd && filteredItems?.length === 0) { + this._suggestion = filterString; + this.comboBox.filteredItems = [ + { + label_id: "add_new_suggestion", + name: this.hass.localize( + "ui.components.label-picker.add_new_sugestion", + { name: this._suggestion } + ), + picture: null, + }, + ]; + } else { + this.comboBox.filteredItems = filteredItems; + } + } + + private get _value() { + return this.value || ""; + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + } + + private _labelChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + let newValue = ev.detail.value; + + if (newValue === "no_labels") { + newValue = ""; + this.comboBox.setInputValue(""); + return; + } + + if (!["add_new_suggestion", "add_new"].includes(newValue)) { + if (newValue !== this._value) { + this._setValue(newValue); + } + return; + } + + (ev.target as any).value = this._value; + + showLabelDetailDialog(this, { + entry: undefined, + suggestedName: newValue === "add_new_suggestion" ? this._suggestion : "", + createEntry: async (values) => { + const label = await createLabelRegistryEntry(this.hass, values); + const labels = [...this._labels!, label]; + this.comboBox.filteredItems = this._getLabels( + labels, + this.hass.areas!, + 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); + return label; + }, + }); + + this._suggestion = undefined; + this.comboBox.setInputValue(""); + } + + 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; + } +} diff --git a/src/components/ha-labels-picker.ts b/src/components/ha-labels-picker.ts new file mode 100644 index 0000000000..3010c69b0e --- /dev/null +++ b/src/components/ha-labels-picker.ts @@ -0,0 +1,213 @@ +import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { LitElement, TemplateResult, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import { computeCssColor } from "../common/color/compute-color"; +import { fireEvent } from "../common/dom/fire_event"; +import { + LabelRegistryEntry, + subscribeLabelRegistry, + updateLabelRegistryEntry, +} from "../data/label_registry"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; +import { HomeAssistant, ValueChangedEvent } from "../types"; +import "./chips/ha-chip-set"; +import "./chips/ha-input-chip"; +import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; +import "./ha-label-picker"; +import type { HaLabelPicker } from "./ha-label-picker"; + +@customElement("ha-labels-picker") +export class HaLabelsPicker extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property({ attribute: false }) public value?: string[]; + + @property() public helper?: string; + + @property() public placeholder?: string; + + @property({ type: Boolean, attribute: "no-add" }) + public noAdd = false; + + /** + * Show only labels with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show no labels 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-label" }) + public excludeLabels?: string[]; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: (entity: HassEntity) => boolean; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @state() private _labels?: LabelRegistryEntry[]; + + @query("ha-label-picker", true) public labelPicker!: HaLabelPicker; + + public async open() { + await this.updateComplete; + await this.labelPicker?.open(); + } + + public async focus() { + await this.updateComplete; + await this.labelPicker?.focus(); + } + + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeLabelRegistry(this.hass.connection, (labels) => { + this._labels = labels; + }), + ]; + } + + protected render(): TemplateResult { + return html` + ${this.value?.length + ? html` + ${repeat( + this.value, + (item) => item, + (item, idx) => { + const label = this._labels?.find( + (lbl) => lbl.label_id === item + ); + const color = label?.color + ? computeCssColor(label.color) + : undefined; + return html` + + ${label?.icon + ? html`` + : nothing} + + `; + } + )} + ` + : nothing} + + + `; + } + + private get _value() { + return this.value || []; + } + + private _removeItem(ev) { + this._value.splice(ev.target.idx, 1); + this._setValue([...this._value]); + } + + private _openDetail(ev) { + const label = ev.target.item; + showLabelDetailDialog(this, { + entry: label, + updateEntry: async (values) => { + const updated = await updateLabelRegistryEntry( + this.hass, + label.label_id, + values + ); + this._labels = this._labels!.map((lbl) => + lbl.label_id === updated.label_id ? updated : lbl + ); + return updated; + }, + }); + } + + private _labelChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + const newValue = ev.detail.value; + if (!newValue || this._value.includes(newValue)) { + return; + } + this._setValue([...this._value, newValue]); + this.labelPicker.value = ""; + } + + private _setValue(value?: string[]) { + this.value = value; + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); + }, 0); + } + + static styles = css` + ha-chip-set { + margin-bottom: 8px; + } + ha-input-chip { + border: 1px solid var(--color); + --md-input-chip-selected-container-color: var(--color); + --ha-input-chip-selected-container-opacity: 0.3; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-labels-picker": HaLabelsPicker; + } +} diff --git a/src/components/ha-selector/ha-selector-label.ts b/src/components/ha-selector/ha-selector-label.ts new file mode 100644 index 0000000000..14e90e8f7d --- /dev/null +++ b/src/components/ha-selector/ha-selector-label.ts @@ -0,0 +1,83 @@ +import { CSSResultGroup, LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import { ensureArray } from "../../common/array/ensure-array"; +import { fireEvent } from "../../common/dom/fire_event"; +import { LabelSelector } from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../ha-labels-picker"; + +@customElement("ha-selector-label") +export class HaLabelSelector extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property() public value?: string | string[]; + + @property() public name?: string; + + @property() public label?: string; + + @property() public placeholder?: string; + + @property() public helper?: string; + + @property({ attribute: false }) public selector!: LabelSelector; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + protected render() { + if (this.selector.label.multiple) { + return html` + + + `; + } + return html` + + + `; + } + + private _handleChange(ev) { + let value = ev.detail.value; + if (this.value === value) { + return; + } + if ( + (value === "" || (Array.isArray(value) && value.length === 0)) && + !this.required + ) { + value = undefined; + } + + fireEvent(this, "value-changed", { value }); + } + + static get styles(): CSSResultGroup { + return css` + ha-labels-picker { + display: block; + width: 100%; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-label": HaLabelSelector; + } +} diff --git a/src/components/ha-selector/ha-selector-ui-color.ts b/src/components/ha-selector/ha-selector-ui-color.ts index 291977ace0..56379f7fef 100644 --- a/src/components/ha-selector/ha-selector-ui-color.ts +++ b/src/components/ha-selector/ha-selector-ui-color.ts @@ -2,7 +2,7 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import { UiColorSelector } from "../../data/selector"; -import "../../panels/lovelace/components/hui-color-picker"; +import "../ha-color-picker"; import { HomeAssistant } from "../../types"; @customElement("ha-selector-ui_color") @@ -19,13 +19,14 @@ export class HaSelectorUiColor extends LitElement { protected render() { return html` - + > `; } diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index e7f9613f5f..5ab02782c1 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -30,6 +30,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"), diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index e54423b9b6..8310aee8f4 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -31,6 +31,7 @@ import { expandAreaTarget, expandDeviceTarget, expandFloorTarget, + expandLabelTarget, Selector, } from "../data/selector"; import { HomeAssistant, ValueChangedEvent } from "../types"; @@ -274,6 +275,24 @@ export class HaServiceControl extends LitElement { const targetFloors = ensureArray( value?.target?.floor_id || value?.data?.floor_id )?.slice(); + const targetLabels = ensureArray( + value?.target?.label_id || value?.data?.label_id + )?.slice(); + if (targetLabels) { + targetLabels.forEach((labelId) => { + const expanded = expandLabelTarget( + this.hass, + labelId, + this.hass.areas, + this.hass.devices, + this.hass.entities, + targetSelector + ); + targetDevices.push(...expanded.devices); + targetEntities.push(...expanded.entities); + targetAreas.push(...expanded.areas); + }); + } if (targetFloors) { targetFloors.forEach((floorId) => { const expanded = expandFloorTarget( diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index d2e8f99c1e..70478dbc40 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -7,6 +7,7 @@ import { mdiClose, mdiDevices, mdiFloorPlan, + mdiLabel, mdiPlus, mdiSofa, mdiUnfoldMoreVertical, @@ -45,7 +46,13 @@ import { FloorRegistryEntry, subscribeFloorRegistry, } from "../data/floor_registry"; +import { + LabelRegistryEntry, + subscribeLabelRegistry, +} from "../data/label_registry"; +import { computeCssColor } from "../common/color/compute-color"; import { AreaRegistryEntry } from "../data/area_registry"; +import { hex2rgb } from "../common/color/convert-color"; @customElement("ha-target-picker") export class HaTargetPicker extends SubscribeMixin(LitElement) { @@ -83,7 +90,11 @@ export class HaTargetPicker extends SubscribeMixin(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?; @@ -91,6 +102,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { @state() private _floors?: FloorRegistryEntry[]; + @state() private _labels?: LabelRegistryEntry[]; + private _opened = false; protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { @@ -98,6 +111,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { subscribeFloorRegistry(this.hass.connection, (floors) => { this._floors = floors; }), + subscribeLabelRegistry(this.hass.connection, (labels) => { + this._labels = labels; + }), ]; } @@ -138,7 +154,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { mdiSofa ); }) - : ""} + : nothing} ${this.value?.device_id ? ensureArray(this.value.device_id).map((device_id) => { const device = this.hass.devices![device_id]; @@ -151,7 +167,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { mdiDevices ); }) - : ""} + : nothing} ${this.value?.entity_id ? ensureArray(this.value.entity_id).map((entity_id) => { const entity = this.hass.states[entity_id]; @@ -162,7 +178,35 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { entity ); }) - : ""} + : nothing} + ${this.value?.label_id + ? ensureArray(this.value.label_id).map((label_id) => { + const label = this._labels?.find( + (lbl) => lbl.label_id === label_id + ); + let color = label?.color + ? computeCssColor(label.color) + : undefined; + if (color?.startsWith("var(")) { + const computedStyles = getComputedStyle(this); + color = computedStyles.getPropertyValue( + color.substring(4, color.length - 1) + ); + } + if (color?.startsWith("#")) { + color = hex2rgb(color).join(","); + } + return this._renderChip( + "label_id", + label_id, + label ? label.name : label_id, + undefined, + label?.icon, + mdiLabel, + color + ); + }) + : nothing} `; } @@ -230,6 +274,26 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { +
+
+ + + + ${this.hass.localize( + "ui.components.target-picker.add_label_id" + )} + + +
${this._renderPicker()} ${this.helper @@ -243,18 +307,22 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { } private _renderChip( - type: "floor_id" | "area_id" | "device_id" | "entity_id", + type: "floor_id" | "area_id" | "device_id" | "entity_id" | "label_id", id: string, name: string, entityState?: HassEntity, icon?: string | null, - fallbackIconPath?: string + fallbackIconPath?: string, + color?: string ) { return html`
${icon ? html` ` - : html` - - `} + ` + : html` + + `}`; } @@ -471,6 +558,34 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { newEntities.push(entity.entity_id); } }); + } else if (target.type === "label_id") { + Object.values(this.hass.areas).forEach((area) => { + if ( + area.labels.includes(target.id) && + !this.value!.area_id?.includes(area.area_id) && + this._areaMeetsFilter(area) + ) { + newAreas.push(area.area_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; } @@ -578,39 +693,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { (entity) => entity.device_id === device.id ); - if (this.includeDomains) { - if (!devEntities || !devEntities.length) { - return false; - } - if ( - !devEntities.some((entity) => - this.includeDomains!.includes(computeDomain(entity.entity_id)) - ) - ) { - return false; - } - } - - if (this.includeDeviceClasses) { - if (!devEntities || !devEntities.length) { - return false; - } - if ( - !devEntities.some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return ( - stateObj.attributes.device_class && - this.includeDeviceClasses!.includes( - stateObj.attributes.device_class - ) - ); - }) - ) { - return false; - } + if (!devEntities.some((entity) => this._entityRegMeetsFilter(entity))) { + return false; } if (this.deviceFilter) { @@ -619,19 +703,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { } } - if (this.entityFilter) { - if ( - !devEntities.some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return this.entityFilter!(stateObj); - }) - ) { - return false; - } - } return true; } @@ -719,8 +790,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { --mdc-icon-size: 20px; border-radius: 50%; padding: 6px; - margin-left: -14px !important; - margin-inline-start: -14px !important; + margin-left: -13px !important; + margin-inline-start: -13px !important; margin-inline-end: 4px !important; direction: var(--direction); } @@ -731,7 +802,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { } .mdc-chip.area_id:not(.add), .mdc-chip.floor_id:not(.add) { - border: 2px solid #fed6a4; + border: 1px solid #fed6a4; background: var(--card-background-color); } .mdc-chip.area_id:not(.add) .mdc-chip__icon--leading, @@ -741,7 +812,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { background: #fed6a4; } .mdc-chip.device_id:not(.add) { - border: 2px solid #a8e1fb; + border: 1px solid #a8e1fb; background: var(--card-background-color); } .mdc-chip.device_id:not(.add) .mdc-chip__icon--leading, @@ -749,13 +820,21 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { background: #a8e1fb; } .mdc-chip.entity_id:not(.add) { - border: 2px solid #d2e7b9; + border: 1px solid #d2e7b9; background: var(--card-background-color); } .mdc-chip.entity_id:not(.add) .mdc-chip__icon--leading, .mdc-chip.entity_id.add { background: #d2e7b9; } + .mdc-chip.label_id:not(.add) { + border: 1px solid var(--color, #e0e0e0); + background: var(--card-background-color); + } + .mdc-chip.label_id:not(.add) .mdc-chip__icon--leading, + .mdc-chip.label_id.add { + background: var(--background-color, #e0e0e0); + } .mdc-chip:hover { z-index: 5; } diff --git a/src/data/area_registry.ts b/src/data/area_registry.ts index 69af2ec7b2..46e16b50cc 100644 --- a/src/data/area_registry.ts +++ b/src/data/area_registry.ts @@ -11,6 +11,7 @@ export interface AreaRegistryEntry { name: string; picture: string | null; icon: string | null; + labels: string[]; aliases: string[]; } @@ -28,6 +29,7 @@ export interface AreaRegistryEntryMutableParams { picture?: string | null; icon?: string | null; aliases?: string[]; + labels?: string[]; } export const createAreaRegistryEntry = ( diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 0127bdf7d3..80e9a5b053 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -20,6 +20,7 @@ export interface DeviceRegistryEntry { manufacturer: string | null; model: string | null; name: string | null; + labels: string[]; sw_version: string | null; hw_version: string | null; serial_number: string | null; @@ -43,6 +44,7 @@ export interface DeviceRegistryEntryMutableParams { area_id?: string | null; name_by_user?: string | null; disabled_by?: string | null; + labels?: string[]; } export const fallbackDeviceName = ( @@ -140,7 +142,7 @@ export const getDeviceEntityDisplayLookup = ( export const getDeviceIntegrationLookup = ( entitySources: EntitySources, - entities: EntityRegistryDisplayEntry[] + entities: EntityRegistryDisplayEntry[] | EntityRegistryEntry[] ): Record => { const deviceIntegrations: Record = {}; diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index 5b015d9adb..acb05b2d47 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -18,6 +18,7 @@ export interface EntityRegistryDisplayEntry { icon?: string; device_id?: string; area_id?: string; + labels: string[]; hidden?: boolean; entity_category?: entityCategory; translation_key?: string; @@ -30,6 +31,7 @@ export interface EntityRegistryDisplayEntryResponse { ei: string; di?: string; ai?: string; + lb: string[]; ec?: number; en?: string; ic?: string; @@ -50,6 +52,7 @@ export interface EntityRegistryEntry { config_entry_id: string | null; device_id: string | null; area_id: string | null; + labels: string[]; disabled_by: "user" | "device" | "integration" | "config_entry" | null; hidden_by: Exclude; entity_category: entityCategory | null; @@ -133,6 +136,7 @@ export interface EntityRegistryEntryUpdateParams { | WeatherEntityOptions | LightEntityOptions; aliases?: string[]; + labels?: string[]; } const batteryPriorities = ["sensor", "binary_sensor"]; diff --git a/src/data/label_registry.ts b/src/data/label_registry.ts new file mode 100644 index 0000000000..ca3512cd93 --- /dev/null +++ b/src/data/label_registry.ts @@ -0,0 +1,86 @@ +import { Connection, createCollection } from "home-assistant-js-websocket"; +import { Store } from "home-assistant-js-websocket/dist/store"; +import { stringCompare } from "../common/string/compare"; +import { HomeAssistant } from "../types"; +import { debounce } from "../common/util/debounce"; + +export interface LabelRegistryEntry { + label_id: string; + name: string; + icon: string | null; + color: string | null; +} + +export interface LabelRegistryEntryMutableParams { + name: string; + icon?: string | null; + color?: string | null; +} + +export const fetchLabelRegistry = (conn: Connection) => + conn + .sendMessagePromise({ + type: "config/label_registry/list", + }) + .then((labels) => + (labels as LabelRegistryEntry[]).sort((ent1, ent2) => + stringCompare(ent1.name, ent2.name) + ) + ); + +export const subscribeLabelRegistryUpdates = ( + conn: Connection, + store: Store +) => + conn.subscribeEvents( + debounce( + () => + fetchLabelRegistry(conn).then((labels: LabelRegistryEntry[]) => + store.setState(labels, true) + ), + 500, + true + ), + "label_registry_updated" + ); + +export const subscribeLabelRegistry = ( + conn: Connection, + onChange: (labels: LabelRegistryEntry[]) => void +) => + createCollection( + "_labelRegistry", + fetchLabelRegistry, + subscribeLabelRegistryUpdates, + conn, + onChange + ); + +export const createLabelRegistryEntry = ( + hass: HomeAssistant, + values: LabelRegistryEntryMutableParams +) => + hass.callWS({ + type: "config/label_registry/create", + ...values, + }); + +export const updateLabelRegistryEntry = ( + hass: HomeAssistant, + labelId: string, + updates: Partial +) => + hass.callWS({ + type: "config/label_registry/update", + label_id: labelId, + ...updates, + }); + +export const deleteLabelRegistryEntry = ( + hass: HomeAssistant, + labelId: string +) => + hass.callWS({ + type: "config/label_registry/delete", + label_id: labelId, + }); diff --git a/src/data/selector.ts b/src/data/selector.ts index 77be0fd1d0..442fba220f 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -8,7 +8,10 @@ import { DeviceRegistryEntry, getDeviceIntegrationLookup, } from "./device_registry"; -import { EntityRegistryDisplayEntry } from "./entity_registry"; +import { + EntityRegistryDisplayEntry, + EntityRegistryEntry, +} from "./entity_registry"; import { EntitySources } from "./entity_sources"; export type Selector = @@ -34,6 +37,7 @@ export type Selector = | LegacyEntitySelector | FileSelector | IconSelector + | LabelSelector | LanguageSelector | LocationSelector | MediaSelector @@ -242,6 +246,12 @@ export interface IconSelector { } | null; } +export interface LabelSelector { + label: { + multiple?: boolean; + }; +} + export interface LanguageSelector { language: { languages?: string[]; @@ -421,9 +431,69 @@ export interface UiActionSelector { export interface UiColorSelector { // eslint-disable-next-line @typescript-eslint/ban-types - ui_color: {} | null; + ui_color: { default_color?: boolean } | null; } +export const expandLabelTarget = ( + hass: HomeAssistant, + labelId: string, + areas: HomeAssistant["areas"], + devices: HomeAssistant["devices"], + entities: HomeAssistant["entities"], + targetSelector: TargetSelector, + entitySources?: EntitySources +) => { + const newEntities: string[] = []; + const newDevices: string[] = []; + const newAreas: string[] = []; + + Object.values(areas).forEach((area) => { + if ( + area.labels.includes(labelId) && + areaMeetsTargetSelector( + hass, + entities, + devices, + area.area_id, + targetSelector, + entitySources + ) + ) { + newAreas.push(area.area_id); + } + }); + + 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 { areas: newAreas, devices: newDevices, entities: newEntities }; +}; + export const expandFloorTarget = ( hass: HomeAssistant, floorId: string, @@ -555,7 +625,7 @@ export const areaMeetsTargetSelector = ( export const deviceMeetsTargetSelector = ( hass: HomeAssistant, - entityRegistry: EntityRegistryDisplayEntry[], + entityRegistry: EntityRegistryDisplayEntry[] | EntityRegistryEntry[], device: DeviceRegistryEntry, targetSelector: TargetSelector, entitySources?: EntitySources diff --git a/src/panels/config/areas/dialog-area-registry-detail.ts b/src/panels/config/areas/dialog-area-registry-detail.ts index 31d313309a..0153bd8903 100644 --- a/src/panels/config/areas/dialog-area-registry-detail.ts +++ b/src/panels/config/areas/dialog-area-registry-detail.ts @@ -12,6 +12,7 @@ import "../../../components/ha-settings-row"; import "../../../components/ha-icon-picker"; import "../../../components/ha-floor-picker"; import "../../../components/ha-textfield"; +import "../../../components/ha-labels-picker"; import { AreaRegistryEntryMutableParams } from "../../../data/area_registry"; import { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog"; import { haStyleDialog } from "../../../resources/styles"; @@ -32,6 +33,8 @@ class DialogAreaDetail extends LitElement { @state() private _aliases!: string[]; + @state() private _labels!: string[]; + @state() private _picture!: string | null; @state() private _icon!: string | null; @@ -51,6 +54,7 @@ class DialogAreaDetail extends LitElement { this._error = undefined; this._name = this._params.entry ? this._params.entry.name : ""; this._aliases = this._params.entry ? this._params.entry.aliases : []; + this._labels = this._params.entry ? this._params.entry.labels : []; this._picture = this._params.entry?.picture || null; this._icon = this._params.entry?.icon || null; this._floor = this._params.entry?.floor_id || null; @@ -123,6 +127,12 @@ class DialogAreaDetail extends LitElement { .label=${this.hass.localize("ui.panel.config.areas.editor.floor")} > + + ) { this._error = undefined; this._picture = (ev.target as HaPictureUpload).value; @@ -198,6 +213,7 @@ class DialogAreaDetail extends LitElement { picture: this._picture || (create ? undefined : null), icon: this._icon || (create ? undefined : null), floor_id: this._floor || (create ? undefined : null), + labels: this._labels || null, aliases: this._aliases, }; if (create) { @@ -226,6 +242,7 @@ class DialogAreaDetail extends LitElement { ha-textfield, ha-icon-picker, ha-floor-picker, + ha-labels-picker, ha-picture-upload { display: block; margin-bottom: 16px; diff --git a/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts b/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts index 74218b47a4..ff37618fb7 100644 --- a/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts +++ b/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts @@ -5,6 +5,7 @@ import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-alert"; import "../../../../components/ha-area-picker"; import "../../../../components/ha-dialog"; +import "../../../../components/ha-labels-picker"; import type { HaSwitch } from "../../../../components/ha-switch"; import "../../../../components/ha-textfield"; import { @@ -27,6 +28,8 @@ class DialogDeviceRegistryDetail extends LitElement { @state() private _areaId!: string; + @state() private _labels!: string[]; + @state() private _disabledBy!: DeviceRegistryEntry["disabled_by"]; @state() private _submitting = false; @@ -38,6 +41,7 @@ class DialogDeviceRegistryDetail extends LitElement { this._error = undefined; this._nameByUser = this._params.device.name_by_user || ""; this._areaId = this._params.device.area_id || ""; + this._labels = this._params.device.labels || []; this._disabledBy = this._params.device.disabled_by; await this.updateComplete; } @@ -79,6 +83,11 @@ class DialogDeviceRegistryDetail extends LitElement { .value=${this._areaId} @value-changed=${this._areaPicked} > +
` : ""} + ${this._cameraPrefs ? html` @@ -1008,6 +1018,7 @@ export class EntityRegistrySettingsEditor extends LitElement { name: this._name.trim() || null, icon: this._icon.trim() || null, area_id: this._areaId || null, + labels: this._labels || [], new_entity_id: this._entityId.trim(), }; @@ -1350,6 +1361,10 @@ export class EntityRegistrySettingsEditor extends LitElement { this._areaId = ev.detail.value; } + private _labelsChanged(ev: CustomEvent) { + this._labels = ev.detail.value; + } + private async _fetchCameraPrefs() { this._cameraPrefs = await fetchCameraPrefs(this.hass, this.entry.entity_id); } diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 6ca9b07bcc..726fd97075 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -736,6 +736,7 @@ export class HaConfigEntities extends LitElement { entity_category: null, has_entity_name: false, options: null, + labels: [], }); } if (changed) { diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index c0028557ec..f7fd2fd195 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -9,6 +9,7 @@ import { mdiDevices, mdiInformation, mdiInformationOutline, + mdiLabel, mdiLightningBolt, mdiMapMarkerRadius, mdiMathLog, @@ -267,6 +268,14 @@ export const configSections: { [name: string]: PageNavigation[] } = { iconColor: "#2D338F", core: true, }, + { + component: "labels", + path: "/config/labels", + translationKey: "ui.panel.config.labels.caption", + iconPath: mdiLabel, + iconColor: "#2D338F", + core: true, + }, { component: "zone", path: "/config/zone", @@ -451,6 +460,10 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) { tag: "ha-config-integrations", load: () => import("./integrations/ha-config-integrations"), }, + labels: { + tag: "ha-config-labels", + load: () => import("./labels/ha-config-labels"), + }, lovelace: { tag: "ha-config-lovelace", load: () => import("./lovelace/ha-config-lovelace"), diff --git a/src/panels/config/labels/dialog-label-detail.ts b/src/panels/config/labels/dialog-label-detail.ts new file mode 100644 index 0000000000..c5e36cb723 --- /dev/null +++ b/src/panels/config/labels/dialog-label-detail.ts @@ -0,0 +1,214 @@ +import "@material/mwc-button"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-alert"; +import { createCloseHeading } from "../../../components/ha-dialog"; +import "../../../components/ha-formfield"; +import "../../../components/ha-switch"; +import "../../../components/ha-textfield"; +import "../../../components/ha-icon-picker"; +import "../../../components/ha-color-picker"; +import { HassDialog } from "../../../dialogs/make-dialog-manager"; +import { haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { LabelDetailDialogParams } from "./show-dialog-label-detail"; +import { + LabelRegistryEntry, + LabelRegistryEntryMutableParams, +} from "../../../data/label_registry"; + +@customElement("dialog-label-detail") +class DialogLabelDetail + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _name!: string; + + @state() private _icon!: string; + + @state() private _color!: string; + + @state() private _error?: string; + + @state() private _params?: LabelDetailDialogParams; + + @state() private _submitting = false; + + public showDialog(params: LabelDetailDialogParams): void { + this._params = params; + this._error = undefined; + if (this._params.entry) { + this._name = this._params.entry.name || ""; + this._icon = this._params.entry.icon || ""; + this._color = this._params.entry.color || ""; + } else { + this._name = this._params.suggestedName || ""; + this._icon = ""; + this._color = ""; + } + } + + public closeDialog(): void { + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params) { + return nothing; + } + + return html` + +
+ ${this._error + ? html`${this._error}` + : ""} +
+ + + +
+
+ ${this._params.entry && this._params.removeEntry + ? html` + + ${this.hass!.localize("ui.panel.config.labels.detail.delete")} + + ` + : nothing} + + ${this._params.entry + ? this.hass!.localize("ui.panel.config.labels.detail.update") + : this.hass!.localize("ui.panel.config.labels.detail.create")} + +
+ `; + } + + private _input(ev: Event) { + const target = ev.target as any; + const configValue = target.configValue; + + this._error = undefined; + this[`_${configValue}`] = target.value; + } + + private _valueChanged(ev: CustomEvent) { + const target = ev.target as any; + const configValue = target.configValue; + + this._error = undefined; + this[`_${configValue}`] = ev.detail.value || ""; + } + + private async _updateEntry() { + this._submitting = true; + let newValue: LabelRegistryEntry | undefined; + try { + const values: LabelRegistryEntryMutableParams = { + name: this._name.trim(), + icon: this._icon.trim() || null, + color: this._color.trim() || null, + }; + if (this._params!.entry) { + newValue = await this._params!.updateEntry!(values); + } else { + newValue = await this._params!.createEntry!(values); + } + this.closeDialog(); + } catch (err: any) { + this._error = err ? err.message : "Unknown error"; + } finally { + this._submitting = false; + } + return newValue; + } + + private async _deleteEntry() { + this._submitting = true; + try { + if (await this._params!.removeEntry!()) { + this._params = undefined; + } + } finally { + this._submitting = false; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + a { + color: var(--primary-color); + } + ha-textfield, + ha-icon-picker, + ha-color-picker { + display: block; + } + ha-color-picker { + margin-top: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-label-detail": DialogLabelDetail; + } +} diff --git a/src/panels/config/labels/ha-config-labels.ts b/src/panels/config/labels/ha-config-labels.ts new file mode 100644 index 0000000000..9e779c6384 --- /dev/null +++ b/src/panels/config/labels/ha-config-labels.ts @@ -0,0 +1,212 @@ +import { mdiHelpCircle, mdiPlus } from "@mdi/js"; +import { LitElement, PropertyValues, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; +import { LocalizeFunc } from "../../../common/translations/localize"; +import { + DataTableColumnContainer, + RowClickedEvent, +} from "../../../components/data-table/ha-data-table"; +import "../../../components/ha-fab"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-relative-time"; +import { + LabelRegistryEntry, + LabelRegistryEntryMutableParams, + createLabelRegistryEntry, + deleteLabelRegistryEntry, + fetchLabelRegistry, + updateLabelRegistryEntry, +} from "../../../data/label_registry"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-tabs-subpage-data-table"; +import { HomeAssistant, Route } from "../../../types"; +import { configSections } from "../ha-panel-config"; +import { showLabelDetailDialog } from "./show-dialog-label-detail"; + +@customElement("ha-config-labels") +export class HaConfigLabels extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public isWide = false; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: false }) public route!: Route; + + @state() private _labels: LabelRegistryEntry[] = []; + + private _columns = memoizeOne((localize: LocalizeFunc) => { + const columns: DataTableColumnContainer = { + icon: { + title: "", + label: localize("ui.panel.config.labels.headers.icon"), + type: "icon", + template: (label) => + label.icon ? html`` : nothing, + }, + color: { + title: "", + label: localize("ui.panel.config.labels.headers.color"), + type: "icon", + template: (label) => + label.color + ? html`
` + : nothing, + }, + name: { + title: localize("ui.panel.config.labels.headers.name"), + main: true, + sortable: true, + filterable: true, + grows: true, + }, + }; + return columns; + }); + + private _data = memoizeOne( + (labels: LabelRegistryEntry[]): LabelRegistryEntry[] => + labels.map((label) => ({ + ...label, + })) + ); + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + this._fetchLabels(); + } + + protected render() { + return html` + + + + + + + `; + } + + private _editLabel(ev: CustomEvent) { + const label = this._labels.find((lbl) => lbl.label_id === ev.detail.id); + this._openDialog(label); + } + + private _showHelp() { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.config.labels.caption"), + text: html` + ${this.hass.localize("ui.panel.config.labels.introduction")} +

${this.hass.localize("ui.panel.config.labels.introduction2")}

+ `, + }); + } + + private async _fetchLabels() { + this._labels = await fetchLabelRegistry(this.hass.connection); + } + + private _addLabel() { + this._openDialog(); + } + + private _openDialog(entry?: LabelRegistryEntry) { + showLabelDetailDialog(this, { + entry, + createEntry: (values) => this._createLabel(values), + updateEntry: entry + ? (values) => this._updateLabel(entry, values) + : undefined, + removeEntry: entry ? () => this._removeLabel(entry) : undefined, + }); + } + + private async _createLabel( + values: LabelRegistryEntryMutableParams + ): Promise { + const newTag = await createLabelRegistryEntry(this.hass, values); + this._labels = [...this._labels, newTag]; + return newTag; + } + + private async _updateLabel( + selectedLabel: LabelRegistryEntry, + values: Partial + ): Promise { + const updated = await updateLabelRegistryEntry( + this.hass, + selectedLabel.label_id, + values + ); + this._labels = this._labels.map((label) => + label.label_id === selectedLabel.label_id ? updated : label + ); + return updated; + } + + private async _removeLabel(selectedLabel: LabelRegistryEntry) { + if ( + !(await showConfirmationDialog(this, { + title: this.hass!.localize( + "ui.panel.config.labels.confirm_remove_title" + ), + text: this.hass.localize("ui.panel.config.labels.confirm_remove", { + label: selectedLabel.name || selectedLabel.label_id, + }), + dismissText: this.hass!.localize("ui.common.cancel"), + confirmText: this.hass!.localize("ui.common.remove"), + })) + ) { + return false; + } + try { + await deleteLabelRegistryEntry(this.hass, selectedLabel.label_id); + this._labels = this._labels.filter( + (label) => label.label_id !== selectedLabel.label_id + ); + return true; + } catch (err: any) { + return false; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-labels": HaConfigLabels; + } +} diff --git a/src/panels/config/labels/show-dialog-label-detail.ts b/src/panels/config/labels/show-dialog-label-detail.ts new file mode 100644 index 0000000000..b41586df57 --- /dev/null +++ b/src/panels/config/labels/show-dialog-label-detail.ts @@ -0,0 +1,31 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { + LabelRegistryEntry, + LabelRegistryEntryMutableParams, +} from "../../../data/label_registry"; + +export interface LabelDetailDialogParams { + entry?: LabelRegistryEntry; + suggestedName?: string; + createEntry?: ( + values: LabelRegistryEntryMutableParams, + labelId?: string + ) => Promise; + updateEntry?: ( + updates: Partial + ) => Promise; + removeEntry?: () => Promise; +} + +export const loadLabelDetailDialog = () => import("./dialog-label-detail"); + +export const showLabelDetailDialog = ( + element: HTMLElement, + dialogParams: LabelDetailDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-label-detail", + dialogImport: loadLabelDetailDialog, + dialogParams, + }); +}; diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts index cbbfb6ade6..d4de86b70d 100644 --- a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts @@ -155,7 +155,7 @@ export class HuiTileCardEditor { name: "color", selector: { - ui_color: {}, + ui_color: { default_color: true }, }, }, { diff --git a/src/state/connection-mixin.ts b/src/state/connection-mixin.ts index 8179aa3dc2..b00cc43b28 100644 --- a/src/state/connection-mixin.ts +++ b/src/state/connection-mixin.ts @@ -232,6 +232,7 @@ export const connectionMixin = >( entity_id: entity.ei, device_id: entity.di, area_id: entity.ai, + labels: entity.lb, translation_key: entity.tk, platform: entity.pl, entity_category: diff --git a/src/translations/en.json b/src/translations/en.json index 9f27f84e3d..68edd0d0f3 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -487,14 +487,17 @@ "expand_floor_id": "Split this floor into separate areas.", "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 area, devices and entities.", "remove": "Remove", "remove_floor_id": "Remove floor", "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" @@ -544,6 +547,23 @@ "device": "Device", "no_area": "No area" }, + "label-picker": { + "clear": "Clear", + "show_labels": "Show labels", + "labels": "Labels", + "add_label": "Add 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." + } + }, "area-picker": { "clear": "Clear", "show_areas": "Show areas", @@ -585,6 +605,16 @@ "show": "Show {area}", "hide": "Hide {area}" }, + "label-picker": { + "clear": "Clear", + "show_labels": "Show labels", + "label": "Label", + "add_new_sugestion": "Add new label ''{name}''", + "add_new": "Add new label…", + "add_label": "Add label", + "no_labels": "You don't have any labels", + "no_match": "No matching labels found" + }, "statistic-picker": { "statistic": "Statistic", "no_statistics": "You don't have any statistics", @@ -634,6 +664,38 @@ "supported_formats": "Supports JPEG, PNG, or GIF image.", "unsupported_format": "Unsupported format, please choose a JPEG, PNG, or GIF image." }, + "color-picker": { + "default_color": "Default color (state)", + "colors": { + "primary": "Primary", + "accent": "Accent", + "disabled": "Disabled", + "inactive": "Inactive", + "red": "Red", + "pink": "Pink", + "purple": "Purple", + "deep-purple": "Deep purple", + "indigo": "Indigo", + "blue": "Blue", + "light-blue": "Light blue", + "cyan": "Cyan", + "teal": "Teal", + "green": "Green", + "light-green": "Light Green", + "lime": "Lime", + "yellow": "Yellow", + "amber": "Amber", + "orange": "Orange", + "deep-orange": "Deep orange", + "brown": "Brown", + "light-grey": "Light grey", + "grey": "Grey", + "dark-grey": "Dark grey", + "blue-grey": "Blue grey", + "black": "Black", + "white": "White" + } + }, "date-range-picker": { "start_date": "Start date", "end_date": "End date", @@ -1709,7 +1771,7 @@ "secondary": "Manage who can access your home" }, "areas": { - "main": "Areas & zones", + "main": "Areas, labels & zones", "secondary": "Manage locations in and around your house" }, "companion": { @@ -1796,6 +1858,28 @@ "aliases_description": "Aliases are alternative names used in voice assistants to refer to this floor." } }, + "labels": { + "caption": "Labels", + "description": "Group devices and entities", + "headers": { "name": "Name", "icon": "Icon", "color": "Color" }, + "add_label": "Add label", + "no_labels": "You don't have any labels", + "introduction": "Labels can help you organize your areas, devices and entities. They can be used to filter in the UI, or use them as a target in automations.", + "introduction2": "Go to the area, device or entity you want to add a label to, and click on the edit button to assign labels to them.", + "confirm_remove_title": "Remove label?", + "confirm_remove": "Are you sure you want to remove label {label}? It will be removed from all areas, devices and entities.", + "detail": { + "new_label": "New label", + "name": "Name", + "icon": "Icon", + "color": "Color", + "description": "Description", + "delete": "Delete", + "update": "Update", + "create": "Create", + "required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]" + } + }, "areas": { "caption": "Areas", "description": "Group devices and entities into areas", @@ -5841,38 +5925,6 @@ "warning_multiple_cards": "This view contains more than one card, but a panel view can only show 1 card." } }, - "color-picker": { - "default_color": "Default color (state)", - "colors": { - "primary": "Primary", - "accent": "Accent", - "disabled": "Disabled", - "inactive": "Inactive", - "red": "Red", - "pink": "Pink", - "purple": "Purple", - "deep-purple": "Deep purple", - "indigo": "Indigo", - "blue": "Blue", - "light-blue": "Light blue", - "cyan": "Cyan", - "teal": "Teal", - "green": "Green", - "light-green": "Light Green", - "lime": "Lime", - "yellow": "Yellow", - "amber": "Amber", - "orange": "Orange", - "deep-orange": "Deep orange", - "brown": "Brown", - "light-grey": "Light grey", - "grey": "Grey", - "dark-grey": "Dark grey", - "blue-grey": "Blue grey", - "black": "Black", - "white": "White" - } - }, "cardpicker": { "no_description": "No description available.", "custom_card": "Custom",