mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-21 22:28:40 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff33d68b8a | ||
|
|
01a2d15fc7 | ||
|
|
7c15af3079 |
+4
-4
@@ -107,7 +107,7 @@
|
||||
"hls.js": "1.7.0",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"js-yaml": "5.3.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -115,7 +115,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.10",
|
||||
"marked": "18.0.9",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -170,7 +170,7 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.28.8",
|
||||
@@ -215,7 +215,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.11",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import {
|
||||
STATE_ATTRIBUTES,
|
||||
STATE_ATTRIBUTES_DOMAIN_CLASS,
|
||||
} from "../data/entity/entity_attributes";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-attribute-value";
|
||||
import "./ha-expansion-panel";
|
||||
|
||||
@customElement("ha-attributes")
|
||||
class HaAttributes extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public stateObj?: HassEntity;
|
||||
|
||||
@property({ attribute: "extra-filters" }) public extraFilters?: string;
|
||||
|
||||
@state() private _expanded = false;
|
||||
|
||||
private get _filteredAttributes() {
|
||||
return this._computeDisplayAttributes(
|
||||
STATE_ATTRIBUTES.concat(
|
||||
this.extraFilters ? this.extraFilters.split(",") : [],
|
||||
(this.stateObj &&
|
||||
STATE_ATTRIBUTES_DOMAIN_CLASS[computeStateDomain(this.stateObj)]?.[
|
||||
this.stateObj.attributes?.device_class
|
||||
]) ||
|
||||
[]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (
|
||||
changedProperties.has("extraFilters") ||
|
||||
changedProperties.has("stateObj")
|
||||
) {
|
||||
this.toggleAttribute("empty", this._filteredAttributes.length === 0);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const attributes = this._filteredAttributes;
|
||||
|
||||
if (attributes.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
.header=${this.hass.localize(
|
||||
"ui.components.attributes.expansion_header"
|
||||
)}
|
||||
outlined
|
||||
@expanded-will-change=${this._expandedChanged}
|
||||
>
|
||||
<div class="attribute-container">
|
||||
${
|
||||
this._expanded
|
||||
? html`
|
||||
${attributes.map(
|
||||
(attribute) => html`
|
||||
<div class="data-entry">
|
||||
<div class="key">
|
||||
${computeAttributeNameDisplay(
|
||||
this.hass.localize,
|
||||
this.stateObj!,
|
||||
this.hass.entities,
|
||||
attribute
|
||||
)}
|
||||
</div>
|
||||
<div class="value">
|
||||
<ha-attribute-value
|
||||
.attribute=${attribute}
|
||||
.stateObj=${this.stateObj}
|
||||
></ha-attribute-value>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this.stateObj.attributes.attribution
|
||||
? html`
|
||||
<div class="attribution">
|
||||
${this.stateObj.attributes.attribution}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.attribute-container {
|
||||
margin-bottom: 8px;
|
||||
direction: ltr;
|
||||
}
|
||||
.data-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.data-entry .value {
|
||||
max-width: 60%;
|
||||
overflow-wrap: break-word;
|
||||
text-align: right;
|
||||
}
|
||||
.key {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.attribution {
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
hr {
|
||||
border-color: var(--divider-color);
|
||||
border-bottom: none;
|
||||
margin: 16px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
private _computeDisplayAttributes(filtersArray: string[]): string[] {
|
||||
if (!this.stateObj) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(this.stateObj.attributes).filter(
|
||||
(key) => filtersArray.indexOf(key) === -1
|
||||
);
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this._expanded = ev.detail.expanded;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-attributes": HaAttributes;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -1,5 +1,38 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { formatDurationDigital } from "../../common/datetime/format_duration";
|
||||
import type { FrontendLocaleData } from "../translation";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
|
||||
export const STATE_ATTRIBUTES = [
|
||||
"entity_id",
|
||||
"assumed_state",
|
||||
"attribution",
|
||||
"custom_ui_more_info",
|
||||
"custom_ui_state_card",
|
||||
"device_class",
|
||||
"editable",
|
||||
"emulated_hue_name",
|
||||
"emulated_hue",
|
||||
"entity_picture",
|
||||
"event_types",
|
||||
"friendly_name",
|
||||
"haaska_hidden",
|
||||
"haaska_name",
|
||||
"icon",
|
||||
"initial_state",
|
||||
"last_reset",
|
||||
"restored",
|
||||
"state_class",
|
||||
"supported_features",
|
||||
"unit_of_measurement",
|
||||
"available_tones",
|
||||
];
|
||||
|
||||
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
|
||||
sensor: {
|
||||
enum: ["options"],
|
||||
},
|
||||
};
|
||||
|
||||
export const TEMPERATURE_ATTRIBUTES = new Set([
|
||||
"temperature",
|
||||
@@ -177,3 +210,15 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
|
||||
"swing_modes",
|
||||
"token",
|
||||
];
|
||||
|
||||
export const computeShownAttributes = (stateObj: HassEntity) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const filtersArray = STATE_ATTRIBUTES.concat(
|
||||
STATE_ATTRIBUTES_DOMAIN_CLASS[domain]?.[
|
||||
stateObj.attributes?.device_class
|
||||
] || []
|
||||
);
|
||||
return Object.keys(stateObj.attributes).filter(
|
||||
(key) => filtersArray.indexOf(key) === -1
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -34,4 +34,12 @@ export const moreInfoControlStyle = css`
|
||||
.buttons > * {
|
||||
margin: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-attributes {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
ha-more-info-control-select-container + ha-attributes:not([empty]) {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../../common/entity/compute_floor_name";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
@@ -14,6 +15,7 @@ import "../../components/ha-attribute-value";
|
||||
import "../../components/item/ha-list-item-value";
|
||||
import "../../components/list/ha-grouped-list";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeShownAttributes } from "../../data/entity/entity_attributes";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
@@ -24,7 +26,6 @@ import type { FeatureEnum } from "../../common/entity/get_domain_features";
|
||||
import { getFeatures } from "../../common/entity/get_domain_features";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import { titleCase } from "../../common/string/title-case";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
|
||||
interface DetailsViewParams {
|
||||
entityId: string;
|
||||
@@ -214,7 +215,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
stateObj: HassEntity
|
||||
): {
|
||||
stateEntries: DetailEntry[];
|
||||
attributes: { name: string; label: string }[];
|
||||
attributes: string[];
|
||||
yamlData: {
|
||||
state: {
|
||||
translated: string;
|
||||
@@ -227,14 +228,11 @@ class HaMoreInfoDetails extends LitElement {
|
||||
} => {
|
||||
const translatedState = this.hass.formatEntityState(stateObj);
|
||||
|
||||
const attributes = Object.keys(stateObj.attributes)
|
||||
.map((a) => ({
|
||||
name: a,
|
||||
label: this.hass.formatEntityAttributeName(stateObj, a),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
stringCompare(a.label, b.label, this.hass.locale.language)
|
||||
);
|
||||
const detailsAttributes = computeShownAttributes(stateObj);
|
||||
const detailsAttributeSet = new Set(detailsAttributes);
|
||||
const builtInAttributes = Object.keys(stateObj.attributes).filter(
|
||||
(attribute) => !detailsAttributeSet.has(attribute)
|
||||
);
|
||||
|
||||
return {
|
||||
stateEntries: [
|
||||
@@ -255,7 +253,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
value: this._formatTimestamp(stateObj.last_updated),
|
||||
},
|
||||
],
|
||||
attributes,
|
||||
attributes: [...detailsAttributes, ...builtInAttributes],
|
||||
yamlData: {
|
||||
state: {
|
||||
translated: translatedState,
|
||||
@@ -291,7 +289,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _renderAttributes(attributes: { name: string; label: string }[]) {
|
||||
private _renderAttributes(attributes: string[]) {
|
||||
if (attributes.length === 0) {
|
||||
return html`<div class="empty">
|
||||
${this.hass.localize("ui.common.none")}
|
||||
@@ -306,13 +304,20 @@ class HaMoreInfoDetails extends LitElement {
|
||||
|
||||
return attributes.map(
|
||||
(attribute) => html`
|
||||
<ha-list-item-value .label=${attribute.label}>
|
||||
<ha-list-item-value
|
||||
.label=${computeAttributeNameDisplay(
|
||||
this.hass.localize,
|
||||
this._stateObj!,
|
||||
this.hass.entities,
|
||||
attribute
|
||||
)}
|
||||
>
|
||||
${
|
||||
attribute.name === "supported_features" && featureEnum
|
||||
attribute === "supported_features" && featureEnum
|
||||
? this._renderFeatures(featureEnum, this._stateObj!)
|
||||
: html`
|
||||
<ha-attribute-value
|
||||
.attribute=${attribute.name}
|
||||
.attribute=${attribute}
|
||||
.stateObj=${this._stateObj}
|
||||
></ha-attribute-value>
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -49,8 +49,6 @@ interface LovelacePanelConfig {
|
||||
mode: "yaml" | "storage";
|
||||
}
|
||||
|
||||
const EXTERNALLY_UPDATED_TOAST_ID = "lovelace-externally-updated";
|
||||
|
||||
let editorLoaded = false;
|
||||
let resourcesLoaded = false;
|
||||
|
||||
@@ -267,7 +265,6 @@ export class LovelacePanel extends LitElement {
|
||||
return;
|
||||
}
|
||||
showToast(this, {
|
||||
id: EXTERNALLY_UPDATED_TOAST_ID,
|
||||
message: this.hass!.localize(
|
||||
"ui.panel.lovelace.externally_updated_toast.message"
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeShownAttributes } from "../../src/data/entity/entity_attributes";
|
||||
|
||||
describe("computeShownAttributes", () => {
|
||||
it("filters globally hidden attributes", () => {
|
||||
const stateObj = {
|
||||
entity_id: "sensor.temperature",
|
||||
attributes: {
|
||||
friendly_name: "Office temperature",
|
||||
unit_of_measurement: "°C",
|
||||
temperature: 21,
|
||||
custom_value: "shown",
|
||||
},
|
||||
} as unknown as HassEntity;
|
||||
|
||||
expect(computeShownAttributes(stateObj)).toEqual([
|
||||
"temperature",
|
||||
"custom_value",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters domain and device class specific attributes", () => {
|
||||
const stateObj = {
|
||||
entity_id: "sensor.status",
|
||||
attributes: {
|
||||
device_class: "enum",
|
||||
options: ["home", "away"],
|
||||
current_option: "home",
|
||||
},
|
||||
} as unknown as HassEntity;
|
||||
|
||||
expect(computeShownAttributes(stateObj)).toEqual(["current_option"]);
|
||||
});
|
||||
|
||||
it("keeps device-class attributes for other device classes", () => {
|
||||
const stateObj = {
|
||||
entity_id: "sensor.status",
|
||||
attributes: {
|
||||
device_class: "temperature",
|
||||
options: ["home", "away"],
|
||||
current_option: "home",
|
||||
},
|
||||
} as unknown as HassEntity;
|
||||
|
||||
expect(computeShownAttributes(stateObj)).toEqual([
|
||||
"options",
|
||||
"current_option",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2776,12 +2776,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-messageformat-parser@npm:3.5.17":
|
||||
version: 3.5.17
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.17"
|
||||
"@formatjs/icu-messageformat-parser@npm:3.5.16":
|
||||
version: 3.5.16
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.16"
|
||||
dependencies:
|
||||
"@formatjs/icu-skeleton-parser": "npm:2.1.11"
|
||||
checksum: 10/cbb9daf23f65e4ef3697eae3be4c6888eda942fcde18929dfc6e96ef6c45052409d20340ece13476eeadd062e222809c46a2f26e3b453c9cbd4979bb6c0b0ae5
|
||||
checksum: 10/406cf08cf01a68e244c7077b726b199dde6ba4c95ecb2d807bff9ec62a9acc77b7f47fad196f02dfbd7a7b8110cccf5d5392e9693069e2484cf96f00899a728f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -6452,12 +6452,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/coverage-v8@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/coverage-v8@npm:4.1.11"
|
||||
"@vitest/coverage-v8@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/coverage-v8@npm:4.1.10"
|
||||
dependencies:
|
||||
"@bcoe/v8-coverage": "npm:^1.0.2"
|
||||
"@vitest/utils": "npm:4.1.11"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
ast-v8-to-istanbul: "npm:^1.0.0"
|
||||
istanbul-lib-coverage: "npm:^3.2.2"
|
||||
istanbul-lib-report: "npm:^3.0.1"
|
||||
@@ -6467,34 +6467,34 @@ __metadata:
|
||||
std-env: "npm:^4.0.0-rc.1"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
peerDependencies:
|
||||
"@vitest/browser": 4.1.11
|
||||
vitest: 4.1.11
|
||||
"@vitest/browser": 4.1.10
|
||||
vitest: 4.1.10
|
||||
peerDependenciesMeta:
|
||||
"@vitest/browser":
|
||||
optional: true
|
||||
checksum: 10/b6171ec592e0017c3b10954a9400b10af0becf944a0533af01b002a4cc35b6e562a353b4837451a44b73c5561de358c23a2c135d4e1e79bc9041ebb241a68440
|
||||
checksum: 10/e593f5205a65d10f200e68a99e720d7a9f5e9be65d8160e4f0b6b5f1a7a87f0453c03e79fd1c022dbd7fb26a22657ca4d9410ae27b36da90d41d7ca04f681ab1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/expect@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/expect@npm:4.1.11"
|
||||
"@vitest/expect@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/expect@npm:4.1.10"
|
||||
dependencies:
|
||||
"@standard-schema/spec": "npm:^1.1.0"
|
||||
"@types/chai": "npm:^5.2.2"
|
||||
"@vitest/spy": "npm:4.1.11"
|
||||
"@vitest/utils": "npm:4.1.11"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
chai: "npm:^6.2.2"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10/9bfcfe5ad926ab58beea1c700dc057f17422f14516506f8fc12c9881ed3e81d4c2faadb768042c8497fdee7007e201c1bd3e7d2e91157dbb47fb5c07c4c02aaa
|
||||
checksum: 10/487fcad404a68968a54ae5fb9d099f12170cd793420a04b34a5606516317090c50a8303ab687c70166ee181864e3e138941d4a96d0405434dcd37696b3105350
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/mocker@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/mocker@npm:4.1.11"
|
||||
"@vitest/mocker@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/mocker@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/spy": "npm:4.1.11"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
estree-walker: "npm:^3.0.3"
|
||||
magic-string: "npm:^0.30.21"
|
||||
peerDependencies:
|
||||
@@ -6505,56 +6505,56 @@ __metadata:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
checksum: 10/00b6e1266d8403194b49313e3a9a1af0dff2c773f4b2df11f4955fa0f244fd4b59484cd23381dfef8af30298e2651c1aaa42b439fdbc871bb4bb911de38a9509
|
||||
checksum: 10/ae9645d1bcdad3ab7de7182feb4f1c9148a5ff97cef19581eec9257112aace94889eee9a1ad12e40ce59453ac05f52453b5fdb49ff76a31af8ccdbaaa4471ef3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/pretty-format@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/pretty-format@npm:4.1.11"
|
||||
"@vitest/pretty-format@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/pretty-format@npm:4.1.10"
|
||||
dependencies:
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10/2dfc2f20dbe1c4dbea33ec42e85a8b5648aa6585521bea47573406f5cefb81cd3b86b71f981c4e3d69946e77252cb42a700416c1dc656bb0478cb2932c953cdc
|
||||
checksum: 10/e4f6907143ab0e40dda29d70b17027586c92921d622091321f10512e660b3995dcee7aa56e17b750b72560f295e25f96035372348415f18ebfd39b66a55b4704
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/runner@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/runner@npm:4.1.11"
|
||||
"@vitest/runner@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/runner@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/utils": "npm:4.1.11"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
pathe: "npm:^2.0.3"
|
||||
checksum: 10/5247df824fa28b458ba0102592dfec50707982193b62076db941fbe5d7c88fb7067e68a33c194db0092bbe35459cfbeaed33b3c667e5f02192c18baeb4f56239
|
||||
checksum: 10/2c962cb13af0880990036808a35679b7ac6657c8f542490234c2faa6ffd2ab080ac6bf21b487c64d84aa635cfb37b49eb679098c2003a100dfc6c4d5e87bf055
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/snapshot@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/snapshot@npm:4.1.11"
|
||||
"@vitest/snapshot@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/snapshot@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/pretty-format": "npm:4.1.11"
|
||||
"@vitest/utils": "npm:4.1.11"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
magic-string: "npm:^0.30.21"
|
||||
pathe: "npm:^2.0.3"
|
||||
checksum: 10/5d096373fb4b102f65ff884844a18c2d2e7d88caf68a64a842a245573311b2d531ca5a94ebf7e4fe39324e71a78670f74c949d4ec2cad3764c3f4c272b84d982
|
||||
checksum: 10/7940d83ffd2fbebf9a04ea31e196b7e8bf981093ec739950959fe8dd29caa33c80823780fb4b1063d9459c44a0a8d8b2748c00dfb6941becd7404e6d687eea01
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/spy@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/spy@npm:4.1.11"
|
||||
checksum: 10/d49a7ed7501080e5f817d61250a169a46fcc7901887e4985a1e08705ce79aea8d1edcffd74f4dc6669ea1bc3d717a39354ce89c67188d81a63dc439d42f195f6
|
||||
"@vitest/spy@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/spy@npm:4.1.10"
|
||||
checksum: 10/7c1b79a95474338e0659f0f2e43be4df1ef7939ff5b37b044954e0287582947803bd417508f44a7f244809672309d9b3dd67660b704ec3fe7f323cc958ae47a3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/utils@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/utils@npm:4.1.11"
|
||||
"@vitest/utils@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/utils@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/pretty-format": "npm:4.1.11"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
convert-source-map: "npm:^2.0.0"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10/f05381e12d0926db7b01bfaae9a577fa664d36b96c23df185e4b0f6dad3a9fb59ac00931613da53b4511ee6ab473a14ac500c72c5ec5e9b3c3042875051f20c4
|
||||
checksum: 10/95484aad55c7b00bbcd4963e27cbb86fe207620a6093973d68da9d0a06bad37c388d84c9ab43d5f35d88e46c8f376a5592d9c54c025c958361160f4802bb25ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10047,7 +10047,7 @@ __metadata:
|
||||
"@types/tar": "npm:7.0.87"
|
||||
"@typescript/native": "npm:[email protected]"
|
||||
"@vibrant/color": "npm:4.0.4"
|
||||
"@vitest/coverage-v8": "npm:4.1.11"
|
||||
"@vitest/coverage-v8": "npm:4.1.10"
|
||||
"@vvo/tzdb": "npm:6.198.0"
|
||||
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
|
||||
"@webcomponents/webcomponentsjs": "npm:2.8.0"
|
||||
@@ -10092,7 +10092,7 @@ __metadata:
|
||||
html-minifier-terser: "npm:7.2.0"
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.14"
|
||||
intl-messageformat: "npm:11.2.13"
|
||||
js-yaml: "npm:5.3.0"
|
||||
jsdom: "npm:30.0.1"
|
||||
jszip: "npm:3.10.1"
|
||||
@@ -10109,7 +10109,7 @@ __metadata:
|
||||
lodash.template: "npm:4.18.1"
|
||||
luxon: "npm:3.7.2"
|
||||
map-stream: "npm:0.0.7"
|
||||
marked: "npm:18.0.10"
|
||||
marked: "npm:18.0.9"
|
||||
memoize-one: "npm:6.0.0"
|
||||
minify-literals: "npm:2.1.0"
|
||||
node-vibrant: "npm:4.0.4"
|
||||
@@ -10134,7 +10134,7 @@ __metadata:
|
||||
typescript: "npm:6.0.3"
|
||||
typescript-eslint: "npm:8.67.0"
|
||||
vite-tsconfig-paths: "npm:6.1.1"
|
||||
vitest: "npm:4.1.11"
|
||||
vitest: "npm:4.1.10"
|
||||
webpack-stats-plugin: "npm:1.1.3"
|
||||
webpackbar: "npm:7.0.0"
|
||||
weekstart: "npm:2.0.0"
|
||||
@@ -10453,13 +10453,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"intl-messageformat@npm:11.2.14":
|
||||
version: 11.2.14
|
||||
resolution: "intl-messageformat@npm:11.2.14"
|
||||
"intl-messageformat@npm:11.2.13":
|
||||
version: 11.2.13
|
||||
resolution: "intl-messageformat@npm:11.2.13"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.17"
|
||||
checksum: 10/2720155c42bfd99a00d41f7bc9e2f5c2c83c2b6cb2353006b7d8e736a0f1ed20d0147406c1aeba0748731a0ce588c67304b52eb2fe92b42b928bf1ef6bffdcdf
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.16"
|
||||
checksum: 10/7da1b2e01258ae310cd3aeabd6302497a70755ef860cc666405e89ede2927f5d5995d8a6f9be556d7dfc4f7b1eb0a13b26c9633234fdb4afea762013bb25af65
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11757,12 +11757,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:18.0.10":
|
||||
version: 18.0.10
|
||||
resolution: "marked@npm:18.0.10"
|
||||
"marked@npm:18.0.9":
|
||||
version: 18.0.9
|
||||
resolution: "marked@npm:18.0.9"
|
||||
bin:
|
||||
marked: bin/marked.js
|
||||
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
|
||||
checksum: 10/99d337c50acd57034734f8460f25b28e9658b15627f950092707cb443b840f0bd29fe343bf211b948e643303c34abdb5b5a12cf5245a846635750697524bb747
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15470,17 +15470,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vitest@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "vitest@npm:4.1.11"
|
||||
"vitest@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "vitest@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/expect": "npm:4.1.11"
|
||||
"@vitest/mocker": "npm:4.1.11"
|
||||
"@vitest/pretty-format": "npm:4.1.11"
|
||||
"@vitest/runner": "npm:4.1.11"
|
||||
"@vitest/snapshot": "npm:4.1.11"
|
||||
"@vitest/spy": "npm:4.1.11"
|
||||
"@vitest/utils": "npm:4.1.11"
|
||||
"@vitest/expect": "npm:4.1.10"
|
||||
"@vitest/mocker": "npm:4.1.10"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
"@vitest/runner": "npm:4.1.10"
|
||||
"@vitest/snapshot": "npm:4.1.10"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
es-module-lexer: "npm:^2.0.0"
|
||||
expect-type: "npm:^1.3.0"
|
||||
magic-string: "npm:^0.30.21"
|
||||
@@ -15498,12 +15498,12 @@ __metadata:
|
||||
"@edge-runtime/vm": "*"
|
||||
"@opentelemetry/api": ^1.9.0
|
||||
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
"@vitest/browser-playwright": 4.1.11
|
||||
"@vitest/browser-preview": 4.1.11
|
||||
"@vitest/browser-webdriverio": 4.1.11
|
||||
"@vitest/coverage-istanbul": 4.1.11
|
||||
"@vitest/coverage-v8": 4.1.11
|
||||
"@vitest/ui": 4.1.11
|
||||
"@vitest/browser-playwright": 4.1.10
|
||||
"@vitest/browser-preview": 4.1.10
|
||||
"@vitest/browser-webdriverio": 4.1.10
|
||||
"@vitest/coverage-istanbul": 4.1.10
|
||||
"@vitest/coverage-v8": 4.1.10
|
||||
"@vitest/ui": 4.1.10
|
||||
happy-dom: "*"
|
||||
jsdom: "*"
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -15534,7 +15534,7 @@ __metadata:
|
||||
optional: false
|
||||
bin:
|
||||
vitest: ./vitest.mjs
|
||||
checksum: 10/054f1e25d90d911693b0b93c5b85a7c3105775aa3e39c3279c7d3e7af719e2d94070a898d6d74292445366c1215bb91d07063989ac0965973f0c5ae22ad3b06b
|
||||
checksum: 10/020843460fe696c23be2a363634dde4daf54625f1c443c24066ba3f87c478b0ccfdd5124343ba30eb092f54902ffea09f4bed0af4a16a9ee805e494ee2dce34e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user