From d8b43597a0f5c0b6a0177e71f0f8e7fb5750ca2b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Apr 2024 11:49:05 +0200 Subject: [PATCH 01/13] Use area and floor dialog when adding item in picker (#20311) * Use area and floor dialog when adding item in picker * use const --- src/components/ha-area-picker.ts | 57 +++++++------------ src/components/ha-floor-picker.ts | 39 ++++--------- .../areas/dialog-area-registry-detail.ts | 4 +- .../areas/dialog-floor-registry-detail.ts | 4 +- .../areas/show-dialog-area-registry-detail.ts | 1 + .../show-dialog-floor-registry-detail.ts | 1 + src/translations/en.json | 16 +----- 7 files changed, 43 insertions(+), 79 deletions(-) diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index a7b404f29abe..625ea62beb1b 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -21,10 +21,8 @@ import { getDeviceEntityDisplayLookup, } from "../data/device_registry"; import { EntityRegistryDisplayEntry } from "../data/entity_registry"; -import { - showAlertDialog, - showPromptDialog, -} from "../dialogs/generic/show-dialog-box"; +import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; +import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail"; import { HomeAssistant, ValueChangedEvent } from "../types"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import "./ha-combo-box"; @@ -38,7 +36,7 @@ type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry; const rowRenderer: ComboBoxLitRenderer = (item) => html` ${item.icon ? html`` @@ -46,6 +44,10 @@ const rowRenderer: ComboBoxLitRenderer = (item) => ${item.name} `; +const ADD_NEW_ID = "___ADD_NEW___"; +const NO_ITEMS_ID = "___NO_ITEMS___"; +const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___"; + @customElement("ha-area-picker") export class HaAreaPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -137,7 +139,7 @@ export class HaAreaPicker extends LitElement { if (!areas.length) { return [ { - area_id: "no_areas", + area_id: NO_ITEMS_ID, floor_id: null, name: this.hass.localize("ui.components.area-picker.no_areas"), picture: null, @@ -284,7 +286,7 @@ export class HaAreaPicker extends LitElement { if (!outputAreas.length) { outputAreas = [ { - area_id: "no_areas", + area_id: NO_ITEMS_ID, floor_id: null, name: this.hass.localize("ui.components.area-picker.no_match"), picture: null, @@ -300,7 +302,7 @@ export class HaAreaPicker extends LitElement { : [ ...outputAreas, { - area_id: "add_new", + area_id: ADD_NEW_ID, floor_id: null, name: this.hass.localize("ui.components.area-picker.add_new"), picture: null, @@ -380,7 +382,7 @@ export class HaAreaPicker extends LitElement { this._suggestion = filterString; this.comboBox.filteredItems = [ { - area_id: "add_new_suggestion", + area_id: ADD_NEW_SUGGESTION_ID, name: this.hass.localize( "ui.components.area-picker.add_new_sugestion", { name: this._suggestion } @@ -405,11 +407,11 @@ export class HaAreaPicker extends LitElement { ev.stopPropagation(); let newValue = ev.detail.value; - if (newValue === "no_areas") { + if (newValue === NO_ITEMS_ID) { newValue = ""; } - if (!["add_new_suggestion", "add_new"].includes(newValue)) { + if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { if (newValue !== this._value) { this._setValue(newValue); } @@ -417,25 +419,12 @@ export class HaAreaPicker extends LitElement { } (ev.target as any).value = this._value; - showPromptDialog(this, { - title: this.hass.localize("ui.components.area-picker.add_dialog.title"), - text: this.hass.localize("ui.components.area-picker.add_dialog.text"), - confirmText: this.hass.localize( - "ui.components.area-picker.add_dialog.add" - ), - inputLabel: this.hass.localize( - "ui.components.area-picker.add_dialog.name" - ), - defaultValue: - newValue === "add_new_suggestion" ? this._suggestion : undefined, - confirm: async (name) => { - if (!name) { - return; - } + + showAreaRegistryDetailDialog(this, { + suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", + createEntry: async (values) => { try { - const area = await createAreaRegistryEntry(this.hass, { - name, - }); + const area = await createAreaRegistryEntry(this.hass, values); const areas = [...Object.values(this.hass.areas), area]; this.comboBox.filteredItems = this._getAreas( areas, @@ -455,18 +444,16 @@ export class HaAreaPicker extends LitElement { } catch (err: any) { showAlertDialog(this, { title: this.hass.localize( - "ui.components.area-picker.add_dialog.failed_create_area" + "ui.components.area-picker.failed_create_area" ), text: err.message, }); } }, - cancel: () => { - this._setValue(undefined); - this._suggestion = undefined; - this.comboBox.setInputValue(""); - }, }); + + this._suggestion = undefined; + this.comboBox.setInputValue(""); } private _setValue(value?: string) { diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index 9ac37cf746b1..aa6ea9501b33 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -23,11 +23,9 @@ import { getFloorAreaLookup, subscribeFloorRegistry, } from "../data/floor_registry"; -import { - showAlertDialog, - showPromptDialog, -} from "../dialogs/generic/show-dialog-box"; +import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { showFloorRegistryDetailDialog } from "../panels/config/areas/show-dialog-floor-registry-detail"; import { HomeAssistant, ValueChangedEvent } from "../types"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import "./ha-combo-box"; @@ -438,25 +436,12 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { } (ev.target as any).value = this._value; - showPromptDialog(this, { - title: this.hass.localize("ui.components.floor-picker.add_dialog.title"), - text: this.hass.localize("ui.components.floor-picker.add_dialog.text"), - confirmText: this.hass.localize( - "ui.components.floor-picker.add_dialog.add" - ), - inputLabel: this.hass.localize( - "ui.components.floor-picker.add_dialog.name" - ), - defaultValue: - newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : undefined, - confirm: async (name) => { - if (!name) { - return; - } + + showFloorRegistryDetailDialog(this, { + suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", + createEntry: async (values) => { try { - const floor = await createFloorRegistryEntry(this.hass, { - name, - }); + const floor = await createFloorRegistryEntry(this.hass, values); const floors = [...this._floors!, floor]; this.comboBox.filteredItems = this._getFloors( floors, @@ -477,18 +462,16 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { } catch (err: any) { showAlertDialog(this, { title: this.hass.localize( - "ui.components.floor-picker.add_dialog.failed_create_floor" + "ui.components.floor-picker.failed_create_floor" ), text: err.message, }); } }, - cancel: () => { - this._setValue(undefined); - this._suggestion = undefined; - this.comboBox.setInputValue(""); - }, }); + + this._suggestion = undefined; + this.comboBox.setInputValue(""); } private _setValue(value?: string) { diff --git a/src/panels/config/areas/dialog-area-registry-detail.ts b/src/panels/config/areas/dialog-area-registry-detail.ts index 0153bd890353..bdbfff68d872 100644 --- a/src/panels/config/areas/dialog-area-registry-detail.ts +++ b/src/panels/config/areas/dialog-area-registry-detail.ts @@ -52,7 +52,9 @@ class DialogAreaDetail extends LitElement { ): Promise { this._params = params; this._error = undefined; - this._name = this._params.entry ? this._params.entry.name : ""; + this._name = this._params.entry + ? this._params.entry.name + : this._params.suggestedName || ""; 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; diff --git a/src/panels/config/areas/dialog-floor-registry-detail.ts b/src/panels/config/areas/dialog-floor-registry-detail.ts index 775d18399382..12e666873f3b 100644 --- a/src/panels/config/areas/dialog-floor-registry-detail.ts +++ b/src/panels/config/areas/dialog-floor-registry-detail.ts @@ -38,7 +38,9 @@ class DialogFloorDetail extends LitElement { ): Promise { this._params = params; this._error = undefined; - this._name = this._params.entry ? this._params.entry.name : ""; + this._name = this._params.entry + ? this._params.entry.name + : this._params.suggestedName || ""; this._aliases = this._params.entry?.aliases || []; this._icon = this._params.entry?.icon || null; this._level = this._params.entry?.level ?? null; diff --git a/src/panels/config/areas/show-dialog-area-registry-detail.ts b/src/panels/config/areas/show-dialog-area-registry-detail.ts index 0f0eb422fa4b..19d56e52c1fc 100644 --- a/src/panels/config/areas/show-dialog-area-registry-detail.ts +++ b/src/panels/config/areas/show-dialog-area-registry-detail.ts @@ -6,6 +6,7 @@ import { export interface AreaRegistryDetailDialogParams { entry?: AreaRegistryEntry; + suggestedName?: string; createEntry?: (values: AreaRegistryEntryMutableParams) => Promise; updateEntry?: ( updates: Partial diff --git a/src/panels/config/areas/show-dialog-floor-registry-detail.ts b/src/panels/config/areas/show-dialog-floor-registry-detail.ts index 507d10346999..ecaa74786bb5 100644 --- a/src/panels/config/areas/show-dialog-floor-registry-detail.ts +++ b/src/panels/config/areas/show-dialog-floor-registry-detail.ts @@ -6,6 +6,7 @@ import { export interface FloorRegistryDetailDialogParams { entry?: FloorRegistryEntry; + suggestedName?: string; createEntry?: (values: FloorRegistryEntryMutableParams) => Promise; updateEntry?: ( updates: Partial diff --git a/src/translations/en.json b/src/translations/en.json index 9a68c368b7b1..b6353d934af8 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -599,13 +599,7 @@ "no_areas": "You don't have any areas", "no_match": "No matching areas found", "unassigned_areas": "Unassigned areas", - "add_dialog": { - "title": "Add new area", - "text": "Enter the name of the new area.", - "name": "Name", - "add": "Add", - "failed_create_area": "Failed to create area." - } + "failed_create_area": "Failed to create area." }, "floor-picker": { "clear": "Clear", @@ -615,13 +609,7 @@ "add_new": "Add new floor…", "no_floors": "You don't have any floors", "no_match": "No matching floors found", - "add_dialog": { - "title": "Add new floor", - "text": "Enter the name of the new floor.", - "name": "Name", - "add": "Add", - "failed_create_floor": "Failed to create floor." - } + "failed_create_floor": "Failed to create floor." }, "area-filter": { "title": "Areas", From bc8012dcc9d15fd9b4fe36d8e8f6e0f8994f33af Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Apr 2024 11:50:50 +0200 Subject: [PATCH 02/13] Add shortcut to label filter from label config page (#20313) --- .../config/automation/ha-automation-picker.ts | 22 ++++++++++- .../devices/ha-config-devices-dashboard.ts | 17 ++++++++ .../config/entities/ha-config-entities.ts | 17 ++++++++ src/panels/config/labels/ha-config-labels.ts | 39 ++++++++++++++++++- src/panels/config/scene/ha-scene-dashboard.ts | 23 +++++++++++ src/panels/config/script/ha-script-picker.ts | 18 +++++++++ 6 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index b5208b944e3c..ad8be83b6dfa 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -379,7 +379,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { this._navigateEntities(label), + }, + { + label: this.hass.localize("ui.panel.config.devices.caption"), + path: mdiDevices, + action: () => this._navigateDevices(label), + }, + { + label: this.hass.localize("ui.panel.config.automation.caption"), + path: mdiRobot, + action: () => this._navigateAutomations(label), + }, { label: this.hass.localize("ui.common.delete"), path: mdiDelete, @@ -225,6 +248,20 @@ export class HaConfigLabels extends LitElement { return false; } } + + private _navigateEntities(label: LabelRegistryEntry) { + navigate(`/config/entities?historyBack=1&label=${label.label_id}`); + } + + private _navigateDevices(label: LabelRegistryEntry) { + navigate(`/config/devices/dashboard?historyBack=1&label=${label.label_id}`); + } + + private _navigateAutomations(label: LabelRegistryEntry) { + navigate( + `/config/automation/dashboard?historyBack=1&label=${label.label_id}` + ); + } } declare global { diff --git a/src/panels/config/scene/ha-scene-dashboard.ts b/src/panels/config/scene/ha-scene-dashboard.ts index 36826e56df26..ece548c7060c 100644 --- a/src/panels/config/scene/ha-scene-dashboard.ts +++ b/src/panels/config/scene/ha-scene-dashboard.ts @@ -96,6 +96,8 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { @property({ attribute: false }) public scenes!: SceneEntity[]; + @state() private _searchParms = new URLSearchParams(window.location.search); + @state() private _activeFilters?: string[]; @state() private _filteredScenes?: string[] | null; @@ -530,6 +532,27 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { this._applyFilters(); } + firstUpdated() { + if (this._searchParms.has("label")) { + this._filterLabel(); + } + } + + private _filterLabel() { + const label = this._searchParms.get("label"); + if (!label) { + return; + } + this._filters = { + ...this._filters, + "ha-filter-labels": { + value: [label], + items: undefined, + }, + }; + this._applyFilters(); + } + private _handleRowClicked(ev: HASSDomEvent) { const scene = this.scenes.find((a) => a.entity_id === ev.detail.id); diff --git a/src/panels/config/script/ha-script-picker.ts b/src/panels/config/script/ha-script-picker.ts index fa9a4b920838..b5ddc8cc22dc 100644 --- a/src/panels/config/script/ha-script-picker.ts +++ b/src/panels/config/script/ha-script-picker.ts @@ -572,6 +572,24 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { if (this._searchParms.has("blueprint")) { this._filterBlueprint(); } + if (this._searchParms.has("label")) { + this._filterLabel(); + } + } + + private _filterLabel() { + const label = this._searchParms.get("label"); + if (!label) { + return; + } + this._filters = { + ...this._filters, + "ha-filter-labels": { + value: [label], + items: undefined, + }, + }; + this._applyFilters(); } private async _filterBlueprint() { From db59e138e9436891191d81ca5f8536cf70b55075 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 2 Apr 2024 12:45:13 +0200 Subject: [PATCH 03/13] Fix pickers (#20315) * Fix not found area picker * Fix no match for categories --- src/components/ha-area-picker.ts | 64 +++++++++++-------- src/components/ha-floor-picker.ts | 2 +- .../config/category/ha-category-picker.ts | 4 +- src/translations/en.json | 1 + 4 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 625ea62beb1b..0acadf2474e3 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -136,20 +136,6 @@ export class HaAreaPicker extends LitElement { noAdd: this["noAdd"], excludeAreas: this["excludeAreas"] ): AreaRegistryEntry[] => { - if (!areas.length) { - return [ - { - area_id: NO_ITEMS_ID, - floor_id: null, - name: this.hass.localize("ui.components.area-picker.no_areas"), - picture: null, - icon: null, - aliases: [], - labels: [], - }, - ]; - } - let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let inputDevices: DeviceRegistryEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined; @@ -288,7 +274,7 @@ export class HaAreaPicker extends LitElement { { area_id: NO_ITEMS_ID, floor_id: null, - name: this.hass.localize("ui.components.area-picker.no_match"), + name: this.hass.localize("ui.components.area-picker.no_areas"), picture: null, icon: null, aliases: [], @@ -376,20 +362,40 @@ export class HaAreaPicker extends LitElement { const filteredItems = fuzzyFilterSort( filterString, - target.items || [] + target.items?.filter( + (item) => ![NO_ITEMS_ID, ADD_NEW_ID].includes(item.label_id) + ) || [] ); - if (!this.noAdd && filteredItems?.length === 0) { - this._suggestion = filterString; - this.comboBox.filteredItems = [ - { - area_id: ADD_NEW_SUGGESTION_ID, - name: this.hass.localize( - "ui.components.area-picker.add_new_sugestion", - { name: this._suggestion } - ), - picture: null, - }, - ]; + if (filteredItems.length === 0) { + if (!this.noAdd) { + this.comboBox.filteredItems = [ + { + area_id: NO_ITEMS_ID, + floor_id: null, + name: this.hass.localize("ui.components.area-picker.no_match"), + icon: null, + picture: null, + labels: [], + aliases: [], + }, + ] as AreaRegistryEntry[]; + } else { + this._suggestion = filterString; + this.comboBox.filteredItems = [ + { + area_id: ADD_NEW_SUGGESTION_ID, + floor_id: null, + name: this.hass.localize( + "ui.components.area-picker.add_new_sugestion", + { name: this._suggestion } + ), + icon: "mdi:plus", + picture: null, + labels: [], + aliases: [], + }, + ] as AreaRegistryEntry[]; + } } else { this.comboBox.filteredItems = filteredItems; } @@ -409,6 +415,8 @@ export class HaAreaPicker extends LitElement { if (newValue === NO_ITEMS_ID) { newValue = ""; + this.comboBox.setInputValue(""); + return; } if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index aa6ea9501b33..6935c3a9f4aa 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -384,7 +384,7 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { this.comboBox.filteredItems = [ { floor_id: NO_FLOORS_ID, - name: this.hass.localize("ui.components.floor-picker.no_floors"), + name: this.hass.localize("ui.components.floor-picker.no_match"), icon: null, level: null, aliases: [], diff --git a/src/panels/config/category/ha-category-picker.ts b/src/panels/config/category/ha-category-picker.ts index 3cd495935a96..b797697fc7c9 100644 --- a/src/panels/config/category/ha-category-picker.ts +++ b/src/panels/config/category/ha-category-picker.ts @@ -188,9 +188,7 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) { this.comboBox.filteredItems = [ { category_id: NO_CATEGORIES_ID, - name: this.hass.localize( - "ui.components.category-picker.no_categories" - ), + name: this.hass.localize("ui.components.category-picker.no_match"), icon: null, }, ] as ScorableCategoryRegistryEntry[]; diff --git a/src/translations/en.json b/src/translations/en.json index b6353d934af8..e4332014a32f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -571,6 +571,7 @@ "add_new_sugestion": "Add new category ''{name}''", "add_new": "Add new category…", "no_categories": "You don't have any categories", + "no_match": "No matching categories found", "add_dialog": { "title": "Add new category", "text": "Enter the name of the new category.", From 21263a1ffb83ae8fce9a36eced4b64daf011db0e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 2 Apr 2024 12:45:39 +0200 Subject: [PATCH 04/13] Improve more info dialog navigation for specific view (#20312) --- src/dialogs/more-info/ha-more-info-dialog.ts | 55 +++++++++++++++++--- src/translations/en.json | 1 + 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index ecc3411f08ac..33413df92be3 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -77,6 +77,8 @@ declare global { } } +const DEFAULT_VIEW: View = "info"; + @customElement("ha-more-info-dialog") export class MoreInfoDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -85,7 +87,9 @@ export class MoreInfoDialog extends LitElement { @state() private _entityId?: string | null; - @state() private _currView: View = "info"; + @state() private _currView: View = DEFAULT_VIEW; + + @state() private _initialView: View = DEFAULT_VIEW; @state() private _childView?: ChildView; @@ -102,7 +106,8 @@ export class MoreInfoDialog extends LitElement { this.closeDialog(); return; } - this._currView = params.view || "info"; + this._currView = params.view || DEFAULT_VIEW; + this._initialView = params.view || DEFAULT_VIEW; this._childView = undefined; this.large = false; this._loadEntityRegistryEntry(); @@ -127,6 +132,7 @@ export class MoreInfoDialog extends LitElement { this._entry = undefined; this._childView = undefined; this._infoEditMode = false; + this._initialView = DEFAULT_VIEW; fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -183,10 +189,15 @@ export class MoreInfoDialog extends LitElement { if (this._childView) { this._childView = undefined; } else { - this.setView("info"); + this.setView(this._initialView); } } + private _resetInitialView() { + this._initialView = DEFAULT_VIEW; + this.setView(DEFAULT_VIEW); + } + private _goToHistory() { this.setView("history"); } @@ -262,7 +273,10 @@ export class MoreInfoDialog extends LitElement { const title = this._childView?.viewTitle ?? name; - const isInfoView = this._currView === "info" && !this._childView; + const isDefaultView = this._currView === DEFAULT_VIEW && !this._childView; + const isSpecificInitialView = + this._initialView !== DEFAULT_VIEW && !this._childView; + const showCloseIcon = isDefaultView || isSpecificInitialView; return html` - ${isInfoView + ${showCloseIcon ? html` ${title} - ${isInfoView + ${isDefaultView ? html` ${this.shouldShowHistory(domain) ? html` @@ -407,7 +421,34 @@ export class MoreInfoDialog extends LitElement { ` : nothing} ` - : nothing} + : isSpecificInitialView + ? html` + + + + + ${this.hass.localize("ui.dialogs.more_info_control.info")} + + + + ` + : nothing}
Date: Tue, 2 Apr 2024 13:37:46 +0200 Subject: [PATCH 05/13] Sort labels by name (#20316) --- .../data-table/ha-data-table-labels.ts | 10 ++++--- src/components/ha-labels-picker.ts | 28 +++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/components/data-table/ha-data-table-labels.ts b/src/components/data-table/ha-data-table-labels.ts index 1c47f0bf64d3..66c137e92a6a 100644 --- a/src/components/data-table/ha-data-table-labels.ts +++ b/src/components/data-table/ha-data-table-labels.ts @@ -5,20 +5,22 @@ import { LabelRegistryEntry } from "../../data/label_registry"; import { computeCssColor } from "../../common/color/compute-color"; import { fireEvent } from "../../common/dom/fire_event"; import "../ha-label"; +import { stringCompare } from "../../common/string/compare"; @customElement("ha-data-table-labels") class HaDataTableLabels extends LitElement { @property({ attribute: false }) public labels!: LabelRegistryEntry[]; protected render(): TemplateResult { + const labels = this.labels.sort((a, b) => stringCompare(a.name, b.name)); return html` ${repeat( - this.labels.slice(0, 2), + labels.slice(0, 2), (label) => label.label_id, (label) => this._renderLabel(label, true) )} - ${this.labels.length > 2 + ${labels.length > 2 ? html` - +${this.labels.length - 2} + +${labels.length - 2} ${repeat( - this.labels.slice(2), + labels.slice(2), (label) => label.label_id, (label) => html` diff --git a/src/components/ha-labels-picker.ts b/src/components/ha-labels-picker.ts index ccf4511a6119..2cc592e26efd 100644 --- a/src/components/ha-labels-picker.ts +++ b/src/components/ha-labels-picker.ts @@ -17,6 +17,7 @@ import "./chips/ha-input-chip"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import "./ha-label-picker"; import type { HaLabelPicker } from "./ha-label-picker"; +import { stringCompare } from "../common/string/compare"; @customElement("ha-labels-picker") export class HaLabelsPicker extends SubscribeMixin(LitElement) { @@ -75,7 +76,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { @property({ type: Boolean }) public required = false; - @state() private _labels?: LabelRegistryEntry[]; + @state() private _labels?: { [id: string]: LabelRegistryEntry }; @query("ha-label-picker", true) public labelPicker!: HaLabelPicker; @@ -92,22 +93,28 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { return [ subscribeLabelRegistry(this.hass.connection, (labels) => { - this._labels = labels; + const lookUp = {}; + labels.forEach((label) => { + lookUp[label.label_id] = label; + }); + this._labels = lookUp; }), ]; } protected render(): TemplateResult { + const labels = this.value + ?.map((id) => this._labels?.[id]) + .sort((a, b) => + stringCompare(a?.name || "", b?.name || "", this.hass.locale.language) + ); return html` - ${this.value?.length + ${labels?.length ? html` ${repeat( - this.value, - (item) => item, - (item, idx) => { - const label = this._labels?.find( - (lbl) => lbl.label_id === item - ); + labels, + (label) => label?.label_id, + (label, idx) => { const color = label?.color ? computeCssColor(label.color) : undefined; @@ -168,9 +175,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { label.label_id, values ); - this._labels = this._labels!.map((lbl) => - lbl.label_id === updated.label_id ? updated : lbl - ); return updated; }, }); From 912d2cbd7986cc3ba01105652eebb6f5ff6903a0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Apr 2024 14:40:53 +0200 Subject: [PATCH 06/13] Add warning color for menu item (#20317) * Add warning color for menu item * align icons --- src/components/ha-menu-item.ts | 4 ++++ src/panels/config/automation/ha-automation-picker.ts | 8 +++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/ha-menu-item.ts b/src/components/ha-menu-item.ts index e96e867f6f83..1b2c71f251e4 100644 --- a/src/components/ha-menu-item.ts +++ b/src/components/ha-menu-item.ts @@ -26,6 +26,10 @@ export class HaMenuItem extends MdMenuItem { --md-sys-color-on-primary-container: var(--primary-text-color); --md-sys-color-on-secondary-container: var(--primary-text-color); } + :host(.warning) { + --md-menu-item-label-text-color: var(--error-color); + --md-menu-item-leading-icon-color: var(--error-color); + } `, ]; } diff --git a/src/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index ad8be83b6dfa..24232a69f860 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -11,10 +11,8 @@ import { mdiInformationOutline, mdiMenuDown, mdiPlay, - mdiPlayCircleOutline, mdiPlus, mdiRobotHappy, - mdiStopCircleOutline, mdiTag, mdiToggleSwitch, mdiToggleSwitchOffOutline, @@ -60,6 +58,7 @@ import "../../../components/ha-filter-labels"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-menu"; +import type { HaMenu } from "../../../components/ha-menu"; import "../../../components/ha-menu-item"; import "../../../components/ha-sub-menu"; import "../../../components/ha-svg-icon"; @@ -101,7 +100,6 @@ import { turnOnOffEntity } from "../../lovelace/common/entity/turn-on-off-entity import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import { configSections } from "../ha-panel-config"; import { showNewAutomationDialog } from "./show-dialog-new-automation"; -import type { HaMenu } from "../../../components/ha-menu"; type AutomationItem = AutomationEntity & { name: string; @@ -694,8 +692,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { From a5d7043ce4d17a318ea684b3a7dfc082fd18824c Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 2 Apr 2024 15:05:21 +0200 Subject: [PATCH 07/13] Update style of more info style (#20322) * Set more info border radius to 36px * Use control button for alarm more info --- .../src/pages/components/ha-control-select.ts | 2 +- .../src/pages/components/ha-control-slider.ts | 2 +- .../src/pages/components/ha-control-switch.ts | 2 +- .../lights/light-color-temp-picker.ts | 2 +- .../controls/more-info-alarm_control_panel.ts | 26 ++++++++++++------- .../more-info/controls/more-info-lock.ts | 2 +- ...state-control-alarm_control_panel-modes.ts | 2 +- .../cover/ha-state-control-cover-position.ts | 2 +- .../ha-state-control-cover-tilt-position.ts | 2 +- .../cover/ha-state-control-cover-toggle.ts | 4 +-- .../fan/ha-state-control-fan-speed.ts | 4 +-- src/state-control/ha-state-control-toggle.ts | 4 +-- .../ha-state-control-light-brightness.ts | 2 +- .../lock/ha-state-control-lock-toggle.ts | 4 +-- .../valve/ha-state-control-valve-position.ts | 2 +- .../valve/ha-state-control-valve-toggle.ts | 4 +-- 16 files changed, 36 insertions(+), 30 deletions(-) diff --git a/gallery/src/pages/components/ha-control-select.ts b/gallery/src/pages/components/ha-control-select.ts index 1b18d0e5ab20..8ab5f0ea891a 100644 --- a/gallery/src/pages/components/ha-control-select.ts +++ b/gallery/src/pages/components/ha-control-select.ts @@ -187,7 +187,7 @@ export class DemoHaControlSelect extends LitElement { --mdc-icon-size: 24px; --control-select-color: var(--state-fan-active-color); --control-select-thickness: 130px; - --control-select-border-radius: 48px; + --control-select-border-radius: 36px; } .vertical-selects { height: 300px; diff --git a/gallery/src/pages/components/ha-control-slider.ts b/gallery/src/pages/components/ha-control-slider.ts index 1b14a666cc68..ee1ef8a66288 100644 --- a/gallery/src/pages/components/ha-control-slider.ts +++ b/gallery/src/pages/components/ha-control-slider.ts @@ -151,7 +151,7 @@ export class DemoHaBarSlider extends LitElement { --control-slider-background: #ffcf4c; --control-slider-background-opacity: 0.2; --control-slider-thickness: 130px; - --control-slider-border-radius: 48px; + --control-slider-border-radius: 36px; } .vertical-sliders { height: 300px; diff --git a/gallery/src/pages/components/ha-control-switch.ts b/gallery/src/pages/components/ha-control-switch.ts index 662c9d13426c..dc154b725d7e 100644 --- a/gallery/src/pages/components/ha-control-switch.ts +++ b/gallery/src/pages/components/ha-control-switch.ts @@ -118,7 +118,7 @@ export class DemoHaControlSwitch extends LitElement { --control-switch-on-color: var(--green-color); --control-switch-off-color: var(--red-color); --control-switch-thickness: 130px; - --control-switch-border-radius: 48px; + --control-switch-border-radius: 36px; --control-switch-padding: 6px; --mdc-icon-size: 24px; } diff --git a/src/dialogs/more-info/components/lights/light-color-temp-picker.ts b/src/dialogs/more-info/components/lights/light-color-temp-picker.ts index 78d71f6cbdd7..a1f5726cac18 100644 --- a/src/dialogs/more-info/components/lights/light-color-temp-picker.ts +++ b/src/dialogs/more-info/components/lights/light-color-temp-picker.ts @@ -190,7 +190,7 @@ class LightColorTempPicker extends LitElement { max-height: 320px; min-height: 200px; --control-slider-thickness: 130px; - --control-slider-border-radius: 48px; + --control-slider-border-radius: 36px; --control-slider-color: var(--primary-color); --control-slider-background: -webkit-linear-gradient( top, diff --git a/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts b/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts index 312c0f2c0d64..3a28b4f4b757 100644 --- a/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts +++ b/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts @@ -1,9 +1,8 @@ -import { mdiShieldOff } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import { stateColorCss } from "../../../common/entity/state_color"; -import "../../../components/ha-outlined-button"; +import "../../../components/ha-control-button"; import "../../../components/ha-state-icon"; import { AlarmControlPanelEntity } from "../../../data/alarm_control_panel"; import "../../../state-control/alarm_control_panel/ha-state-control-alarm_control_panel-modes"; @@ -57,15 +56,10 @@ class MoreInfoAlarmControlPanel extends LitElement { ${["triggered", "arming", "pending"].includes(this.stateObj.state) ? html`
-
- - ${this.hass.localize("ui.card.alarm_control_panel.disarm")} - -
` : html` @@ -76,7 +70,15 @@ class MoreInfoAlarmControlPanel extends LitElement { `}
- +
+ ${["triggered", "arming", "pending"].includes(this.stateObj.state) + ? html` + + ${this.hass.localize("ui.card.alarm_control_panel.disarm")} + + ` + : nothing} +
`; } @@ -127,8 +129,12 @@ class MoreInfoAlarmControlPanel extends LitElement { transition: background-color 180ms ease-in-out; opacity: 0.2; } - .status ha-outlined-button { - margin-top: 32px; + ha-control-button.disarm { + height: 60px; + min-width: 130px; + max-width: 200px; + margin: 0 auto; + --control-button-border-radius: 24px; } `, ]; diff --git a/src/dialogs/more-info/controls/more-info-lock.ts b/src/dialogs/more-info/controls/more-info-lock.ts index fccc841c9014..ac0f45e43ad9 100644 --- a/src/dialogs/more-info/controls/more-info-lock.ts +++ b/src/dialogs/more-info/controls/more-info-lock.ts @@ -170,7 +170,7 @@ class MoreInfoLock extends LitElement { --control-button-border-radius: 24px; } .open-button { - width: 100px; + width: 130px; --control-button-background-color: var(--state-color); } .open-button.confirm { diff --git a/src/state-control/alarm_control_panel/ha-state-control-alarm_control_panel-modes.ts b/src/state-control/alarm_control_panel/ha-state-control-alarm_control_panel-modes.ts index 10d5b465638a..585d14d9ca81 100644 --- a/src/state-control/alarm_control_panel/ha-state-control-alarm_control_panel-modes.ts +++ b/src/state-control/alarm_control_panel/ha-state-control-alarm_control_panel-modes.ts @@ -129,7 +129,7 @@ export class HaStateControlAlarmControlPanelModes extends LitElement { max-height: max(320px, var(--modes-count, 1) * 80px); min-height: max(200px, var(--modes-count, 1) * 80px); --control-select-thickness: 130px; - --control-select-border-radius: 48px; + --control-select-border-radius: 36px; --control-select-color: var(--primary-color); --control-select-background: var(--disabled-color); --control-select-background-opacity: 0.2; diff --git a/src/state-control/cover/ha-state-control-cover-position.ts b/src/state-control/cover/ha-state-control-cover-position.ts index 9f64c4b017f4..0f5652004e17 100644 --- a/src/state-control/cover/ha-state-control-cover-position.ts +++ b/src/state-control/cover/ha-state-control-cover-position.ts @@ -75,7 +75,7 @@ export class HaStateControlCoverPosition extends LitElement { max-height: 320px; min-height: 200px; --control-slider-thickness: 130px; - --control-slider-border-radius: 48px; + --control-slider-border-radius: 36px; --control-slider-color: var(--primary-color); --control-slider-background: var(--disabled-color); --control-slider-background-opacity: 0.2; diff --git a/src/state-control/cover/ha-state-control-cover-tilt-position.ts b/src/state-control/cover/ha-state-control-cover-tilt-position.ts index a3dccdf363a6..30d7160531d2 100644 --- a/src/state-control/cover/ha-state-control-cover-tilt-position.ts +++ b/src/state-control/cover/ha-state-control-cover-tilt-position.ts @@ -112,7 +112,7 @@ export class HaStateControlInfoCoverTiltPosition extends LitElement { max-height: 320px; min-height: 200px; --control-slider-thickness: 130px; - --control-slider-border-radius: 48px; + --control-slider-border-radius: 36px; --control-slider-color: var(--primary-color); --control-slider-background: var(--disabled-color); --control-slider-background-opacity: 0.2; diff --git a/src/state-control/cover/ha-state-control-cover-toggle.ts b/src/state-control/cover/ha-state-control-cover-toggle.ts index f743f774cd84..328a56352b55 100644 --- a/src/state-control/cover/ha-state-control-cover-toggle.ts +++ b/src/state-control/cover/ha-state-control-cover-toggle.ts @@ -142,7 +142,7 @@ export class HaStateControlCoverToggle extends LitElement { max-height: 320px; min-height: 200px; --control-switch-thickness: 130px; - --control-switch-border-radius: 48px; + --control-switch-border-radius: 36px; --control-switch-padding: 6px; --mdc-icon-size: 24px; } @@ -159,7 +159,7 @@ export class HaStateControlCoverToggle extends LitElement { ha-control-button { flex: 1; width: 100%; - --control-button-border-radius: 48px; + --control-button-border-radius: 36px; --mdc-icon-size: 24px; } ha-control-button.active { diff --git a/src/state-control/fan/ha-state-control-fan-speed.ts b/src/state-control/fan/ha-state-control-fan-speed.ts index f6bbb286b555..16e6d3a30ae1 100644 --- a/src/state-control/fan/ha-state-control-fan-speed.ts +++ b/src/state-control/fan/ha-state-control-fan-speed.ts @@ -142,7 +142,7 @@ export class HaStateControlFanSpeed extends LitElement { max-height: 320px; min-height: 200px; --control-slider-thickness: 130px; - --control-slider-border-radius: 48px; + --control-slider-border-radius: 36px; --control-slider-color: var(--primary-color); --control-slider-background: var(--disabled-color); --control-slider-background-opacity: 0.2; @@ -153,7 +153,7 @@ export class HaStateControlFanSpeed extends LitElement { max-height: 320px; min-height: 200px; --control-select-thickness: 130px; - --control-select-border-radius: 48px; + --control-select-border-radius: 36px; --control-select-color: var(--primary-color); --control-select-background: var(--disabled-color); --control-select-background-opacity: 0.2; diff --git a/src/state-control/ha-state-control-toggle.ts b/src/state-control/ha-state-control-toggle.ts index 8614f1d5055c..7d6c6971897d 100644 --- a/src/state-control/ha-state-control-toggle.ts +++ b/src/state-control/ha-state-control-toggle.ts @@ -133,7 +133,7 @@ export class HaStateControlToggle extends LitElement { max-height: 320px; min-height: 200px; --control-switch-thickness: 130px; - --control-switch-border-radius: 48px; + --control-switch-border-radius: 36px; --control-switch-padding: 6px; --mdc-icon-size: 24px; } @@ -150,7 +150,7 @@ export class HaStateControlToggle extends LitElement { ha-control-button { flex: 1; width: 100%; - --control-button-border-radius: 48px; + --control-button-border-radius: 36px; --mdc-icon-size: 24px; } ha-control-button.active { diff --git a/src/state-control/light/ha-state-control-light-brightness.ts b/src/state-control/light/ha-state-control-light-brightness.ts index 444dd1d7f04b..0205514cfa5e 100644 --- a/src/state-control/light/ha-state-control-light-brightness.ts +++ b/src/state-control/light/ha-state-control-light-brightness.ts @@ -89,7 +89,7 @@ export class HaStateControlLightBrightness extends LitElement { max-height: 320px; min-height: 200px; --control-slider-thickness: 130px; - --control-slider-border-radius: 48px; + --control-slider-border-radius: 36px; --control-slider-color: var(--primary-color); --control-slider-background: var(--disabled-color); --control-slider-background-opacity: 0.2; diff --git a/src/state-control/lock/ha-state-control-lock-toggle.ts b/src/state-control/lock/ha-state-control-lock-toggle.ts index d05eb393e2e6..bbc7c42ff0f0 100644 --- a/src/state-control/lock/ha-state-control-lock-toggle.ts +++ b/src/state-control/lock/ha-state-control-lock-toggle.ts @@ -167,7 +167,7 @@ export class HaStateControlLockToggle extends LitElement { max-height: 320px; min-height: 200px; --control-switch-thickness: 130px; - --control-switch-border-radius: 48px; + --control-switch-border-radius: 36px; --control-switch-padding: 6px; --mdc-icon-size: 24px; } @@ -187,7 +187,7 @@ export class HaStateControlLockToggle extends LitElement { ha-control-button { flex: 1; width: 100%; - --control-button-border-radius: 48px; + --control-button-border-radius: 36px; --mdc-icon-size: 24px; } ha-control-button.active { diff --git a/src/state-control/valve/ha-state-control-valve-position.ts b/src/state-control/valve/ha-state-control-valve-position.ts index af7a356f4191..a9b9714ca913 100644 --- a/src/state-control/valve/ha-state-control-valve-position.ts +++ b/src/state-control/valve/ha-state-control-valve-position.ts @@ -71,7 +71,7 @@ export class HaStateControlValvePosition extends LitElement { max-height: 320px; min-height: 200px; --control-slider-thickness: 130px; - --control-slider-border-radius: 48px; + --control-slider-border-radius: 36px; --control-slider-color: var(--primary-color); --control-slider-background: var(--disabled-color); --control-slider-background-opacity: 0.2; diff --git a/src/state-control/valve/ha-state-control-valve-toggle.ts b/src/state-control/valve/ha-state-control-valve-toggle.ts index cc74e52170d0..907a86e6f42c 100644 --- a/src/state-control/valve/ha-state-control-valve-toggle.ts +++ b/src/state-control/valve/ha-state-control-valve-toggle.ts @@ -142,7 +142,7 @@ export class HaStateControlValveToggle extends LitElement { max-height: 320px; min-height: 200px; --control-switch-thickness: 130px; - --control-switch-border-radius: 48px; + --control-switch-border-radius: 36px; --control-switch-padding: 6px; --mdc-icon-size: 24px; } @@ -159,7 +159,7 @@ export class HaStateControlValveToggle extends LitElement { ha-control-button { flex: 1; width: 100%; - --control-button-border-radius: 48px; + --control-button-border-radius: 36px; --mdc-icon-size: 24px; } ha-control-button.active { From 6301bc713c6296d844e8744619268ea8e240e91a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Apr 2024 15:16:00 +0200 Subject: [PATCH 08/13] Add multi select to devices (#20321) --- .../devices/ha-config-devices-dashboard.ts | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index 9f25431623b0..76e086cba941 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -1,6 +1,6 @@ import { consume } from "@lit-labs/context"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; -import { mdiPlus } from "@mdi/js"; +import { mdiChevronRight, mdiMenuDown, mdiPlus } from "@mdi/js"; import { CSSResultGroup, LitElement, @@ -13,6 +13,7 @@ import { import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; import { HASSDomEvent } from "../../../common/dom/fire_event"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { @@ -24,6 +25,7 @@ import { LocalizeFunc } from "../../../common/translations/localize"; import { DataTableColumnContainer, RowClickedEvent, + SelectionChangedEvent, } from "../../../components/data-table/ha-data-table"; import "../../../components/data-table/ha-data-table-labels"; import "../../../components/entity/ha-battery-icon"; @@ -37,12 +39,15 @@ import "../../../components/ha-filter-integrations"; import "../../../components/ha-filter-labels"; import "../../../components/ha-filter-states"; import "../../../components/ha-icon-button"; +import "../../../components/ha-menu-item"; +import "../../../components/ha-sub-menu"; import { ConfigEntry, sortConfigEntries } from "../../../data/config_entries"; import { fullEntitiesContext } from "../../../data/context"; import { DeviceEntityLookup, DeviceRegistryEntry, computeDeviceName, + updateDeviceRegistryEntry, } from "../../../data/device_registry"; import { EntityRegistryEntry, @@ -91,6 +96,8 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { @state() private _searchParms = new URLSearchParams(window.location.search); + @state() private _selected: string[] = []; + @state() private _filter: string = history.state?.filter || ""; @state() private _filters: Record< @@ -535,6 +542,21 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { this._labels ); + const labelItems = html` ${this._labels?.map((label) => { + const color = label.color ? computeCssColor(label.color) : undefined; + return html` + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })}`; + return html` + + ${!this.narrow + ? html` + + + + ${labelItems} + ` + : html` + + + + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.add_label" + )} +
+ +
+ ${labelItems} +
+
`}
`; } @@ -700,6 +768,25 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { }); } + private _handleSelectionChanged( + ev: HASSDomEvent + ): void { + this._selected = ev.detail.value; + } + + private async _handleBulkLabel(ev) { + const label = ev.currentTarget.value; + const promises: Promise[] = []; + this._selected.forEach((deviceId) => { + promises.push( + updateDeviceRegistryEntry(this.hass, deviceId, { + labels: this.hass.devices[deviceId].labels.concat(label), + }) + ); + }); + await Promise.all(promises); + } + static get styles(): CSSResultGroup { return [ css` @@ -721,6 +808,16 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { text-transform: uppercase; direction: var(--direction); } + ha-assist-chip { + --ha-assist-chip-container-shape: 10px; + } + ha-button-menu-new ha-assist-chip { + --md-assist-chip-trailing-space: 8px; + } + ha-label { + --ha-label-background-color: var(--color, var(--grey-color)); + --ha-label-background-opacity: 0.5; + } `, haStyle, ]; From cbb08c6202bbd6ce980b8aeea4cafacffbf4f777 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Apr 2024 15:16:10 +0200 Subject: [PATCH 09/13] Add multi select to scripts and scenes (#20318) --- src/panels/config/scene/ha-scene-dashboard.ts | 191 ++++++++++++++++- src/panels/config/script/ha-script-picker.ts | 192 +++++++++++++++++- 2 files changed, 381 insertions(+), 2 deletions(-) diff --git a/src/panels/config/scene/ha-scene-dashboard.ts b/src/panels/config/scene/ha-scene-dashboard.ts index ece548c7060c..cb8bebaaf177 100644 --- a/src/panels/config/scene/ha-scene-dashboard.ts +++ b/src/panels/config/scene/ha-scene-dashboard.ts @@ -1,10 +1,13 @@ import { consume } from "@lit-labs/context"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import { + mdiChevronRight, mdiContentDuplicate, mdiDelete, + mdiDotsVertical, mdiHelpCircle, mdiInformationOutline, + mdiMenuDown, mdiPalette, mdiPencilOff, mdiPlay, @@ -33,6 +36,7 @@ import { LocalizeFunc } from "../../../common/translations/localize"; import { DataTableColumnContainer, RowClickedEvent, + SelectionChangedEvent, } from "../../../components/data-table/ha-data-table"; import "../../../components/data-table/ha-data-table-labels"; import "../../../components/ha-button"; @@ -46,13 +50,19 @@ import "../../../components/ha-icon-button"; import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-state-icon"; import "../../../components/ha-svg-icon"; +import "../../../components/ha-menu-item"; +import "../../../components/ha-sub-menu"; import { CategoryRegistryEntry, subscribeCategoryRegistry, } from "../../../data/category_registry"; import { fullEntitiesContext } from "../../../data/context"; import { isUnavailableState } from "../../../data/entity"; -import { EntityRegistryEntry } from "../../../data/entity_registry"; +import { + EntityRegistryEntry, + UpdateEntityRegistryEntryResult, + updateEntityRegistryEntry, +} from "../../../data/entity_registry"; import { forwardHaptic } from "../../../data/haptics"; import { LabelRegistryEntry, @@ -77,6 +87,7 @@ import { documentationUrl } from "../../../util/documentation-url"; import { showToast } from "../../../util/toast"; import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import { configSections } from "../ha-panel-config"; +import { computeCssColor } from "../../../common/color/compute-color"; type SceneItem = SceneEntity & { name: string; @@ -98,6 +109,8 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { @state() private _searchParms = new URLSearchParams(window.location.search); + @state() private _selected: string[] = []; + @state() private _activeFilters?: string[]; @state() private _filteredScenes?: string[] | null; @@ -319,6 +332,40 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { } protected render(): TemplateResult { + const categoryItems = html`${this._categories?.map( + (category) => + html` + ${category.icon + ? html`` + : html``} +
${category.name}
+
` + )} + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.no_category" + )} +
+
`; + const labelItems = html` ${this._labels?.map((label) => { + const color = label.color ? computeCssColor(label.color) : undefined; + return html` + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })}`; + return html` filter.value?.length @@ -407,6 +457,103 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { @expanded-changed=${this._filterExpanded} > + ${!this.narrow + ? html` + + + + ${categoryItems} + + ${this.hass.dockedSidebar === "docked" + ? nothing + : html` + + + + ${labelItems} + `}` + : nothing} + ${this.narrow || this.hass.dockedSidebar === "docked" + ? html` + + ${ + this.narrow + ? html` + + ` + : html`` + } + + ${ + this.narrow + ? html` + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.move_category" + )} +
+ +
+ ${categoryItems} +
` + : nothing + } + ${ + this.narrow || this.hass.dockedSidebar === "docked" + ? html` + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.add_label" + )} +
+ +
+ ${labelItems} +
` + : nothing + } +
` + : nothing} ${!this.scenes.length ? html`
@@ -553,6 +700,12 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { this._applyFilters(); } + private _handleSelectionChanged( + ev: HASSDomEvent + ): void { + this._selected = ev.detail.value; + } + private _handleRowClicked(ev: HASSDomEvent) { const scene = this.scenes.find((a) => a.entity_id === ev.detail.id); @@ -561,6 +714,32 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { } } + private async _handleBulkCategory(ev) { + const category = ev.currentTarget.value; + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push( + updateEntityRegistryEntry(this.hass, entityId, { + categories: { scene: category }, + }) + ); + }); + await Promise.all(promises); + } + + private async _handleBulkLabel(ev) { + const label = ev.currentTarget.value; + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push( + updateEntityRegistryEntry(this.hass, entityId, { + labels: this.hass.entities[entityId].labels.concat(label), + }) + ); + }); + await Promise.all(promises); + } + private _editCategory(scene: any) { const entityReg = this._entityReg.find( (reg) => reg.entity_id === scene.entity_id @@ -664,6 +843,16 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { --mdc-icon-size: 80px; max-width: 500px; } + ha-assist-chip { + --ha-assist-chip-container-shape: 10px; + } + ha-button-menu-new ha-assist-chip { + --md-assist-chip-trailing-space: 8px; + } + ha-label { + --ha-label-background-color: var(--color, var(--grey-color)); + --ha-label-background-opacity: 0.5; + } `, ]; } diff --git a/src/panels/config/script/ha-script-picker.ts b/src/panels/config/script/ha-script-picker.ts index b5ddc8cc22dc..15441b45da11 100644 --- a/src/panels/config/script/ha-script-picker.ts +++ b/src/panels/config/script/ha-script-picker.ts @@ -1,9 +1,12 @@ import { consume } from "@lit-labs/context"; import { + mdiChevronRight, mdiContentDuplicate, mdiDelete, + mdiDotsVertical, mdiHelpCircle, mdiInformationOutline, + mdiMenuDown, mdiPlay, mdiPlus, mdiScriptText, @@ -34,6 +37,7 @@ import { LocalizeFunc } from "../../../common/translations/localize"; import { DataTableColumnContainer, RowClickedEvent, + SelectionChangedEvent, } from "../../../components/data-table/ha-data-table"; import "../../../components/data-table/ha-data-table-labels"; import "../../../components/ha-fab"; @@ -46,13 +50,19 @@ import "../../../components/ha-filter-labels"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-svg-icon"; +import "../../../components/ha-menu-item"; +import "../../../components/ha-sub-menu"; import { CategoryRegistryEntry, subscribeCategoryRegistry, } from "../../../data/category_registry"; import { fullEntitiesContext } from "../../../data/context"; import { UNAVAILABLE } from "../../../data/entity"; -import { EntityRegistryEntry } from "../../../data/entity_registry"; +import { + EntityRegistryEntry, + UpdateEntityRegistryEntryResult, + updateEntityRegistryEntry, +} from "../../../data/entity_registry"; import { LabelRegistryEntry, subscribeLabelRegistry, @@ -79,6 +89,7 @@ import { showToast } from "../../../util/toast"; import { showNewAutomationDialog } from "../automation/show-dialog-new-automation"; import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import { configSections } from "../ha-panel-config"; +import { computeCssColor } from "../../../common/color/compute-color"; type ScriptItem = ScriptEntity & { name: string; @@ -102,6 +113,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { @state() private _searchParms = new URLSearchParams(window.location.search); + @state() private _selected: string[] = []; + @state() private _activeFilters?: string[]; @state() private _filteredScripts?: string[] | null; @@ -331,6 +344,40 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { } protected render(): TemplateResult { + const categoryItems = html`${this._categories?.map( + (category) => + html` + ${category.icon + ? html`` + : html``} +
${category.name}
+
` + )} + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.no_category" + )} +
+
`; + const labelItems = html` ${this._labels?.map((label) => { + const color = label.color ? computeCssColor(label.color) : undefined; + return html` + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })}`; + return html` filter.value?.length ).length} @@ -432,6 +482,104 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { .narrow=${this.narrow} @expanded-changed=${this._filterExpanded} > + + ${!this.narrow + ? html` + + + + ${categoryItems} + + ${this.hass.dockedSidebar === "docked" + ? nothing + : html` + + + + ${labelItems} + `}` + : nothing} + ${this.narrow || this.hass.dockedSidebar === "docked" + ? html` + + ${ + this.narrow + ? html` + + ` + : html`` + } + + ${ + this.narrow + ? html` + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.move_category" + )} +
+ +
+ ${categoryItems} +
` + : nothing + } + ${ + this.narrow || this.hass.dockedSidebar === "docked" + ? html` + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.add_label" + )} +
+ +
+ ${labelItems} +
` + : nothing + } +
` + : nothing} ${!this.scripts.length ? html`
@@ -629,6 +777,38 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { }); } + private _handleSelectionChanged( + ev: HASSDomEvent + ): void { + this._selected = ev.detail.value; + } + + private async _handleBulkCategory(ev) { + const category = ev.currentTarget.value; + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push( + updateEntityRegistryEntry(this.hass, entityId, { + categories: { script: category }, + }) + ); + }); + await Promise.all(promises); + } + + private async _handleBulkLabel(ev) { + const label = ev.currentTarget.value; + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push( + updateEntityRegistryEntry(this.hass, entityId, { + labels: this.hass.entities[entityId].labels.concat(label), + }) + ); + }); + await Promise.all(promises); + } + private _handleRowClicked(ev: HASSDomEvent) { const entry = this.entityRegistry.find((e) => e.entity_id === ev.detail.id); if (entry) { @@ -782,6 +962,16 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { --mdc-icon-size: 80px; max-width: 500px; } + ha-assist-chip { + --ha-assist-chip-container-shape: 10px; + } + ha-button-menu-new ha-assist-chip { + --md-assist-chip-trailing-space: 8px; + } + ha-label { + --ha-label-background-color: var(--color, var(--grey-color)); + --ha-label-background-opacity: 0.5; + } `, ]; } From 8a015f4e3853eb219ff5b048cfe993f04111976e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Apr 2024 15:16:20 +0200 Subject: [PATCH 10/13] Update multi select of entities config (#20319) * Update multi select of entities config * Update ha-config-entities.ts --- .../config/entities/ha-config-entities.ts | 317 +++++++++++------- src/translations/en.json | 3 + 2 files changed, 208 insertions(+), 112 deletions(-) diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 434c85014db7..80adf624d587 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -3,12 +3,17 @@ import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import { mdiAlertCircle, mdiCancel, + mdiChevronRight, mdiDelete, + mdiDotsVertical, + mdiEye, mdiEyeOff, + mdiMenuDown, mdiPencilOff, mdiPlus, mdiRestoreAlert, - mdiUndo, + mdiToggleSwitch, + mdiToggleSwitchOffOutline, } from "@mdi/js"; import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { @@ -24,6 +29,7 @@ import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import { until } from "lit/directives/until"; import memoize from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; import type { HASSDomEvent } from "../../../common/dom/fire_event"; import { computeDomain } from "../../../common/entity/compute_domain"; import { computeStateName } from "../../../common/entity/compute_state_name"; @@ -44,16 +50,19 @@ import "../../../components/ha-check-list-item"; import "../../../components/ha-filter-devices"; import "../../../components/ha-filter-floor-areas"; import "../../../components/ha-filter-integrations"; -import "../../../components/ha-filter-states"; import "../../../components/ha-filter-labels"; +import "../../../components/ha-filter-states"; import "../../../components/ha-icon"; import "../../../components/ha-icon-button"; +import "../../../components/ha-menu-item"; +import "../../../components/ha-sub-menu"; import "../../../components/ha-svg-icon"; import { ConfigEntry, getConfigEntries } from "../../../data/config_entries"; import { fullEntitiesContext } from "../../../data/context"; import { UNAVAILABLE } from "../../../data/entity"; import { EntityRegistryEntry, + UpdateEntityRegistryEntryResult, computeEntityRegistryName, removeEntityRegistryEntry, updateEntityRegistryEntry, @@ -505,13 +514,28 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { [...filteredDomains][0] ); + const labelItems = html` ${this._labels?.map((label) => { + const color = label.color ? computeCssColor(label.color) : undefined; + return html` + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })}`; + return html` filter.value?.length - ).length} + .filters=${ + Object.values(this._filters).filter((filter) => filter.value?.length) + .length + } .filter=${this._filter} selectable .selected=${this._selectedEntities.length} @@ -543,100 +568,131 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { .hass=${this.hass} slot="toolbar-icon" > -
- ${!this.narrow - ? html` - ${this.hass.localize( - "ui.panel.config.entities.picker.enable_selected.button" - )} - ${this.hass.localize( - "ui.panel.config.entities.picker.disable_selected.button" - )} - ${this.hass.localize( - "ui.panel.config.entities.picker.hide_selected.button" - )} - ${this.hass.localize( - "ui.panel.config.entities.picker.remove_selected.button" - )} - ` - : html` - - - ${this.hass.localize( - "ui.panel.config.entities.picker.enable_selected.button" - )} - - - - ${this.hass.localize( - "ui.panel.config.entities.picker.disable_selected.button" - )} - - - - ${this.hass.localize( - "ui.panel.config.entities.picker.hide_selected.button" - )} - - - - ${this.hass.localize( - "ui.panel.config.entities.picker.remove_selected.button" - )} - - `} -
- ${this._filters.config_entry?.value?.length - ? html` - Filtering by config entry - ${this._entries?.find( - (entry) => - entry.entry_id === this._filters.config_entry!.value![0] - )?.title || this._filters.config_entry.value[0]} - ` - : nothing} + + +${ + !this.narrow + ? html` + + + + ${labelItems} + ` + : nothing +} + + ${ + this.narrow + ? html` + + ` + : html`` + } + + ${ + this.narrow + ? html` + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.add_label" + )} +
+ +
+ ${labelItems} +
+ ` + : nothing + } + + + +
+ ${this.hass.localize( + "ui.panel.config.entities.picker.enable_selected.button" + )} +
+
+ + +
+ ${this.hass.localize( + "ui.panel.config.entities.picker.disable_selected.button" + )} +
+
+ + + + +
+ ${this.hass.localize( + "ui.panel.config.entities.picker.unhide_selected.button" + )} +
+
+ + +
+ ${this.hass.localize( + "ui.panel.config.entities.picker.hide_selected.button" + )} +
+
+ + + + +
+ ${this.hass.localize( + "ui.panel.config.entities.picker.remove_selected.button" + )} +
+
+ +
+ ${ + this._filters.config_entry?.value?.length + ? html` + Filtering by config entry + ${this._entries?.find( + (entry) => + entry.entry_id === this._filters.config_entry!.value![0] + )?.title || this._filters.config_entry.value[0]} + ` + : nothing + } - ${includeAddDeviceFab - ? html` - - ` - : nothing} + ${ + includeAddDeviceFab + ? html` + + ` + : nothing + }
`; } @@ -931,6 +991,28 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { }); } + private _unhideSelected() { + this._selectedEntities.forEach((entity) => + updateEntityRegistryEntry(this.hass, entity, { + hidden_by: null, + }) + ); + this._clearSelection(); + } + + private async _handleBulkLabel(ev) { + const label = ev.currentTarget.value; + const promises: Promise[] = []; + this._selectedEntities.forEach((entityId) => { + promises.push( + updateEntityRegistryEntry(this.hass, entityId, { + labels: this.hass.entities[entityId].labels.concat(label), + }) + ); + }); + await Promise.all(promises); + } + private _removeSelected() { const removeableEntities = this._selectedEntities.filter((entity) => { const stateObj = this.hass.states[entity]; @@ -1080,6 +1162,17 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { text-transform: uppercase; direction: var(--direction); } + + ha-assist-chip { + --ha-assist-chip-container-shape: 10px; + } + ha-button-menu-new ha-assist-chip { + --md-assist-chip-trailing-space: 8px; + } + ha-label { + --ha-label-background-color: var(--color, var(--grey-color)); + --ha-label-background-opacity: 0.5; + } `, ]; } diff --git a/src/translations/en.json b/src/translations/en.json index 3f1e2570e5a2..2f0ed5d46c36 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4052,6 +4052,9 @@ "button": "Hide selected", "confirm_title": "Do you want to hide {number} {number, plural,\n one {entity}\n other {entities}\n}?", "confirm_text": "Hidden entities will not be shown on your dashboard. Their history is still tracked and you can still interact with them with services." + }, + "unhide_selected": { + "button": "Unhide selected" } } }, From 169d7825805174735fbdba7a738274f6964ff8fd Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 2 Apr 2024 15:20:22 +0200 Subject: [PATCH 11/13] Fix label wrap (#20323) * Don't wrap headline on ha-menu-item * Don't wrap text on ha-label --- src/components/ha-label.ts | 1 + src/components/ha-menu-item.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/ha-label.ts b/src/components/ha-label.ts index e542b06be531..d9d82052015b 100644 --- a/src/components/ha-label.ts +++ b/src/components/ha-label.ts @@ -43,6 +43,7 @@ class HaLabel extends LitElement { border-radius: 18px; color: var(--ha-label-text-color); --mdc-icon-size: 12px; + text-wrap: nowrap; } .content > * { position: relative; diff --git a/src/components/ha-menu-item.ts b/src/components/ha-menu-item.ts index 1b2c71f251e4..9c03e4e7f567 100644 --- a/src/components/ha-menu-item.ts +++ b/src/components/ha-menu-item.ts @@ -1,7 +1,7 @@ -import { customElement } from "lit/decorators"; +import { MdMenuItem } from "@material/web/menu/menu-item"; import "element-internals-polyfill"; import { CSSResult, css } from "lit"; -import { MdMenuItem } from "@material/web/menu/menu-item"; +import { customElement } from "lit/decorators"; @customElement("ha-menu-item") export class HaMenuItem extends MdMenuItem { @@ -30,6 +30,9 @@ export class HaMenuItem extends MdMenuItem { --md-menu-item-label-text-color: var(--error-color); --md-menu-item-leading-icon-color: var(--error-color); } + ::slotted([slot="headline"]) { + text-wrap: nowrap; + } `, ]; } From 21ed8e4206273e769424c7deaf6d32a853ce3637 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Apr 2024 15:33:10 +0200 Subject: [PATCH 12/13] Load translations when adding item in pickers (#20325) * Load translations when adding item in pickers * Update ha-selector-label.ts --- src/components/ha-area-picker.ts | 2 ++ src/components/ha-floor-picker.ts | 2 ++ src/components/ha-label-picker.ts | 2 ++ src/components/ha-selector/ha-selector-label.ts | 2 ++ src/panels/config/category/ha-category-picker.ts | 2 ++ 5 files changed, 10 insertions(+) diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 0acadf2474e3..5fe9b63d5eec 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -428,6 +428,8 @@ export class HaAreaPicker extends LitElement { (ev.target as any).value = this._value; + this.hass.loadFragmentTranslation("config"); + showAreaRegistryDetailDialog(this, { suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", createEntry: async (values) => { diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index 6935c3a9f4aa..f8e29dc7ef48 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -437,6 +437,8 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { (ev.target as any).value = this._value; + this.hass.loadFragmentTranslation("config"); + showFloorRegistryDetailDialog(this, { suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", createEntry: async (values) => { diff --git a/src/components/ha-label-picker.ts b/src/components/ha-label-picker.ts index 15b42c07b266..289b7ba269c1 100644 --- a/src/components/ha-label-picker.ts +++ b/src/components/ha-label-picker.ts @@ -445,6 +445,8 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { (ev.target as any).value = this._value; + this.hass.loadFragmentTranslation("config"); + showLabelDetailDialog(this, { entry: undefined, suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", diff --git a/src/components/ha-selector/ha-selector-label.ts b/src/components/ha-selector/ha-selector-label.ts index 14e90e8f7d69..f8a091769214 100644 --- a/src/components/ha-selector/ha-selector-label.ts +++ b/src/components/ha-selector/ha-selector-label.ts @@ -30,6 +30,7 @@ export class HaLabelSelector extends LitElement { if (this.selector.label.multiple) { return html` Date: Tue, 2 Apr 2024 16:33:05 +0200 Subject: [PATCH 13/13] Bumped version to 20240402.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index caf1247abc3b..f19e51af4f57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20240402.0" +version = "20240402.1" license = {text = "Apache-2.0"} description = "The Home Assistant frontend" readme = "README.md"