From 64b91041990bdf3e406a5adbf84658f191acbbaf Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 17 Mar 2025 22:04:01 +0100 Subject: [PATCH] Add area dashboard settings (#24619) * Add hidden and order settings * Share path logic * Add editor to sort and filter areas * Remove unused form * Add areas strategy in the dashboard picker * Move display editor * Fix min width * Add leading icon slot to expansion panel * Fix left chevron icon with dynamic property * Use area display in original state strategy * Rename selector * Rename to area_display * Remove ha-expansion-panel changes * Remove expanded * Fix rebase * Display all entities in the areas strategy overview (#24663) * Don't use subgroup * Add all entities in the overview * Add tile card features for area view --- src/common/entity/context/get_area_context.ts | 30 ++ src/common/entity/entity_filter.ts | 2 +- src/components/ha-area-filter.ts | 95 ----- src/components/ha-areas-display-editor.ts | 98 +++++ src/components/ha-items-display-editor.ts | 241 ++++++++++++ ...filter.ts => ha-selector-areas-display.ts} | 16 +- src/components/ha-selector/ha-selector.ts | 2 +- src/data/selector.ts | 6 +- src/dialogs/area-filter/area-filter-dialog.ts | 203 ---------- .../area-filter/show-area-filter-dialog.ts | 38 -- .../config/dashboard/dialog-new-dashboard.ts | 6 +- .../common/generate-lovelace-config.ts | 4 +- .../dialog-dashboard-strategy-editor.ts | 18 +- ...iginal-states-dashboard-strategy-editor.ts | 2 +- .../strategies/area/area-view-strategy.ts | 365 +++++++++--------- .../areas/areas-dashboard-strategy.ts | 51 ++- .../strategies/areas/areas-view-strategy.ts | 96 +++-- .../hui-areas-dashboard-strategy-editor.ts | 59 +++ .../areas/helpers/areas-strategy-helpers.ts | 25 ++ .../original-states-view-strategy.ts | 4 +- src/translations/en.json | 13 +- 21 files changed, 779 insertions(+), 595 deletions(-) create mode 100644 src/common/entity/context/get_area_context.ts delete mode 100644 src/components/ha-area-filter.ts create mode 100644 src/components/ha-areas-display-editor.ts create mode 100644 src/components/ha-items-display-editor.ts rename src/components/ha-selector/{ha-selector-area-filter.ts => ha-selector-areas-display.ts} (64%) delete mode 100644 src/dialogs/area-filter/area-filter-dialog.ts delete mode 100644 src/dialogs/area-filter/show-area-filter-dialog.ts create mode 100644 src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts create mode 100644 src/panels/lovelace/strategies/areas/helpers/areas-strategy-helpers.ts diff --git a/src/common/entity/context/get_area_context.ts b/src/common/entity/context/get_area_context.ts new file mode 100644 index 0000000000..6b05bf5d63 --- /dev/null +++ b/src/common/entity/context/get_area_context.ts @@ -0,0 +1,30 @@ +import type { AreaRegistryEntry } from "../../../data/area_registry"; +import type { FloorRegistryEntry } from "../../../data/floor_registry"; +import type { HomeAssistant } from "../../../types"; + +interface AreaContext { + area: AreaRegistryEntry | null; + floor: FloorRegistryEntry | null; +} + +export const getAreaContext = ( + areaId: string, + hass: HomeAssistant +): AreaContext => { + const area = (hass.areas[areaId] as AreaRegistryEntry | undefined) || null; + + if (!area) { + return { + area: null, + floor: null, + }; + } + + const floorId = area?.floor_id; + const floor = floorId ? hass.floors[floorId] : null; + + return { + area: area, + floor: floor, + }; +}; diff --git a/src/common/entity/entity_filter.ts b/src/common/entity/entity_filter.ts index 53558e5298..072520d8a0 100644 --- a/src/common/entity/entity_filter.ts +++ b/src/common/entity/entity_filter.ts @@ -17,7 +17,7 @@ export interface EntityFilter { hidden_platform?: string | string[]; } -type EntityFilterFunc = (entityId: string) => boolean; +export type EntityFilterFunc = (entityId: string) => boolean; export const generateEntityFilter = ( hass: HomeAssistant, diff --git a/src/components/ha-area-filter.ts b/src/components/ha-area-filter.ts deleted file mode 100644 index b34af2bd51..0000000000 --- a/src/components/ha-area-filter.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { mdiTextureBox } from "@mdi/js"; -import type { TemplateResult } from "lit"; -import { LitElement, css, html } from "lit"; -import { customElement, property } from "lit/decorators"; -import { fireEvent } from "../common/dom/fire_event"; -import { showAreaFilterDialog } from "../dialogs/area-filter/show-area-filter-dialog"; -import type { HomeAssistant } from "../types"; -import "./ha-icon-next"; -import "./ha-svg-icon"; -import "./ha-textfield"; - -export interface AreaFilterValue { - hidden?: string[]; - order?: string[]; -} - -@customElement("ha-area-filter") -export class HaAreaPicker extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property() public label?: string; - - @property({ attribute: false }) public value?: AreaFilterValue; - - @property() public helper?: string; - - @property({ type: Boolean }) public disabled = false; - - @property({ type: Boolean }) public required = false; - - protected render(): TemplateResult { - const allAreasCount = Object.keys(this.hass.areas).length; - const hiddenAreasCount = this.value?.hidden?.length ?? 0; - - const description = - hiddenAreasCount === 0 - ? this.hass.localize("ui.components.area-filter.all_areas") - : allAreasCount === hiddenAreasCount - ? this.hass.localize("ui.components.area-filter.no_areas") - : this.hass.localize("ui.components.area-filter.area_count", { - count: allAreasCount - hiddenAreasCount, - }); - - return html` - - - ${this.label} - ${description} - - - `; - } - - private async _edit(ev) { - if (ev.defaultPrevented) { - return; - } - if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") { - return; - } - ev.preventDefault(); - ev.stopPropagation(); - const value = await showAreaFilterDialog(this, { - title: this.label, - initialValue: this.value, - }); - if (!value) return; - fireEvent(this, "value-changed", { value }); - } - - static styles = css` - ha-list-item { - --mdc-list-side-padding-left: 8px; - --mdc-list-side-padding-right: 8px; - } - `; -} - -declare global { - interface HTMLElementTagNameMap { - "ha-area-filter": HaAreaPicker; - } -} diff --git a/src/components/ha-areas-display-editor.ts b/src/components/ha-areas-display-editor.ts new file mode 100644 index 0000000000..340ae2126f --- /dev/null +++ b/src/components/ha-areas-display-editor.ts @@ -0,0 +1,98 @@ +import { mdiTextureBox } from "@mdi/js"; +import type { TemplateResult } from "lit"; +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { getAreaContext } from "../common/entity/context/get_area_context"; +import { areaCompare } from "../data/area_registry"; +import type { HomeAssistant } from "../types"; +import "./ha-expansion-panel"; +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[]; +} + +@customElement("ha-areas-display-editor") +export class HaAreasDisplayEditor 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; + + protected render(): TemplateResult { + const compare = areaCompare(this.hass.areas, this.value?.order); + + const areas = Object.values(this.hass.areas).sort((areaA, areaB) => + compare(areaA.area_id, areaB.area_id) + ); + + const items: DisplayItem[] = areas.map((area) => { + const { floor } = getAreaContext(area.area_id, this.hass!); + return { + value: area.area_id, + label: area.name, + icon: area.icon ?? undefined, + iconPath: mdiTextureBox, + description: floor?.name, + }; + }); + + const value: DisplayValue = { + order: this.value?.order ?? [], + hidden: this.value?.hidden ?? [], + }; + + return html` + + + + + `; + } + + private async _areaDisplayChanged(ev) { + ev.stopPropagation(); + const value = ev.detail.value as DisplayValue; + const newValue: AreasDisplayValue = { + ...this.value, + ...value, + }; + if (newValue.hidden?.length === 0) { + delete newValue.hidden; + } + if (newValue.order?.length === 0) { + delete newValue.order; + } + + fireEvent(this, "value-changed", { value: newValue }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-areas-display-editor": HaAreasDisplayEditor; + } +} diff --git a/src/components/ha-items-display-editor.ts b/src/components/ha-items-display-editor.ts new file mode 100644 index 0000000000..6feb75c207 --- /dev/null +++ b/src/components/ha-items-display-editor.ts @@ -0,0 +1,241 @@ +import { ResizeController } from "@lit-labs/observers/resize-controller"; +import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { repeat } from "lit/directives/repeat"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import type { HomeAssistant } from "../types"; +import "./ha-icon"; +import "./ha-icon-button"; +import "./ha-icon-button-next"; +import "./ha-md-list"; +import "./ha-md-list-item"; +import "./ha-sortable"; +import "./ha-svg-icon"; + +export interface DisplayItem { + icon?: string; + iconPath?: string; + value: string; + label: string; + description?: string; +} + +export interface DisplayValue { + order: string[]; + hidden: string[]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-items-display-editor": HaItemDisplayEditor; + } + interface HASSDomEvents { + "item-display-navigate-clicked": { value: string }; + } +} + +@customElement("ha-items-display-editor") +export class HaItemDisplayEditor extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public items: DisplayItem[] = []; + + @property({ type: Boolean, attribute: "show-navigation-button" }) + public showNavigationButton = false; + + @property({ attribute: false }) + public value: DisplayValue = { + order: [], + hidden: [], + }; + + private _showIcon = new ResizeController(this, { + callback: (entries) => entries[0]?.contentRect.width > 450, + }); + + private _toggle(ev) { + ev.stopPropagation(); + const value = ev.currentTarget.value; + + const hiddenItems = this._hiddenItems(this.items, this.value.hidden); + + const newHidden = hiddenItems.map((item) => item.value); + + if (newHidden.includes(value)) { + newHidden.splice(newHidden.indexOf(value), 1); + } else { + newHidden.push(value); + } + + const newVisibleItems = this._visibleItems(this.items, newHidden); + const newOrder = newVisibleItems.map((a) => a.value); + + this.value = { + hidden: newHidden, + order: newOrder, + }; + fireEvent(this, "value-changed", { value: this.value }); + } + + private _itemMoved(ev: CustomEvent): void { + ev.stopPropagation(); + const { oldIndex, newIndex } = ev.detail; + + const visibleItems = this._visibleItems(this.items, this.value.hidden); + const newOrder = visibleItems.map((item) => item.value); + + const movedItem = newOrder.splice(oldIndex, 1)[0]; + newOrder.splice(newIndex, 0, movedItem); + + this.value = { + ...this.value, + order: newOrder, + }; + fireEvent(this, "value-changed", { value: this.value }); + } + + private _navigate(ev) { + const value = ev.currentTarget.value; + fireEvent(this, "item-display-navigate-clicked", { value }); + ev.stopPropagation(); + } + + private _visibleItems = memoizeOne((items: DisplayItem[], hidden: string[]) => + items.filter((item) => !hidden.includes(item.value)) + ); + + private _hiddenItems = memoizeOne((items: DisplayItem[], hidden: string[]) => + items.filter((item) => hidden.includes(item.value)) + ); + + protected render() { + const allItems = [ + ...this._visibleItems(this.items, this.value.hidden), + ...this._hiddenItems(this.items, this.value.hidden), + ]; + + const showIcon = this._showIcon.value; + return html` + + + ${repeat( + allItems, + (item) => item.value, + (item, _idx) => { + const isVisible = !this.value.hidden.includes(item.value); + const { label, value, description, icon, iconPath } = item; + return html` + + ${label} + ${description + ? html`${description}` + : nothing} + ${isVisible + ? html` + + ` + : html``} + ${!showIcon + ? nothing + : icon + ? html` + + ` + : iconPath + ? html` + + ` + : nothing} + + ${this.showNavigationButton + ? html` + + ` + : nothing} + + `; + } + )} + + + `; + } + + static styles = css` + :host { + display: block; + } + .handle { + cursor: move; + padding: 8px; + margin: -8px; + } + ha-md-list { + padding: 0; + } + ha-md-list-item { + --md-list-item-top-space: 0; + --md-list-item-bottom-space: 0; + --md-list-item-leading-space: 8px; + --md-list-item-trailing-space: 8px; + --md-list-item-two-line-container-height: 48px; + --md-list-item-one-line-container-height: 48px; + } + ha-md-list-item ha-icon-button { + margin-left: -12px; + margin-right: -12px; + } + ha-md-list-item.hidden { + --md-list-item-label-text-color: var(--disabled-text-color); + --md-list-item-supporting-text-color: var(--disabled-text-color); + } + ha-md-list-item.hidden .icon { + color: var(--disabled-text-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-items-display-editor": HaItemDisplayEditor; + } +} diff --git a/src/components/ha-selector/ha-selector-area-filter.ts b/src/components/ha-selector/ha-selector-areas-display.ts similarity index 64% rename from src/components/ha-selector/ha-selector-area-filter.ts rename to src/components/ha-selector/ha-selector-areas-display.ts index 6237aec0ef..d6b02593a3 100644 --- a/src/components/ha-selector/ha-selector-area-filter.ts +++ b/src/components/ha-selector/ha-selector-areas-display.ts @@ -1,14 +1,14 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators"; -import type { AreaFilterSelector } from "../../data/selector"; +import type { AreasDisplaySelector } from "../../data/selector"; import type { HomeAssistant } from "../../types"; -import "../ha-area-filter"; +import "../ha-areas-display-editor"; -@customElement("ha-selector-area_filter") -export class HaAreaFilterSelector extends LitElement { +@customElement("ha-selector-areas_display") +export class HaAreasDisplaySelector extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public selector!: AreaFilterSelector; + @property({ attribute: false }) public selector!: AreasDisplaySelector; @property() public value?: any; @@ -22,20 +22,20 @@ export class HaAreaFilterSelector extends LitElement { protected render() { return html` - + > `; } } declare global { interface HTMLElementTagNameMap { - "ha-selector-area_filter": HaAreaFilterSelector; + "ha-selector-areas_display": HaAreasDisplaySelector; } } diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index dd45f13d8d..69a5b59a25 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -14,7 +14,7 @@ const LOAD_ELEMENTS = { action: () => import("./ha-selector-action"), addon: () => import("./ha-selector-addon"), area: () => import("./ha-selector-area"), - area_filter: () => import("./ha-selector-area-filter"), + areas_display: () => import("./ha-selector-areas-display"), attribute: () => import("./ha-selector-attribute"), assist_pipeline: () => import("./ha-selector-assist-pipeline"), boolean: () => import("./ha-selector-boolean"), diff --git a/src/data/selector.ts b/src/data/selector.ts index ba422c6833..8993e0e5c3 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -23,7 +23,7 @@ export type Selector = | ActionSelector | AddonSelector | AreaSelector - | AreaFilterSelector + | AreasDisplaySelector | AttributeSelector | BooleanSelector | ButtonToggleSelector @@ -92,8 +92,8 @@ export interface AreaSelector { } | null; } -export interface AreaFilterSelector { - area_filter: {} | null; +export interface AreasDisplaySelector { + areas_display: {} | null; } export interface AttributeSelector { diff --git a/src/dialogs/area-filter/area-filter-dialog.ts b/src/dialogs/area-filter/area-filter-dialog.ts deleted file mode 100644 index 56fce665b3..0000000000 --- a/src/dialogs/area-filter/area-filter-dialog.ts +++ /dev/null @@ -1,203 +0,0 @@ -import "@material/mwc-list/mwc-list"; -import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js"; -import type { CSSResultGroup } from "lit"; -import { LitElement, css, html, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; -import { repeat } from "lit/directives/repeat"; -import { fireEvent } from "../../common/dom/fire_event"; -import type { AreaFilterValue } from "../../components/ha-area-filter"; -import "../../components/ha-button"; -import "../../components/ha-dialog"; -import "../../components/ha-icon-button"; -import "../../components/ha-list-item"; -import "../../components/ha-sortable"; -import { areaCompare } from "../../data/area_registry"; -import { haStyleDialog } from "../../resources/styles"; -import type { HomeAssistant } from "../../types"; -import type { HassDialog } from "../make-dialog-manager"; -import type { AreaFilterDialogParams } from "./show-area-filter-dialog"; - -@customElement("dialog-area-filter") -export class DialogAreaFilter - extends LitElement - implements HassDialog -{ - @property({ attribute: false }) public hass?: HomeAssistant; - - @state() private _dialogParams?: AreaFilterDialogParams; - - @state() private _hidden: string[] = []; - - @state() private _areas: string[] = []; - - public showDialog(dialogParams: AreaFilterDialogParams): void { - this._dialogParams = dialogParams; - this._hidden = dialogParams.initialValue?.hidden ?? []; - const order = dialogParams.initialValue?.order ?? []; - const allAreas = Object.keys(this.hass!.areas); - this._areas = allAreas.concat().sort(areaCompare(this.hass!.areas, order)); - } - - public closeDialog() { - this._dialogParams = undefined; - this._hidden = []; - this._areas = []; - fireEvent(this, "dialog-closed", { dialog: this.localName }); - return true; - } - - private _submit(): void { - const order = this._areas.filter((area) => !this._hidden.includes(area)); - const value: AreaFilterValue = { - hidden: this._hidden, - order, - }; - this._dialogParams?.submit?.(value); - this.closeDialog(); - } - - private _cancel(): void { - this._dialogParams?.cancel?.(); - this.closeDialog(); - } - - private _areaMoved(ev: CustomEvent): void { - ev.stopPropagation(); - const { oldIndex, newIndex } = ev.detail; - - const areas = this._areas.concat(); - - const option = areas.splice(oldIndex, 1)[0]; - areas.splice(newIndex, 0, option); - - this._areas = areas; - } - - protected render() { - if (!this._dialogParams || !this.hass) { - return nothing; - } - - const allAreas = this._areas; - - return html` - - - - ${repeat( - allAreas, - (area) => area, - (area, _idx) => { - const isVisible = !this._hidden.includes(area); - const name = this.hass!.areas[area]?.name || area; - return html` - - ${isVisible - ? html`` - : nothing} - ${name} - - - `; - } - )} - - - - ${this.hass.localize("ui.common.cancel")} - - - ${this.hass.localize("ui.common.submit")} - - - `; - } - - private _toggle(ev) { - const area = ev.target.area; - const hidden = [...(this._hidden ?? [])]; - if (hidden.includes(area)) { - hidden.splice(hidden.indexOf(area), 1); - } else { - hidden.push(area); - } - this._hidden = hidden; - const nonHiddenAreas = this._areas.filter( - (ar) => !this._hidden.includes(ar) - ); - const hiddenAreas = this._areas.filter((ar) => this._hidden.includes(ar)); - this._areas = [...nonHiddenAreas, ...hiddenAreas]; - } - - static get styles(): CSSResultGroup { - return [ - haStyleDialog, - css` - ha-dialog { - /* Place above other dialogs */ - --dialog-z-index: 104; - --dialog-content-padding: 0; - } - ha-list-item { - overflow: visible; - } - .hidden { - color: var(--disabled-text-color); - } - .handle { - cursor: move; /* fallback if grab cursor is unsupported */ - cursor: grab; - } - .actions { - display: flex; - flex-direction: row; - } - ha-icon-button { - display: block; - margin: -12px; - } - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "dialog-area-filter": DialogAreaFilter; - } -} diff --git a/src/dialogs/area-filter/show-area-filter-dialog.ts b/src/dialogs/area-filter/show-area-filter-dialog.ts deleted file mode 100644 index 148db2d8bc..0000000000 --- a/src/dialogs/area-filter/show-area-filter-dialog.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { fireEvent } from "../../common/dom/fire_event"; -import type { AreaFilterValue } from "../../components/ha-area-filter"; - -export interface AreaFilterDialogParams { - title?: string; - initialValue?: AreaFilterValue; - submit?: (value?: AreaFilterValue) => void; - cancel?: () => void; -} - -export const showAreaFilterDialog = ( - element: HTMLElement, - dialogParams: AreaFilterDialogParams -) => - new Promise((resolve) => { - const origCancel = dialogParams.cancel; - const origSubmit = dialogParams.submit; - - fireEvent(element, "show-dialog", { - dialogTag: "dialog-area-filter", - dialogImport: () => import("./area-filter-dialog"), - dialogParams: { - ...dialogParams, - cancel: () => { - resolve(null); - if (origCancel) { - origCancel(); - } - }, - submit: (code: AreaFilterValue) => { - resolve(code); - if (origSubmit) { - origSubmit(code); - } - }, - }, - }); - }); diff --git a/src/panels/config/dashboard/dialog-new-dashboard.ts b/src/panels/config/dashboard/dialog-new-dashboard.ts index 6f9ff9a34d..33040d8f2c 100644 --- a/src/panels/config/dashboard/dialog-new-dashboard.ts +++ b/src/panels/config/dashboard/dialog-new-dashboard.ts @@ -1,5 +1,5 @@ import "@material/mwc-list/mwc-list"; -import { mdiMap, mdiPencilOutline, mdiShape, mdiWeb } from "@mdi/js"; +import { mdiHome, mdiMap, mdiPencilOutline, mdiShape, mdiWeb } from "@mdi/js"; import type { CSSResultGroup } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; @@ -30,6 +30,10 @@ const STRATEGIES = [ type: "iframe", iconPath: mdiWeb, }, + { + type: "areas", + iconPath: mdiHome, + }, ] as const satisfies Strategy[]; @customElement("ha-dialog-new-dashboard") diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 2ac9cb0dbe..b1ef399835 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -7,7 +7,7 @@ import { splitByGroups } from "../../../common/entity/split_by_groups"; import { stripPrefixFromEntityName } from "../../../common/entity/strip_prefix_from_entity_name"; import { stringCompare } from "../../../common/string/compare"; import type { LocalizeFunc } from "../../../common/translations/localize"; -import type { AreaFilterValue } from "../../../components/ha-area-filter"; +import type { AreasDisplayValue } from "../../../components/ha-areas-display-editor"; import { areaCompare } from "../../../data/area_registry"; import type { EnergyPreferences, @@ -503,7 +503,7 @@ export const generateDefaultViewConfig = ( entities: HassEntities, localize: LocalizeFunc, energyPrefs?: EnergyPreferences, - areasPrefs?: AreaFilterValue, + areasPrefs?: AreasDisplayValue, hideEntitiesWithoutAreas?: boolean, hideEnergy?: boolean ): LovelaceViewConfig => { diff --git a/src/panels/lovelace/editor/dashboard-strategy-editor/dialogs/dialog-dashboard-strategy-editor.ts b/src/panels/lovelace/editor/dashboard-strategy-editor/dialogs/dialog-dashboard-strategy-editor.ts index ced78a0833..92d192ee25 100644 --- a/src/panels/lovelace/editor/dashboard-strategy-editor/dialogs/dialog-dashboard-strategy-editor.ts +++ b/src/panels/lovelace/editor/dashboard-strategy-editor/dialogs/dialog-dashboard-strategy-editor.ts @@ -191,8 +191,24 @@ class DialogDashboardStrategyEditor extends LitElement { haStyleDialog, css` ha-dialog { - --mdc-dialog-max-width: 800px; --dialog-content-padding: 0 24px; + --dialog-surface-position: fixed; + --dialog-surface-top: 40px; + --mdc-dialog-min-width: min(600px, calc(100% - 32px)); + --mdc-dialog-max-width: calc(100% - 32px); + --mdc-dialog-max-height: calc(100% - 80px); + } + + @media all and (max-width: 450px), all and (max-height: 500px) { + /* overrule the ha-style-dialog max-height on small screens */ + ha-dialog { + height: 100%; + --dialog-surface-top: 0px; + --mdc-dialog-min-width: 100%; + --mdc-dialog-max-width: 100%; + --mdc-dialog-max-height: 100%; + --dialog-content-padding: 8px; + } } `, ]; diff --git a/src/panels/lovelace/editor/dashboard-strategy-editor/hui-original-states-dashboard-strategy-editor.ts b/src/panels/lovelace/editor/dashboard-strategy-editor/hui-original-states-dashboard-strategy-editor.ts index 2d085b5c2c..291be48a1f 100644 --- a/src/panels/lovelace/editor/dashboard-strategy-editor/hui-original-states-dashboard-strategy-editor.ts +++ b/src/panels/lovelace/editor/dashboard-strategy-editor/hui-original-states-dashboard-strategy-editor.ts @@ -14,7 +14,7 @@ const SCHEMA = [ { name: "areas", selector: { - area_filter: {}, + areas_display: {}, }, }, { diff --git a/src/panels/lovelace/strategies/area/area-view-strategy.ts b/src/panels/lovelace/strategies/area/area-view-strategy.ts index 53716164e8..03bd7d6dfc 100644 --- a/src/panels/lovelace/strategies/area/area-view-strategy.ts +++ b/src/panels/lovelace/strategies/area/area-view-strategy.ts @@ -1,30 +1,173 @@ import { ReactiveElement } from "lit"; import { customElement } from "lit/decorators"; +import type { EntityFilterFunc } from "../../../../common/entity/entity_filter"; import { generateEntityFilter } from "../../../../common/entity/entity_filter"; import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge"; import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section"; import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; import type { HomeAssistant } from "../../../../types"; +import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature"; +import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover-open-close-card-feature"; +import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature"; +import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature"; +import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature"; +import type { LovelaceCardFeatureConfig } from "../../card-features/types"; + +type Group = "lights" | "climate" | "media_players" | "security"; + +type AreaEntitiesByGroup = Record; + +type AreaFilteredByGroup = Record; + +export const getAreaGroupedEntities = ( + area: string, + hass: HomeAssistant, + controlOnly = false +): AreaEntitiesByGroup => { + const allEntities = Object.keys(hass.states); + + const groupedFilters: AreaFilteredByGroup = { + lights: [ + generateEntityFilter(hass, { + domain: "light", + area: area, + entity_category: "none", + }), + ], + climate: [ + generateEntityFilter(hass, { + domain: "climate", + area: area, + entity_category: "none", + }), + generateEntityFilter(hass, { + domain: "humidifier", + area: area, + entity_category: "none", + }), + generateEntityFilter(hass, { + domain: "cover", + area: area, + device_class: [ + "shutter", + "awning", + "blind", + "curtain", + "shade", + "shutter", + "window", + ], + entity_category: "none", + }), + ...(controlOnly + ? [] + : [ + generateEntityFilter(hass, { + domain: "binary_sensor", + area: area, + device_class: "window", + entity_category: "none", + }), + ]), + ], + media_players: [ + generateEntityFilter(hass, { + domain: "media_player", + area: area, + entity_category: "none", + }), + ], + security: [ + generateEntityFilter(hass, { + domain: "alarm_control_panel", + area: area, + entity_category: "none", + }), + generateEntityFilter(hass, { + domain: "lock", + area: area, + entity_category: "none", + }), + generateEntityFilter(hass, { + domain: "cover", + device_class: ["door", "garage", "gate"], + area: area, + entity_category: "none", + }), + ...(controlOnly + ? [] + : [ + generateEntityFilter(hass, { + domain: "binary_sensor", + device_class: ["door", "garage_door"], + area: area, + entity_category: "none", + }), + ]), + ], + }; + + return Object.fromEntries( + Object.entries(groupedFilters).map(([group, filters]) => [ + group, + filters.reduce( + (acc, filter) => [ + ...acc, + ...allEntities.filter((entity) => filter(entity)), + ], + [] + ), + ]) + ) as AreaEntitiesByGroup; +}; export interface AreaViewStrategyConfig { type: "area"; area?: string; } -const computeTileCard = (entity: string): LovelaceCardConfig => ({ - type: "tile", - entity: entity, -}); +const computeTileCardConfig = + (hass: HomeAssistant) => + (entity: string): LovelaceCardConfig => { + const stateObj = hass.states[entity]; + + let feature: LovelaceCardFeatureConfig | undefined; + if (supportsLightBrightnessCardFeature(stateObj)) { + feature = { + type: "light-brightness", + }; + } else if (supportsCoverOpenCloseCardFeature(stateObj)) { + feature = { + type: "cover-open-close", + }; + } else if (supportsTargetTemperatureCardFeature(stateObj)) { + feature = { + type: "target-temperature", + }; + } else if (supportsAlarmModesCardFeature(stateObj)) { + feature = { + type: "alarm-modes", + }; + } else if (supportsLockCommandsCardFeature(stateObj)) { + feature = { + type: "lock-commands", + }; + } + + return { + type: "tile", + entity: entity, + features: feature ? [feature] : undefined, + }; + }; const computeHeadingCard = ( heading: string, - icon: string, - style: "title" | "subtitle" = "title" + icon: string ): LovelaceCardConfig => ({ type: "heading", heading: heading, - heading_style: style, icon: icon, }); @@ -64,134 +207,36 @@ export class AreaViewStrategy extends ReactiveElement { }); } - const allEntities = Object.keys(hass.states); + const groupedEntities = getAreaGroupedEntities(config.area, hass); - // Lights - const lights = allEntities.filter( - generateEntityFilter(hass, { - domain: "light", - area: config.area, - entity_category: "none", - }) - ); + const computeTileCard = computeTileCardConfig(hass); - if (lights.length) { + const { + lights, + climate, + media_players: mediaPlayers, + security, + } = groupedEntities; + if (lights.length > 0) { sections.push({ type: "grid", cards: [ - { - type: "heading", - heading: "Lights", - icon: "mdi:lamps", - }, - ...lights.map((entity) => ({ - type: "tile", - entity: entity, - })), + computeHeadingCard("Lights", "mdi:lightbulb"), + ...lights.map(computeTileCard), ], }); } - // Climate - const thermostats = allEntities.filter( - generateEntityFilter(hass, { - domain: "climate", - area: config.area, - entity_category: "none", - }) - ); - - const humidifiers = allEntities.filter( - generateEntityFilter(hass, { - domain: "humidifier", - area: config.area, - entity_category: "none", - }) - ); - - const shutters = allEntities.filter( - generateEntityFilter(hass, { - domain: "cover", - area: config.area, - device_class: [ - "shutter", - "awning", - "blind", - "curtain", - "shade", - "shutter", - "window", - ], - entity_category: "none", - }) - ); - - const climateSensor = allEntities.filter( - generateEntityFilter(hass, { - domain: "binary_sensor", - area: config.area, - device_class: "window", - entity_category: "none", - }) - ); - - const climateSectionCards: LovelaceCardConfig[] = []; - - if ( - thermostats.length || - humidifiers.length || - shutters.length || - climateSensor.length - ) { - climateSectionCards.push( - computeHeadingCard("Climate", "mdi:home-thermometer") - ); - } - - if (thermostats.length > 0 || humidifiers.length > 0) { - const title = - thermostats.length > 0 && humidifiers.length - ? "Thermostats and humidifiers" - : thermostats.length - ? "Thermostats" - : "Humidifiers"; - climateSectionCards.push( - computeHeadingCard(title, "mdi:thermostat", "subtitle"), - ...thermostats.map(computeTileCard), - ...humidifiers.map(computeTileCard) - ); - } - - if (shutters.length > 0) { - climateSectionCards.push( - computeHeadingCard("Shutters", "mdi:window-shutter", "subtitle"), - ...shutters.map(computeTileCard) - ); - } - - if (climateSensor.length > 0) { - climateSectionCards.push( - computeHeadingCard("Sensors", "mdi:window-open", "subtitle"), - ...climateSensor.map(computeTileCard) - ); - } - - if (climateSectionCards.length > 0) { + if (climate.length > 0) { sections.push({ type: "grid", - cards: climateSectionCards, + cards: [ + computeHeadingCard("Climate", "mdi:home-thermometer"), + ...climate.map(computeTileCard), + ], }); } - // Media players - const mediaPlayers = allEntities.filter( - generateEntityFilter(hass, { - domain: "media_player", - area: config.area, - entity_category: "none", - }) - ); - if (mediaPlayers.length > 0) { sections.push({ type: "grid", @@ -202,77 +247,27 @@ export class AreaViewStrategy extends ReactiveElement { }); } - // Security - const alarms = allEntities.filter( - generateEntityFilter(hass, { - domain: "alarm_control_panel", - area: config.area, - entity_category: "none", - }) - ); - const locks = allEntities.filter( - generateEntityFilter(hass, { - domain: "lock", - area: config.area, - entity_category: "none", - }) - ); - const doors = allEntities.filter( - generateEntityFilter(hass, { - domain: "cover", - device_class: ["door", "garage", "gate"], - area: config.area, - entity_category: "none", - }) - ); - const securitySensors = allEntities.filter( - generateEntityFilter(hass, { - domain: "binary_sensor", - device_class: ["door", "garage_door"], - area: config.area, - entity_category: "none", - }) - ); - - const securitySectionCards: LovelaceCardConfig[] = []; - - if (alarms.length > 0 || locks.length > 0) { - const title = - alarms.length > 0 && locks.length - ? "Alarms and locks" - : alarms.length - ? "Alarms" - : "Locks"; - securitySectionCards.push( - computeHeadingCard(title, "mdi:shield", "subtitle"), - ...alarms.map(computeTileCard), - ...locks.map(computeTileCard) - ); - } - - if (doors.length > 0) { - securitySectionCards.push( - computeHeadingCard("Doors", "mdi:door", "subtitle"), - ...doors.map(computeTileCard) - ); - } - - if (securitySensors.length > 0) { - securitySectionCards.push( - computeHeadingCard("Sensors", "mdi:wifi", "subtitle"), - ...securitySensors.map(computeTileCard) - ); - } - - if (securitySectionCards.length > 0) { + if (security.length > 0) { sections.push({ type: "grid", - cards: securitySectionCards, + cards: [ + computeHeadingCard("Security", "mdi:security"), + ...security.map(computeTileCard), + ], }); } return { type: "sections", + header: { + badges_position: "bottom", + layout: "responsive", + card: { + type: "markdown", + text_only: true, + content: `## ${area.name}`, + }, + }, max_columns: 2, sections: sections, badges: badges, diff --git a/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts b/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts index 6de6bc7704..4773488762 100644 --- a/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts +++ b/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts @@ -1,34 +1,45 @@ import { ReactiveElement } from "lit"; import { customElement } from "lit/decorators"; -import { areaCompare } from "../../../../data/area_registry"; import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view"; import type { HomeAssistant } from "../../../../types"; import type { AreaViewStrategyConfig } from "../area/area-view-strategy"; +import type { LovelaceStrategyEditor } from "../types"; +import type { AreasViewStrategyConfig } from "./areas-view-strategy"; +import { computeAreaPath, getAreas } from "./helpers/areas-strategy-helpers"; -export interface AreasDashboardStrategyConfig {} +export interface AreasDashboardStrategyConfig { + type: "areas"; + areas_display?: { + hidden?: string[]; + order?: string[]; + }; +} @customElement("areas-dashboard-strategy") export class AreasDashboardStrategy extends ReactiveElement { static async generate( - _config: AreasDashboardStrategyConfig, + config: AreasDashboardStrategyConfig, hass: HomeAssistant ): Promise { - const compare = areaCompare(hass.areas); - const areas = Object.values(hass.areas).sort((a, b) => - compare(a.area_id, b.area_id) + const areas = getAreas( + hass.areas, + config.areas_display?.hidden, + config.areas_display?.order ); - const areaViews = areas.map((area) => ({ - title: area.name, - icon: area.icon || undefined, - path: `areas-${area.area_id}`, - subview: true, - strategy: { - type: "area", - area: area.area_id, - } satisfies AreaViewStrategyConfig, - })); + const areaViews = areas.map((area) => { + const path = computeAreaPath(area.area_id); + return { + title: area.name, + icon: area.icon || undefined, + path: path, + strategy: { + type: "area", + area: area.area_id, + } satisfies AreaViewStrategyConfig, + }; + }); return { views: [ @@ -38,12 +49,18 @@ export class AreasDashboardStrategy extends ReactiveElement { path: "home", strategy: { type: "areas", - }, + areas_display: config.areas_display, + } satisfies AreasViewStrategyConfig, }, ...areaViews, ], }; } + + public static async getConfigElement(): Promise { + await import("./editor/hui-areas-dashboard-strategy-editor"); + return document.createElement("hui-areas-dashboard-strategy-editor"); + } } declare global { diff --git a/src/panels/lovelace/strategies/areas/areas-view-strategy.ts b/src/panels/lovelace/strategies/areas/areas-view-strategy.ts index 502aebf4ac..c6069f9b1a 100644 --- a/src/panels/lovelace/strategies/areas/areas-view-strategy.ts +++ b/src/panels/lovelace/strategies/areas/areas-view-strategy.ts @@ -1,57 +1,81 @@ import { ReactiveElement } from "lit"; import { customElement } from "lit/decorators"; -import { areaCompare } from "../../../../data/area_registry"; import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section"; import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; import type { HomeAssistant } from "../../../../types"; +import { getAreaGroupedEntities } from "../area/area-view-strategy"; +import { computeAreaPath, getAreas } from "./helpers/areas-strategy-helpers"; export interface AreasViewStrategyConfig { type: "areas"; + areas_display?: { + hidden?: string[]; + order?: string[]; + }; } @customElement("areas-view-strategy") export class AreasViewStrategy extends ReactiveElement { static async generate( - _config: AreasViewStrategyConfig, + config: AreasViewStrategyConfig, hass: HomeAssistant ): Promise { - const compare = areaCompare(hass.areas); - const areas = Object.values(hass.areas).sort((a, b) => - compare(a.area_id, b.area_id) + const areas = getAreas( + hass.areas, + config.areas_display?.hidden, + config.areas_display?.order ); - const areaSections = areas.map((area) => { - const areaPath = `areas-${area.area_id}`; - return { - type: "grid", - cards: [ - { - type: "heading", - heading: area.name, - icon: area.icon || undefined, - badges: [ - ...(area.temperature_entity_id - ? [{ entity: area.temperature_entity_id }] - : []), - ...(area.humidity_entity_id - ? [{ entity: area.humidity_entity_id }] - : []), - ], - tap_action: { - action: "navigate", - navigation_path: areaPath, + const areaSections = areas + .map((area) => { + const path = computeAreaPath(area.area_id); + + const groups = getAreaGroupedEntities(area.area_id, hass, true); + + const entities = [ + ...groups.lights, + ...groups.climate, + ...groups.media_players, + ...groups.security, + ]; + + return { + type: "grid", + cards: [ + { + type: "heading", + heading: area.name, + icon: area.icon || undefined, + badges: [ + ...(area.temperature_entity_id + ? [{ entity: area.temperature_entity_id }] + : []), + ...(area.humidity_entity_id + ? [{ entity: area.humidity_entity_id }] + : []), + ], + tap_action: { + action: "navigate", + navigation_path: path, + }, }, - }, - { - type: "area", - area: area.area_id, - navigation_path: areaPath, - alert_classes: [], - sensor_classes: [], - }, - ], - }; - }); + ...(entities.length + ? entities.map((entity) => ({ + type: "tile", + entity: entity, + })) + : [ + { + type: "markdown", + content: "No controllable devices in this area.", + }, + ]), + ], + }; + }) + .filter( + (section): section is LovelaceSectionConfig => section !== undefined + ); return { type: "sections", 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 new file mode 100644 index 0000000000..c1a9586b50 --- /dev/null +++ b/src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts @@ -0,0 +1,59 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import "../../../../../components/ha-areas-display-editor"; +import type { AreasDisplayValue } from "../../../../../components/ha-areas-display-editor"; +import type { HomeAssistant } from "../../../../../types"; +import type { LovelaceStrategyEditor } from "../../types"; +import type { AreasDashboardStrategyConfig } from "../areas-dashboard-strategy"; + +@customElement("hui-areas-dashboard-strategy-editor") +export class HuiAreasDashboardStrategyEditor + extends LitElement + implements LovelaceStrategyEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() + private _config?: AreasDashboardStrategyConfig; + + public setConfig(config: AreasDashboardStrategyConfig): void { + this._config = config; + } + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + const value = this._config.areas_display; + + return html` + + `; + } + + private _areaDisplayChanged(ev: CustomEvent): void { + const value = ev.detail.value as AreasDisplayValue; + const newConfig: AreasDashboardStrategyConfig = { + ...this._config!, + areas_display: value, + }; + + fireEvent(this, "config-changed", { config: newConfig }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-areas-dashboard-strategy-editor": HuiAreasDashboardStrategyEditor; + } +} diff --git a/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helpers.ts b/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helpers.ts new file mode 100644 index 0000000000..3dde5af439 --- /dev/null +++ b/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helpers.ts @@ -0,0 +1,25 @@ +import type { AreaRegistryEntry } from "../../../../../data/area_registry"; +import { areaCompare } from "../../../../../data/area_registry"; +import type { HomeAssistant } from "../../../../../types"; + +export const getAreas = ( + entries: HomeAssistant["areas"], + hiddenAreas?: string[], + areasOrder?: string[] +): AreaRegistryEntry[] => { + const areas = Object.values(entries); + + const filteredAreas = hiddenAreas + ? areas.filter((area) => !hiddenAreas!.includes(area.area_id)) + : areas.concat(); + + const compare = areaCompare(entries, areasOrder); + + const sortedAreas = filteredAreas.sort((areaA, areaB) => + compare(areaA.area_id, areaB.area_id) + ); + + return sortedAreas; +}; + +export const computeAreaPath = (areaId: string): string => `areas-${areaId}`; diff --git a/src/panels/lovelace/strategies/original-states/original-states-view-strategy.ts b/src/panels/lovelace/strategies/original-states/original-states-view-strategy.ts index a128f79730..6336cc2611 100644 --- a/src/panels/lovelace/strategies/original-states/original-states-view-strategy.ts +++ b/src/panels/lovelace/strategies/original-states/original-states-view-strategy.ts @@ -2,7 +2,7 @@ import { STATE_NOT_RUNNING } from "home-assistant-js-websocket"; import { ReactiveElement } from "lit"; import { customElement } from "lit/decorators"; import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; -import type { AreaFilterValue } from "../../../../components/ha-area-filter"; +import type { AreasDisplayValue } from "../../../../components/ha-areas-display-editor"; import { getEnergyPreferences } from "../../../../data/energy"; import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; import type { HomeAssistant } from "../../../../types"; @@ -10,7 +10,7 @@ import { generateDefaultViewConfig } from "../../common/generate-lovelace-config export interface OriginalStatesViewStrategyConfig { type: "original-states"; - areas?: AreaFilterValue; + areas?: AreasDisplayValue; hide_entities_without_area?: boolean; hide_energy?: boolean; } diff --git a/src/translations/en.json b/src/translations/en.json index ec0b89902e..b9700992b3 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1127,6 +1127,10 @@ }, "multi-textfield": { "add_item": "Add {item}" + }, + "items-display-editor": { + "show": "Show {label}", + "hide": "Hide {label}" } }, "dialogs": { @@ -3121,6 +3125,10 @@ "iframe": { "title": "Webpage", "description": "Integrate a webpage as a dashboard" + }, + "areas": { + "title": "Areas (experimental)", + "description": "Display your devices with a view for each area" } } }, @@ -7456,12 +7464,15 @@ }, "strategy": { "original-states": { - "areas": "Areas", + "areas": "Areas to display", "hide_entities_without_area": "Hide entities without area", "hide_energy": "Hide energy" }, "iframe": { "url": "URL" + }, + "areas": { + "areas_display": "Areas to display" } }, "view": {