diff --git a/src/components/ha-area-filter.ts b/src/components/ha-area-filter.ts new file mode 100644 index 0000000000..a5ca967190 --- /dev/null +++ b/src/components/ha-area-filter.ts @@ -0,0 +1,96 @@ +import { mdiChevronRight, mdiSofa } from "@mdi/js"; +import { CSSResultGroup, LitElement, TemplateResult, 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 { HomeAssistant } from "../types"; +import "./ha-svg-icon"; +import "./ha-textfield"; + +export type 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 get styles(): CSSResultGroup { + return 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-selector/ha-selector-area-filter.ts b/src/components/ha-selector/ha-selector-area-filter.ts new file mode 100644 index 0000000000..7efd243a8c --- /dev/null +++ b/src/components/ha-selector/ha-selector-area-filter.ts @@ -0,0 +1,41 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import type { AreaFilterSelector } from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../ha-area-filter"; + +@customElement("ha-selector-area_filter") +export class HaAreaFilterSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: AreaFilterSelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + protected render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-area_filter": HaAreaFilterSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 3bfbb7741e..74d129d901 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -13,6 +13,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"), attribute: () => import("./ha-selector-attribute"), assist_pipeline: () => import("./ha-selector-assist-pipeline"), boolean: () => import("./ha-selector-boolean"), diff --git a/src/data/area_registry.ts b/src/data/area_registry.ts index 4c53f218eb..b3c391b56a 100644 --- a/src/data/area_registry.ts +++ b/src/data/area_registry.ts @@ -123,3 +123,22 @@ export const getAreaDeviceLookup = ( } return areaDeviceLookup; }; + +export const areaCompare = + (entries?: HomeAssistant["areas"], order?: string[]) => + (a: string, b: string) => { + const indexA = order ? order.indexOf(a) : -1; + const indexB = order ? order.indexOf(b) : 1; + if (indexA === -1 && indexB === -1) { + const nameA = entries?.[a].name ?? a; + const nameB = entries?.[b].name ?? b; + return stringCompare(nameA, nameB); + } + if (indexA === -1) { + return 1; + } + if (indexB === -1) { + return -1; + } + return indexA - indexB; + }; diff --git a/src/data/selector.ts b/src/data/selector.ts index 3739e1c82e..641ef114a1 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -15,6 +15,7 @@ export type Selector = | ActionSelector | AddonSelector | AreaSelector + | AreaFilterSelector | AttributeSelector | BooleanSelector | ColorRGBSelector @@ -77,6 +78,11 @@ export interface AreaSelector { } | null; } +export interface AreaFilterSelector { + // eslint-disable-next-line @typescript-eslint/ban-types + area_filter: {} | null; +} + export interface AttributeSelector { attribute: { entity_id?: string; diff --git a/src/dialogs/area-filter/area-filter-dialog.ts b/src/dialogs/area-filter/area-filter-dialog.ts new file mode 100644 index 0000000000..b2b813b3eb --- /dev/null +++ b/src/dialogs/area-filter/area-filter-dialog.ts @@ -0,0 +1,218 @@ +import "@material/mwc-list/mwc-list"; +import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js"; +import { CSSResultGroup, 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 type { SortableEvent } from "sortablejs"; +import { fireEvent } from "../../common/dom/fire_event"; +import type { AreaFilterValue } from "../../components/ha-area-filter"; +import "../../components/ha-button"; +import "../../components/ha-icon-button"; +import "../../components/ha-list-item"; +import { areaCompare } from "../../data/area_registry"; +import { sortableStyles } from "../../resources/ha-sortable-style"; +import type { SortableInstance } from "../../resources/sortable"; +import { haStyleDialog } from "../../resources/styles"; +import { HomeAssistant } from "../../types"; +import { HassDialog } from "../make-dialog-manager"; +import { 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[] = []; + + private _sortable?: SortableInstance; + + public async showDialog(dialogParams: AreaFilterDialogParams): Promise { + 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)); + await this.updateComplete; + this._createSortable(); + } + + public closeDialog(): void { + this._dialogParams = undefined; + this._hidden = []; + this._areas = []; + this._destroySortable(); + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + 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 async _createSortable() { + const Sortable = (await import("../../resources/sortable")).default; + if (this._sortable) return; + this._sortable = new Sortable(this.shadowRoot!.querySelector(".areas")!, { + animation: 150, + fallbackClass: "sortable-fallback", + handle: ".handle", + onChoose: (evt: SortableEvent) => { + (evt.item as any).placeholder = + document.createComment("sort-placeholder"); + evt.item.after((evt.item as any).placeholder); + }, + onEnd: (evt: SortableEvent) => { + // put back in original location + if ((evt.item as any).placeholder) { + (evt.item as any).placeholder.replaceWith(evt.item); + delete (evt.item as any).placeholder; + } + this._dragged(evt); + }, + }); + } + + private _destroySortable() { + this._sortable?.destroy(); + this._sortable = undefined; + } + + private _dragged(ev: SortableEvent): void { + if (ev.oldIndex === ev.newIndex) return; + + const areas = this._areas.concat(); + + const option = areas.splice(ev.oldIndex!, 1)[0]; + areas.splice(ev.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` + + + ${name} + + + `; + } + )} + + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.submit")} + + + `; + } + + _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; + } + + static get styles(): CSSResultGroup { + return [ + sortableStyles, + haStyleDialog, + css` + ha-dialog { + /* Place above other dialogs */ + --dialog-z-index: 104; + --dialog-content-padding: 0; + } + ha-list-item { + overflow: visible; + } + .hidden { + opacity: 0.3; + } + .handle { + 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 new file mode 100644 index 0000000000..148db2d8bc --- /dev/null +++ b/src/dialogs/area-filter/show-area-filter-dialog.ts @@ -0,0 +1,38 @@ +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/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index bb3e08da62..56c7b3ced8 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -7,6 +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 { LocalizeFunc } from "../../../common/translations/localize"; +import type { AreaFilterValue } from "../../../components/ha-area-filter"; import { EnergyPreferences, GridSourceTypeEnergyPreference, @@ -27,6 +28,7 @@ import { } from "../cards/types"; import { EntityConfig } from "../entity-rows/types"; import { ButtonsHeaderFooterConfig } from "../header-footer/types"; +import { areaCompare } from "../../../data/area_registry"; const HIDE_DOMAIN = new Set([ "automation", @@ -447,9 +449,7 @@ export const generateDefaultViewConfig = ( entities: HassEntities, localize: LocalizeFunc, energyPrefs?: EnergyPreferences, - areasPrefs?: { - hidden?: string[]; - }, + areasPrefs?: AreaFilterValue, hideEntitiesWithoutAreas?: boolean, hideEnergy?: boolean ): LovelaceViewConfig => { @@ -511,15 +511,12 @@ export const generateDefaultViewConfig = ( const areaCards: LovelaceCardConfig[] = []; - const sortedAreas = Object.entries( - splittedByAreaDevice.areasWithEntities - ).sort((a, b) => { - const areaA = areaEntries[a[0]]; - const areaB = areaEntries[b[0]]; - return stringCompare(areaA.name, areaB.name); - }); + const sortedAreas = Object.keys(splittedByAreaDevice.areasWithEntities).sort( + areaCompare(areaEntries, areasPrefs?.order) + ); - for (const [areaId, areaEntities] of sortedAreas) { + for (const areaId of sortedAreas) { + const areaEntities = splittedByAreaDevice.areasWithEntities[areaId]; const area = areaEntries[areaId]; areaCards.push( ...computeCards( 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 77e5c58034..48c18f8424 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 @@ -1,6 +1,5 @@ import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-form/ha-form"; import type { @@ -13,11 +12,9 @@ import { LovelaceStrategyEditor } from "../../strategies/types"; const SCHEMA = [ { - name: "hidden_areas", + name: "areas", selector: { - area: { - multiple: true, - }, + area_filter: {}, }, }, { @@ -40,12 +37,6 @@ const SCHEMA = [ }, ] as const satisfies readonly HaFormSchema[]; -type FormData = { - hidden_areas: string[]; - hide_energy?: boolean; - hide_entities_without_area?: boolean; -}; - @customElement("hui-original-states-dashboard-strategy-editor") export class HuiOriginalStatesDashboarStrategyEditor extends LitElement @@ -60,44 +51,15 @@ export class HuiOriginalStatesDashboarStrategyEditor this._config = config; } - private _configToFormData = memoizeOne( - (config: OriginalStatesDashboardStrategyConfig): FormData => { - const { areas, ...rest } = config; - return { - ...rest, - hidden_areas: areas?.hidden || [], - }; - } - ); - - private _formDataToConfig = memoizeOne( - (data: FormData): OriginalStatesDashboardStrategyConfig => { - const { hidden_areas, ...rest } = data; - const areas = - hidden_areas.length > 0 - ? { - hidden: hidden_areas, - } - : undefined; - return { - type: "original-states", - ...rest, - areas, - }; - } - ); - protected render() { if (!this.hass || !this._config) { return nothing; } - const data = this._configToFormData(this._config); - return html` ) => { switch (schema.name) { - case "hidden_areas": + case "areas": case "hide_energy": case "hide_entities_without_area": return this.hass?.localize( diff --git a/src/panels/lovelace/strategies/original-states-view-strategy.ts b/src/panels/lovelace/strategies/original-states-view-strategy.ts index 86f3017315..9fcf20e974 100644 --- a/src/panels/lovelace/strategies/original-states-view-strategy.ts +++ b/src/panels/lovelace/strategies/original-states-view-strategy.ts @@ -2,6 +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 { getEnergyPreferences } from "../../../data/energy"; import { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import { HomeAssistant } from "../../../types"; @@ -9,9 +10,7 @@ import { generateDefaultViewConfig } from "../common/generate-lovelace-config"; export type OriginalStatesViewStrategyConfig = { type: "original-states"; - areas?: { - hidden?: string[]; - }; + areas?: AreaFilterValue; hide_entities_without_area?: boolean; hide_energy?: boolean; }; diff --git a/src/translations/en.json b/src/translations/en.json index 95601f7fc2..268ad8ff84 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -512,6 +512,14 @@ "failed_create_area": "Failed to create area." } }, + "area-filter": { + "title": "Areas", + "no_areas": "No areas", + "area_count": "{count} {count, plural,\n one {area}\n other {areas}\n}", + "all_areas": "All areas", + "show": "Show {area}", + "hide": "Hide {area}" + }, "statistic-picker": { "statistic": "Statistic", "no_statistics": "You don't have any statistics", @@ -5269,7 +5277,7 @@ }, "strategy": { "original-states": { - "hidden_areas": "Hidden Areas", + "areas": "Areas", "hide_entities_without_area": "Hide entities without area", "hide_energy": "Hide energy" }