From b890ef2b01285c25039d965c23e1f397b4e55743 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 24 Jun 2025 14:23:13 +0200 Subject: [PATCH] Group area per floor in the editor --- .../ha-areas-floors-display-editor.ts | 213 ++++++++++++++++++ .../ha-automation-trigger-numeric_state.ts | 2 +- .../areas/areas-overview-view-strategy.ts | 13 +- .../hui-areas-dashboard-strategy-editor.ts | 5 +- src/translations/en.json | 3 +- 5 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 src/components/ha-areas-floors-display-editor.ts diff --git a/src/components/ha-areas-floors-display-editor.ts b/src/components/ha-areas-floors-display-editor.ts new file mode 100644 index 0000000000..578b1d7841 --- /dev/null +++ b/src/components/ha-areas-floors-display-editor.ts @@ -0,0 +1,213 @@ +import { mdiTextureBox } from "@mdi/js"; +import type { TemplateResult } from "lit"; +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { computeFloorName } from "../common/entity/compute_floor_name"; +import { getAreaContext } from "../common/entity/context/get_area_context"; +import { stringCompare } from "../common/string/compare"; +import { areaCompare } from "../data/area_registry"; +import type { FloorRegistryEntry } from "../data/floor_registry"; +import type { HomeAssistant } from "../types"; +import "./ha-expansion-panel"; +import "./ha-floor-icon"; +import "./ha-items-display-editor"; +import type { DisplayItem, DisplayValue } from "./ha-items-display-editor"; +import "./ha-svg-icon"; +import "./ha-textfield"; + +export interface AreasDisplayValue { + hidden?: string[]; + order?: string[]; +} + +const UNASSIGNED_FLOOR = "__unassigned__"; + +@customElement("ha-areas-floors-display-editor") +export class HaAreasFloorsDisplayEditor extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property({ attribute: false }) public value?: AreasDisplayValue; + + @property() public helper?: string; + + @property({ type: Boolean }) public expanded = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @property({ type: Boolean, attribute: "show-navigation-button" }) + public showNavigationButton = false; + + protected render(): TemplateResult { + const compare = areaCompare(this.hass.areas); + + const areas = Object.values(this.hass.areas).sort((areaA, areaB) => + compare(areaA.area_id, areaB.area_id) + ); + + const groupedItems: Record = areas.reduce( + (acc, area) => { + const { floor } = getAreaContext(area, this.hass!); + const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR; + + if (!acc[floorId]) { + acc[floorId] = []; + } + acc[floorId].push({ + value: area.area_id, + label: area.name, + icon: area.icon ?? undefined, + iconPath: mdiTextureBox, + description: floor?.name, + }); + + return acc; + }, + {} as Record + ); + + const filteredFloors = this._sortedFloors(this.hass.floors).filter( + (floor) => + // Only include floors that have areas assigned to them + groupedItems[floor.floor_id]?.length > 0 + ); + + const value: DisplayValue = { + order: this.value?.order ?? [], + hidden: this.value?.hidden ?? [], + }; + + return html` + + + ${filteredFloors.map( + (floor) => html` +
+
+ +

${computeFloorName(floor)}

+
+
+ +
+
+ ` + )} +
+ `; + } + + private _sortedFloors = memoizeOne( + (hassFloors: HomeAssistant["floors"]): FloorRegistryEntry[] => { + const floors = Object.values(hassFloors).sort((floorA, floorB) => { + if (floorA.level !== floorB.level) { + return (floorA.level ?? 0) - (floorB.level ?? 0); + } + return stringCompare(floorA.name, floorB.name); + }); + floors.push({ + floor_id: UNASSIGNED_FLOOR, + name: this.hass.localize( + "ui.panel.lovelace.strategy.areas.unassigned_areas" + ), + icon: null, + level: 999999, + aliases: [], + created_at: 0, + modified_at: 0, + }); + return floors; + } + ); + + private async _areaDisplayChanged(ev) { + ev.stopPropagation(); + const value = ev.detail.value as DisplayValue; + const currentFloorId = ev.currentTarget.floorId; + + const floorIds = this._sortedFloors(this.hass.floors).map( + (floor) => floor.floor_id + ); + + const newHidden: string[] = []; + const newOrder: string[] = []; + + for (const floorId of floorIds) { + if (currentFloorId === floorId) { + newHidden.push(...(value.hidden ?? [])); + newOrder.push(...(value.order ?? [])); + continue; + } + const hidden = this.value?.hidden?.filter( + (areaId) => this.hass.areas[areaId]?.floor_id === floorId + ); + if (hidden) { + newHidden.push(...hidden); + } + const order = this.value?.order?.filter( + (areaId) => this.hass.areas[areaId]?.floor_id === floorId + ); + if (order) { + newOrder.push(...order); + } + } + + const newValue: AreasDisplayValue = { + hidden: newHidden, + order: newOrder, + }; + if (newValue.hidden?.length === 0) { + delete newValue.hidden; + } + if (newValue.order?.length === 0) { + delete newValue.order; + } + + fireEvent(this, "value-changed", { value: newValue }); + } + + static get styles() { + return [ + css` + .floor .header p { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + flex: 1: + } + .floor .header { + margin: 16px 0 8px 0; + padding: 0 8px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-areas-floors-display-editor": HaAreasFloorsDisplayEditor; + } +} diff --git a/src/panels/config/automation/trigger/types/ha-automation-trigger-numeric_state.ts b/src/panels/config/automation/trigger/types/ha-automation-trigger-numeric_state.ts index db78f6fcd4..7b6aafdf71 100644 --- a/src/panels/config/automation/trigger/types/ha-automation-trigger-numeric_state.ts +++ b/src/panels/config/automation/trigger/types/ha-automation-trigger-numeric_state.ts @@ -202,7 +202,7 @@ export class HaNumericStateTrigger extends LitElement { entity: { domain: ["input_number", "number", "sensor"] }, }, }, - ] as const) + ] as const satisfies HaFormSchema[]) : ([ { name: "below", diff --git a/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts b/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts index 74bbd0a38c..230b2230fe 100644 --- a/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts +++ b/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts @@ -11,6 +11,8 @@ import type { AreaCardConfig, HeadingCardConfig } from "../../cards/types"; import type { EntitiesDisplay } from "./area-view-strategy"; import { computeAreaPath, getAreas } from "./helpers/areas-strategy-helper"; +const UNASSIGNED_FLOOR = "__unassigned__"; + interface AreaOptions { groups_options?: Record; } @@ -46,13 +48,20 @@ export class AreasOverviewViewStrategy extends ReactiveElement { const floorSections = [ ...floors, - { floor_id: "default", name: "Default", level: null, icon: null }, + { + floor_id: UNASSIGNED_FLOOR, + name: hass.localize( + "ui.panel.lovelace.strategy.areas.unassigned_areas" + ), + level: null, + icon: null, + }, ] .map((floor) => { const areasInFloors = areas.filter( (area) => area.floor_id === floor.floor_id || - (!area.floor_id && floor.floor_id === "default") + (!area.floor_id && floor.floor_id === UNASSIGNED_FLOOR) ); if (areasInFloors.length === 0) { diff --git a/src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts b/src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts index 6edf10b22c..311641caa2 100644 --- a/src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts +++ b/src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts @@ -22,6 +22,7 @@ import { type AreaRegistryEntry, } from "../../../../../data/area_registry"; import { buttonLinkStyle } from "../../../../../resources/styles"; +import "../../../../../components/ha-areas-floors-display-editor"; @customElement("hui-areas-dashboard-strategy-editor") export class HuiAreasDashboardStrategyEditor @@ -122,7 +123,7 @@ export class HuiAreasDashboardStrategyEditor const value = this._config.areas_display; return html` - + > `; } diff --git a/src/translations/en.json b/src/translations/en.json index 028dd1a6f6..f5869e5c19 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6618,7 +6618,8 @@ "security": "Security", "actions": "Actions", "others": "Others" - } + }, + "unassigned_areas": "[%key:ui::panel::config::areas::picker::unassigned_areas%]" } }, "cards": {