Add area filter selector for default dashboard (#18779)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Paul Bottein 2023-11-28 21:43:34 +01:00 committed by GitHub
parent 9b20e1cf56
commit 7727f34e8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 444 additions and 60 deletions

View File

@ -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`
<ha-list-item
tabindex="0"
role="button"
hasMeta
twoline
graphic="icon"
@click=${this._edit}
@keydown=${this._edit}
.disabled=${this.disabled}
>
<ha-svg-icon slot="graphic" .path=${mdiSofa}></ha-svg-icon>
<span>${this.label}</span>
<span slot="secondary">${description}</span>
<ha-svg-icon
slot="meta"
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-list-item>
`;
}
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;
}
}

View File

@ -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`
<ha-area-filter
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-area-filter>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-area_filter": HaAreaFilterSelector;
}
}

View File

@ -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"),

View File

@ -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;
};

View File

@ -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;

View File

@ -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<AreaFilterDialogParams>
{
@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<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));
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`
<ha-dialog
open
@closed=${this._cancel}
.heading=${this._dialogParams.title ??
this.hass.localize("ui.components.area-filter.title")}
>
<mwc-list class="areas">
${repeat(
allAreas,
(area) => area,
(area, _idx) => {
const isVisible = !this._hidden.includes(area);
const name = this.hass!.areas[area]?.name || area;
return html`
<ha-list-item
class=${classMap({ hidden: !isVisible })}
hasMeta
graphic="icon"
noninteractive
>
<ha-svg-icon
class="handle"
.path=${mdiDrag}
slot="graphic"
></ha-svg-icon>
${name}
<ha-icon-button
tabindex="0"
class="action"
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="meta"
.label=${this.hass!.localize(
`ui.components.area-filter.${
isVisible ? "hide" : "show"
}`,
{ area: name }
)}
.area=${area}
@click=${this._toggle}
></ha-icon-button>
</ha-list-item>
`;
}
)}
</mwc-list>
<ha-button slot="secondaryAction" dialogAction="cancel">
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this.hass.localize("ui.common.submit")}
</ha-button>
</ha-dialog>
`;
}
_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;
}
}

View File

@ -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<AreaFilterValue | null>((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);
}
},
},
});
});

View File

@ -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(

View File

@ -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`
<ha-form
.hass=${this.hass}
.data=${data}
.data=${this._config}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
@ -106,14 +68,13 @@ export class HuiOriginalStatesDashboarStrategyEditor
}
private _valueChanged(ev: CustomEvent): void {
const data = ev.detail.value as FormData;
const config = this._formDataToConfig(data);
fireEvent(this, "config-changed", { config });
const data = ev.detail.value;
fireEvent(this, "config-changed", { config: data });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "hidden_areas":
case "areas":
case "hide_energy":
case "hide_entities_without_area":
return this.hass?.localize(

View File

@ -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;
};

View File

@ -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"
}