Compare commits

...
14 changed files with 939 additions and 356 deletions
-287
View File
@@ -1,287 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
interface DeviceClassItem {
deviceClass: string;
domain: string;
name: string;
}
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-device-classes.caption")}
${
this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
),
(item) => item.deviceClass,
(item) =>
html`<ha-check-list-item
.value=${item.deviceClass}
.selected=${(this.value || []).includes(item.deviceClass)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${item.domain}
.deviceClass=${item.deviceClass}
.state=${item.domain === "binary_sensor" ? "on" : undefined}
></ha-domain-icon>
${item.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined
): DeviceClassItem[] =>
this._deviceClassItems(this._deviceClassDomains(states), localize)
.filter(
(item) =>
!filter ||
item.deviceClass.toLowerCase().includes(filter) ||
item.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _deviceClassDomains = memoizeOne(
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
const domains = new Map<string, string[]>();
Object.values(states).forEach((stateObj) => {
const deviceClass = stateObj.attributes.device_class;
if (!deviceClass) {
return;
}
const domain = computeStateDomain(stateObj);
const known = domains.get(deviceClass);
if (!known) {
domains.set(deviceClass, [domain]);
} else if (!known.includes(domain)) {
known.push(domain);
}
});
return domains;
}
);
private _deviceClassItems = memoizeOne(
(
deviceClassDomains: Map<string, string[]>,
localize: LocalizeFunc
): DeviceClassItem[] =>
[...deviceClassDomains].map(([deviceClass, domains]) => {
for (const domain of domains) {
const name = localize(
`component.${domain}.entity_component.${deviceClass}.name`
);
if (name) {
return { deviceClass, domain, name };
}
}
return { deviceClass, domain: domains[0], name: deviceClass };
}),
([domainsA, localizeA], [domainsB, localizeB]) =>
localizeA === localizeB &&
domainsA.size === domainsB.size &&
[...domainsA].every(
([deviceClass, domains]) =>
domainsB.get(deviceClass)?.join() === domains.join()
)
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const deviceClasses = this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
);
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
const preserved = (this.value || []).filter((d) => !visible.has(d));
const selected = [...ev.detail.index]
.map((i) => deviceClasses[i]?.deviceClass)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+510
View File
@@ -0,0 +1,510 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiChevronDown,
mdiChevronUp,
mdiFilterVariantRemove,
mdiShape,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeRTL } from "../common/util/compute_rtl";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import {
computeDeviceClassName,
NO_DEVICE_CLASS,
} from "../data/entity/device_class";
import {
entityTypeKey,
parseEntityType,
usedEntityTypes,
} from "../data/entity/entity_type";
import { domainToName } from "../data/integration";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
// Core picks this one from the battery level, so it has no usable default.
const FIXED_TYPE_ICONS: Record<string, string> = {
"sensor/battery": "mdi:battery",
};
interface TypeRow {
key: string;
domain: string;
deviceClass?: string;
name: string;
deviceClasses?: string[];
expanded?: boolean;
last?: boolean;
}
@customElement("ha-filter-entity-types")
export class HaFilterEntityTypes extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _filter?: string;
@state() private _expandedDomains = new Set<string>();
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
const rows = this._rows(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this._expandedDomains
);
const rtl = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const count = this._count(this.value, this._types(this._states));
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-entity-types.caption")}
${
count
? html`<div class="badge">${count}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list-selectable
multi
controlled
aria-label=${this._localize(
"ui.components.filter-entity-types.caption"
)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
rows,
(row) => row.key,
(row) => this._renderRow(row, rtl)
)}
</ha-list-selectable>
</div>`
: nothing
}
`;
}
private _renderRow(row: TypeRow, rtl: boolean) {
const selected = this._isSelected(row);
const expandable = !!row.deviceClasses?.length;
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
class=${classMap({ child: !!row.deviceClass, rtl })}
.value=${row.key}
.selected=${selected}
.indeterminate=${!selected && this._isPartiallySelected(row)}
>
${
row.deviceClass
? html`<ha-tree-indicator
slot="start"
.end=${!!row.last}
></ha-tree-indicator>`
: nothing
}
${
row.deviceClass === NO_DEVICE_CLASS
? html`<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>`
: html`<ha-domain-icon
slot="start"
.icon=${FIXED_TYPE_ICONS[row.key]}
.domain=${row.domain}
.deviceClass=${row.deviceClass}
.state=${row.domain === "binary_sensor" ? "on" : undefined}
?brand-fallback=${!row.deviceClass}
></ha-domain-icon>`
}
<span slot="headline">${row.name}</span>
${
expandable
? html`<ha-icon-button
slot="end"
data-domain=${row.domain}
.path=${row.expanded ? mdiChevronUp : mdiChevronDown}
.label=${this._localize(
row.expanded
? "ui.components.filter-entity-types.collapse"
: "ui.components.filter-entity-types.expand"
)}
@click=${this._toggleDomain}
@keydown=${this._handleChevronKeydown}
></ha-icon-button>`
: nothing
}
</ha-list-item-option>
`;
}
private _types = memoizeOne(usedEntityTypes);
// A selected domain counts for the classes it stands for, so that collapsing
// the last one does not drop the count to one.
private _count = memoizeOne(
(value: string[] | undefined, types: Map<string, string[]>): number =>
(value ?? []).reduce((count, key) => {
const { domain, deviceClass } = parseEntityType(key);
return (
count +
(deviceClass ? 1 : Math.max(types.get(domain)?.length ?? 0, 1))
);
}, 0)
);
private _rows = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
expandedDomains: Set<string>
): TypeRow[] => {
const types = this._types(states);
const domains = [...types.keys()]
.map((domain) => ({ domain, name: domainToName(localize, domain) }))
.sort((a, b) => stringCompare(a.name, b.name, language));
const rows: TypeRow[] = [];
for (const { domain, name } of domains) {
const deviceClasses = types
.get(domain)!
.map((deviceClass) => ({
deviceClass,
name: this._deviceClassName(localize, domain, deviceClass),
}))
.sort((a, b) => {
if (a.deviceClass === NO_DEVICE_CLASS) {
return 1;
}
if (b.deviceClass === NO_DEVICE_CLASS) {
return -1;
}
return stringCompare(a.name, b.name, language);
});
const matchingClasses = deviceClasses.filter((entry) =>
this._matches(filter, entry.deviceClass, entry.name)
);
const domainMatches = this._matches(filter, domain, name);
if (!domainMatches && !matchingClasses.length) {
continue;
}
// Only a search that matched nothing but device classes unfolds them.
const revealed = !!filter && !domainMatches;
const expanded = revealed || expandedDomains.has(domain);
rows.push({
key: domain,
domain,
name,
deviceClasses: deviceClasses.map((entry) => entry.deviceClass),
expanded,
});
if (!deviceClasses.length || !expanded) {
continue;
}
const children = revealed ? matchingClasses : deviceClasses;
children.forEach((entry, index) => {
rows.push({
key: entityTypeKey(domain, entry.deviceClass),
domain,
deviceClass: entry.deviceClass,
name: entry.name,
last: index === children.length - 1,
});
});
}
return rows;
}
);
private _deviceClassName(
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string {
return deviceClass === NO_DEVICE_CLASS
? localize("ui.components.filter-entity-types.no_device_class")
: computeDeviceClassName(localize, domain, deviceClass);
}
private _matches(
filter: string | undefined,
slug: string,
name: string
): boolean {
return (
!filter ||
slug.toLowerCase().includes(filter) ||
name.toLowerCase().includes(filter)
);
}
private _isSelected(row: TypeRow): boolean {
const value = this.value;
if (!value?.length) {
return false;
}
return value.includes(row.domain) || value.includes(row.key);
}
private _isPartiallySelected(row: TypeRow): boolean {
if (row.deviceClass || !this.value?.length) {
return false;
}
return this.value.some(
(key) => parseEntityType(key).domain === row.domain && key !== row.domain
);
}
public willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
if (changed.has("expanded") && this.expanded) {
this._expandedDomains = new Set(
(this.value ?? [])
.map((key) => parseEntityType(key))
.filter((type) => type.deviceClass)
.map((type) => type.domain)
);
}
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
// The list activates the focused row on Enter and Space, which would select
// the domain instead of expanding it.
private _handleChevronKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
}
}
private _toggleDomain(ev: Event) {
ev.stopPropagation();
const { domain } = (ev.currentTarget as HTMLElement).dataset;
if (!domain) {
return;
}
const expandedDomains = new Set(this._expandedDomains);
if (!expandedDomains.delete(domain)) {
expandedDomains.add(domain);
}
this._expandedDomains = expandedDomains;
}
private _handleItemToggled(ev: CustomEvent<number>) {
// The list indexes its items by registration order, which a search reorders,
// so read the key off the clicked option instead.
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
const key = option?.value;
if (!key) {
return;
}
const { domain, deviceClass } = parseEntityType(key);
const value = new Set(this.value ?? []);
const siblings = (this._types(this._states).get(domain) ?? []).map(
(entry) => entityTypeKey(domain, entry)
);
// Drops the classes the domain no longer exposes too, so that a stale key
// can never sit next to the domain that covers it.
const selectDomain = () => {
value.forEach((selected) => {
if (parseEntityType(selected).domain === domain) {
value.delete(selected);
}
});
value.add(domain);
};
if (!deviceClass) {
if (!value.delete(domain)) {
selectDomain();
}
} else if (value.delete(domain)) {
siblings.forEach((sibling) => {
if (sibling !== key) {
value.add(sibling);
}
});
} else if (!value.delete(key)) {
value.add(key);
if (siblings.length && siblings.every((sibling) => value.has(sibling))) {
selectDomain();
}
}
this.value = [...value];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
filterPanelStyles,
css`
/* The list scrolls through its own container, not through the host. */
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
/* Keeps a row that carries the chevron as tall as one that does not. */
ha-list-item-option {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-list-item-option ha-icon-button {
--ha-icon-button-size: 32px;
}
.child::part(base) {
padding-inline-start: 48px;
}
ha-tree-indicator {
width: 56px;
position: absolute;
top: 0px;
left: 0px;
}
.rtl ha-tree-indicator {
right: 0px;
left: initial;
transform: scaleX(-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-entity-types": HaFilterEntityTypes;
}
}
+49 -50
View File
@@ -2,15 +2,15 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import { entityTypeFilterFunc } from "../data/entity/entity_type";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-device-classes";
import "./ha-filter-domains";
import "./ha-filter-entity-types";
import "./ha-filter-integrations";
import "./ha-target-picker";
@@ -19,8 +19,8 @@ import "./ha-target-picker";
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
domains?: string[];
deviceClasses?: string[];
/** Domains (`sensor`) and domains narrowed to a device class (`sensor/power`). */
types?: string[];
integrations?: string[];
}
@@ -44,38 +44,30 @@ export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
/**
* Narrows entity IDs down by the selected filters: an entity is kept when it
* matches every filter that has a selection.
* Matches an entity against the selected filters: it is kept when it matches
* every filter that has a selection. Undefined when nothing is selected.
*/
export const applySourceFilters = (
entityIds: string[],
export const sourceFilterFunc = (
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const domains = filters.domains?.length ? filters.domains : undefined;
const deviceClasses = filters.deviceClasses?.length
? filters.deviceClasses
): ((entityId: string) => boolean) | undefined => {
const matchesType = filters.types?.length
? entityTypeFilterFunc(filters.types, states)
: undefined;
const integrations = filters.integrations?.length
? filters.integrations
: undefined;
if (!domains && !deviceClasses && !integrations) {
return entityIds;
if (!matchesType && !integrations) {
return undefined;
}
return entityIds.filter((entityId) => {
if (domains && !domains.includes(computeDomain(entityId))) {
return (entityId: string) => {
if (matchesType && !matchesType(entityId)) {
return false;
}
if (deviceClasses) {
const deviceClass = states[entityId]?.attributes.device_class;
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
return false;
}
}
if (integrations) {
const integration =
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
@@ -84,13 +76,24 @@ export const applySourceFilters = (
}
}
return true;
});
};
};
/** Narrows entity IDs down by the selected filters. */
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const matches = sourceFilterFunc(filters, states, entities, entitySources);
return matches ? entityIds.filter(matches) : entityIds;
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* domain, device class and integration. Meant to be placed in an
* `ha-filter-pane`.
* entity type and integration. Meant to be placed in an `ha-filter-pane`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
@@ -106,6 +109,8 @@ export class HaSourcesPicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false }) public entitySources?: EntitySources;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@@ -129,6 +134,12 @@ export class HaSourcesPicker extends LitElement {
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.activeFilter=${this._activeFilter(
this.filters,
this.hass.states,
this.hass.entities,
this.entitySources
)}
.primaryEntitiesOnly=${false}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
@@ -136,18 +147,12 @@ export class HaSourcesPicker extends LitElement {
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-domains
.value=${this.filters.domains}
.expanded=${this._expandedFilter === "domains"}
@data-table-filter-changed=${this._domainsChanged}
@expanded-changed=${this._domainsExpanded}
></ha-filter-domains>
<ha-filter-device-classes
.value=${this.filters.deviceClasses}
.expanded=${this._expandedFilter === "deviceClasses"}
@data-table-filter-changed=${this._deviceClassesChanged}
@expanded-changed=${this._deviceClassesExpanded}
></ha-filter-device-classes>
<ha-filter-entity-types
.value=${this.filters.types}
.expanded=${this._expandedFilter === "types"}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._typesExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@@ -158,6 +163,8 @@ export class HaSourcesPicker extends LitElement {
`;
}
private _activeFilter = memoizeOne(sourceFilterFunc);
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
@@ -168,12 +175,8 @@ export class HaSourcesPicker extends LitElement {
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _domainsChanged(ev: CustomEvent) {
this._filterChanged("domains", ev);
}
private _deviceClassesChanged(ev: CustomEvent) {
this._filterChanged("deviceClasses", ev);
private _typesChanged(ev: CustomEvent) {
this._filterChanged("types", ev);
}
private _integrationsChanged(ev: CustomEvent) {
@@ -191,12 +194,8 @@ export class HaSourcesPicker extends LitElement {
});
}
private _domainsExpanded(ev: CustomEvent) {
this._filterExpanded("domains", ev);
}
private _deviceClassesExpanded(ev: CustomEvent) {
this._filterExpanded("deviceClasses", ev);
private _typesExpanded(ev: CustomEvent) {
this._filterExpanded("types", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
+11
View File
@@ -108,6 +108,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Entities that pass the filters the page currently has on. Narrows the
* counts, unlike `entityFilter`, which says what can be picked at all.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
@property({ type: Boolean, reflect: true }) public disabled = false;
@state() private _selectedSection?: TargetTypeFloorless;
@@ -286,6 +293,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ entity: entityIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -306,6 +314,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ device: deviceIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -329,6 +338,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -348,6 +358,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ label: labelIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -118,6 +118,16 @@ class DialogTargetDetails extends LitElement implements HassDialog {
);
};
private _combinedFilter = memoizeOne(
(
entityFilter: HaEntityPickerEntityFilterFunc | undefined,
activeFilter: (entityId: string) => boolean
): HaEntityPickerEntityFilterFunc =>
(stateObj) =>
(!entityFilter || entityFilter(stateObj)) &&
activeFilter(stateObj.entity_id)
);
private _selectorTarget() {
return this._params?.selector?.target || null;
}
@@ -127,6 +137,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return nothing;
}
const { activeFilter } = this._params;
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
let includeDomains: string[] | undefined;
@@ -145,6 +157,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
}
if (activeFilter) {
entityFilter = this._combinedFilter(entityFilter, activeFilter);
}
const waitingForSources =
this._params.selector &&
this._hasIntegration(this._params.selector) &&
@@ -11,6 +11,7 @@ export interface TargetDetailsDialogParams {
selector?: TargetSelector;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
activeFilter?: (entityId: string) => boolean;
includeDomains?: string[];
includeDeviceClasses?: string[];
primaryEntitiesOnly?: boolean;
@@ -34,6 +34,9 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -88,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -92,6 +92,13 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Entities that pass the filters the page currently has on. Narrows the
* count, and the target details, but not what the target resolves to.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -189,7 +196,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
@@ -222,12 +229,7 @@ export class HaTargetPickerItemRow extends LitElement {
${
this.expand || !entries.referenced_entities.length
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</span>`
: html`<ha-button
appearance="filled"
@@ -235,12 +237,7 @@ export class HaTargetPickerItemRow extends LitElement {
size="xs"
@click=${this._openDetails}
>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</ha-button>`
}
</div>
@@ -334,6 +331,28 @@ export class HaTargetPickerItemRow extends LitElement {
`;
}
private _entityCounts(entries: ExtractFromTargetResultReferenced) {
const total = entries.referenced_entities.length;
return {
total,
count: this.activeFilter
? entries.referenced_entities.filter(this.activeFilter).length
: total,
};
}
private _entitiesLabel(entries: ExtractFromTargetResultReferenced): string {
const { count, total } = this._entityCounts(entries);
return this.activeFilter
? this.hass.localize(
"ui.components.target-picker.entities_count_filtered",
{ count, total }
)
: this.hass.localize("ui.components.target-picker.entities_count", {
count,
});
}
private _renderEntries() {
const entries = this.parentEntries || this._entries;
@@ -816,6 +835,7 @@ export class HaTargetPickerItemRow extends LitElement {
itemId: this.itemId,
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
activeFilter: this.activeFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
primaryEntitiesOnly: this.primaryEntitiesOnly,
+75
View File
@@ -0,0 +1,75 @@
import type { LocalizeFunc } from "../../common/translations/localize";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../sensor_numeric_device_classes";
export const NO_DEVICE_CLASS = "none";
// Mirrors core's `<Domain>DeviceClass` enums, until the backend exposes them.
export const DOMAIN_DEVICE_CLASSES: Record<string, readonly string[]> = {
binary_sensor: [
"battery",
"battery_charging",
"carbon_monoxide",
"cold",
"connectivity",
"door",
"garage_door",
"gas",
"heat",
"light",
"lock",
"moisture",
"motion",
"moving",
"occupancy",
"opening",
"plug",
"power",
"presence",
"problem",
"running",
"safety",
"smoke",
"sound",
"tamper",
"update",
"vibration",
"window",
],
button: ["identify", "restart", "update"],
cover: [
"awning",
"blind",
"curtain",
"damper",
"door",
"garage",
"gate",
"shade",
"shutter",
"window",
],
event: ["button", "doorbell", "motion"],
humidifier: ["dehumidifier", "humidifier"],
image_processing: ["alpr", "face", "ocr"],
infrared: ["emitter", "receiver"],
media_player: ["projector", "receiver", "speaker", "tv"],
number: SENSOR_NUMERIC_DEVICE_CLASSES,
sensor: [
...SENSOR_NUMERIC_DEVICE_CLASSES,
"date",
"enum",
"timestamp",
"uptime",
],
switch: ["outlet", "switch"],
update: ["firmware"],
valve: ["gas", "water"],
};
export const computeDeviceClassName = (
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string =>
localize(`component.${domain}.entity_component.${deviceClass}.name`) ||
deviceClass;
+91
View File
@@ -0,0 +1,91 @@
import { computeDomain } from "../../common/entity/compute_domain";
import type { HomeAssistant } from "../../types";
import { DOMAIN_DEVICE_CLASSES, NO_DEVICE_CLASS } from "./device_class";
const SEPARATOR = "/";
export interface EntityType {
domain: string;
deviceClass?: string;
}
export const entityTypeKey = (domain: string, deviceClass?: string): string =>
deviceClass ? `${domain}${SEPARATOR}${deviceClass}` : domain;
export const parseEntityType = (key: string): EntityType => {
const index = key.indexOf(SEPARATOR);
return index === -1
? { domain: key }
: {
domain: key.slice(0, index),
deviceClass: key.slice(index + SEPARATOR.length),
};
};
export const entityTypesNeedStates = (types?: string[]): boolean =>
!!types?.some((key) => key.includes(SEPARATOR));
// A domain worth no split maps to an empty list rather than to its lone bucket.
export const usedEntityTypes = (
states: HomeAssistant["states"]
): Map<string, string[]> => {
const byDomain = new Map<string, Set<string>>();
for (const stateObj of Object.values(states)) {
const domain = computeDomain(stateObj.entity_id);
let classes = byDomain.get(domain);
if (!classes) {
classes = new Set();
byDomain.set(domain, classes);
}
if (domain in DOMAIN_DEVICE_CLASSES) {
classes.add(stateObj.attributes.device_class || NO_DEVICE_CLASS);
}
}
return new Map(
[...byDomain].map(([domain, classes]) => [
domain,
classes.size > 1 ? [...classes] : [],
])
);
};
// Relies on a domain and its device classes never being selected at once.
export const entityTypeFilterFunc = (
types: string[],
states: HomeAssistant["states"]
): ((entityId: string) => boolean) => {
const domains = new Set<string>();
const deviceClasses = new Map<string, Set<string>>();
for (const key of types) {
const { domain, deviceClass } = parseEntityType(key);
if (deviceClass === undefined) {
domains.add(domain);
} else {
let classes = deviceClasses.get(domain);
if (!classes) {
classes = new Set();
deviceClasses.set(domain, classes);
}
classes.add(deviceClass);
}
}
return (entityId: string) => {
const domain = computeDomain(entityId);
if (domains.has(domain)) {
return true;
}
const classes = deviceClasses.get(domain);
if (!classes) {
return false;
}
const stateObj = states[entityId];
if (!stateObj) {
return false;
}
return classes.has(stateObj.attributes.device_class || NO_DEVICE_CLASS);
};
};
+10 -2
View File
@@ -49,6 +49,7 @@ import {
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import "../../components/ha-spinner";
import "../../components/ha-top-app-bar-fixed";
import type { EntitySources } from "../../data/entity/entity_sources";
@@ -216,6 +217,7 @@ class HaPanelHistory extends LitElement {
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.entitySources=${this._entitySources}
.disabled=${this._isLoading}
.description=${this.hass.localize(
"ui.panel.history.no_targets"
@@ -536,8 +538,10 @@ class HaPanelHistory extends LitElement {
this.hass.areas
),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
// Only a device class narrows down using the states.
entityTypesNeedStates(this._filters.types)
? this.hass.states
: EMPTY_STATES,
this.hass.entities,
this._entitySources
);
@@ -732,6 +736,10 @@ class HaPanelHistory extends LitElement {
haStyle,
haStyleScrollbar,
css`
:host {
/* The target picker chips need more room than a plain filter list. */
--ha-filter-pane-width: 340px;
}
ha-top-app-bar-fixed {
height: 100vh;
overflow-x: hidden;
+8 -2
View File
@@ -44,6 +44,7 @@ import {
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import "../../components/ha-top-app-bar-fixed";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { EntitySources } from "../../data/entity/entity_sources";
@@ -163,6 +164,7 @@ export class HaPanelLogbook extends LitElement {
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.entitySources=${this._entitySources}
.entityFilter=${this._filterFunc}
.description=${this.hass.localize(
"ui.panel.logbook.no_targets"
@@ -331,8 +333,10 @@ export class HaPanelLogbook extends LitElement {
return this.__filterEntityIds(
targetEntities ?? this.__logbookEntityIds(this.hass.states),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
// Only a device class narrows down using the states.
entityTypesNeedStates(this._filters.types)
? this.hass.states
: EMPTY_STATES,
this.hass.entities,
this._entitySources
);
@@ -579,6 +583,8 @@ export class HaPanelLogbook extends LitElement {
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
/* The target picker chips need more room than a plain filter list. */
--ha-filter-pane-width: 340px;
}
.content {
+6 -2
View File
@@ -806,6 +806,7 @@
"replace_device": "Replace",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"entities_count_filtered": "{count}/{total} {total, plural,\n one {entity}\n other {entities}\n}",
"target_details": "Target details",
"no_targets": "No targets",
"no_target_found": "No target found for {term}",
@@ -835,8 +836,11 @@
},
"style": "Time format style"
},
"filter-device-classes": {
"caption": "Device class"
"filter-entity-types": {
"caption": "Type",
"no_device_class": "No type",
"expand": "Expand",
"collapse": "Collapse"
},
"subpage-data-table": {
"filters": "Filters",
+125
View File
@@ -0,0 +1,125 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import type { HomeAssistant } from "../../../src/types";
import {
entityTypeFilterFunc,
entityTypesNeedStates,
parseEntityType,
usedEntityTypes,
} from "../../../src/data/entity/entity_type";
const state = (entityId: string, deviceClass?: string): HassEntity =>
({
entity_id: entityId,
attributes: deviceClass ? { device_class: deviceClass } : {},
}) as unknown as HassEntity;
const makeStates = (...entities: HassEntity[]): HomeAssistant["states"] =>
Object.fromEntries(entities.map((entity) => [entity.entity_id, entity]));
describe("parseEntityType", () => {
it("reads a domain and a device class", () => {
expect(parseEntityType("sensor")).toEqual({ domain: "sensor" });
expect(parseEntityType("sensor/power")).toEqual({
domain: "sensor",
deviceClass: "power",
});
});
});
describe("entityTypesNeedStates", () => {
it("only needs the states for a device class", () => {
expect(entityTypesNeedStates(["light", "cover"])).toBe(false);
expect(entityTypesNeedStates(["light", "sensor/power"])).toBe(true);
expect(entityTypesNeedStates(undefined)).toBe(false);
});
});
describe("usedEntityTypes", () => {
it("splits a domain by device class, none included", () => {
const types = usedEntityTypes(
makeStates(
state("binary_sensor.front_door", "door"),
state("binary_sensor.hall_motion", "motion"),
state("binary_sensor.unknown")
)
);
expect(types.get("binary_sensor")?.sort()).toEqual([
"door",
"motion",
"none",
]);
});
it("keeps a single-bucket domain whole", () => {
const types = usedEntityTypes(
makeStates(
state("light.kitchen"),
state("cover.garage", "garage"),
state("cover.gate", "garage")
)
);
expect(types.get("light")).toEqual([]);
expect(types.get("cover")).toEqual([]);
});
it("ignores a device class on a domain that has none", () => {
const types = usedEntityTypes(makeStates(state("light.kitchen", "bogus")));
expect(types.get("light")).toEqual([]);
});
});
describe("entityTypeFilterFunc", () => {
const states = makeStates(
state("binary_sensor.front_door", "door"),
state("binary_sensor.hall_motion", "motion"),
state("binary_sensor.unknown"),
state("light.kitchen")
);
it("matches a whole domain", () => {
const matches = entityTypeFilterFunc(["binary_sensor"], states);
expect(matches("binary_sensor.front_door")).toBe(true);
expect(matches("binary_sensor.unknown")).toBe(true);
expect(matches("light.kitchen")).toBe(false);
});
it("matches a device class", () => {
const matches = entityTypeFilterFunc(["binary_sensor/door"], states);
expect(matches("binary_sensor.front_door")).toBe(true);
expect(matches("binary_sensor.hall_motion")).toBe(false);
expect(matches("binary_sensor.unknown")).toBe(false);
});
it("matches the entities that carry no device class", () => {
const matches = entityTypeFilterFunc(["binary_sensor/none"], states);
expect(matches("binary_sensor.unknown")).toBe(true);
expect(matches("binary_sensor.front_door")).toBe(false);
});
it("unions the selection", () => {
const matches = entityTypeFilterFunc(
["light", "binary_sensor/door"],
states
);
expect(matches("light.kitchen")).toBe(true);
expect(matches("binary_sensor.front_door")).toBe(true);
expect(matches("binary_sensor.hall_motion")).toBe(false);
});
it("keeps an entity without a state only for its domain", () => {
const matches = entityTypeFilterFunc(["binary_sensor"], states);
const narrowed = entityTypeFilterFunc(["binary_sensor/none"], states);
expect(matches("binary_sensor.disabled")).toBe(true);
expect(narrowed("binary_sensor.disabled")).toBe(false);
});
});