Compare commits

..

1 Commits

Author SHA1 Message Date
Petar Petrov f01bb2bb7d Remove dead and unused hass props/bindings from migrated leaves 2026-06-22 15:32:26 +03:00
40 changed files with 136 additions and 1027 deletions
+7 -23
View File
@@ -1,5 +1,5 @@
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { ItemType, RelatedResult } from "../../data/search";
import type { RelatedResult } from "../../data/search";
export interface RelatedIdSets {
areas: Set<string>;
@@ -8,30 +8,14 @@ export interface RelatedIdSets {
}
/**
* Build a set of related IDs, merging in the current (queried) item.
* `search/related` does not echo the queried item back, but it is the closest
* related item (e.g. a card editor's own entity), so it is merged into the
* matching group when it is an area, device, or entity.
* Build a set of related IDs for a given related result.
* @param related - The related result to build the sets from.
* @param current - The queried item to merge in.
* @returns The related ID sets, including the current item.
* @returns The related ID sets.
*/
export const buildRelatedIdSets = (
related?: RelatedResult,
current?: { itemType: ItemType; itemId: string }
): RelatedIdSets => ({
areas: new Set([
...(related?.area || []),
...(current?.itemType === "area" ? [current.itemId] : []),
]),
devices: new Set([
...(related?.device || []),
...(current?.itemType === "device" ? [current.itemId] : []),
]),
entities: new Set([
...(related?.entity || []),
...(current?.itemType === "entity" ? [current.itemId] : []),
]),
export const buildRelatedIdSets = (related?: RelatedResult): RelatedIdSets => ({
areas: new Set(related?.area || []),
devices: new Set(related?.device || []),
entities: new Set(related?.entity || []),
});
/**
+8 -57
View File
@@ -1,5 +1,4 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume } from "@lit/context";
import { mdiPlus, mdiShape } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -7,14 +6,10 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { RelatedIdSets } from "../../common/search/related-context";
import { relatedContext } from "../../data/context";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
entityComboBoxKeys,
getEntities,
markEntitiesRelated,
sortEntitiesByRelatedRank,
type EntityComboBoxItem,
} from "../../data/entity/entity_picker";
import { domainToName } from "../../data/integration";
@@ -136,20 +131,6 @@ export class HaEntityPicker extends LitElement {
@state() private _pendingEntityId?: string;
@state()
@consume({ context: relatedContext, subscribe: true })
private _relatedIdSets?: RelatedIdSets;
private get _hasRelatedContext(): boolean {
const related = this._relatedIdSets;
return (
!!related &&
(related.entities.size > 0 ||
related.devices.size > 0 ||
related.areas.size > 0)
);
}
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
this._pendingEntityId &&
@@ -343,22 +324,8 @@ export class HaEntityPicker extends LitElement {
})
);
private _sortByRelatedContext = memoizeOne(
(
items: EntityComboBoxItem[],
related: RelatedIdSets,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
language: string
): EntityComboBoxItem[] =>
sortEntitiesByRelatedRank(
markEntitiesRelated(items, related, entities, devices),
language
)
);
private _getItems = () => {
const entityItems = this._getEntitiesMemoized(
const items = this._getEntitiesMemoized(
this.hass,
this.includeDomains,
this.excludeDomains,
@@ -369,23 +336,14 @@ export class HaEntityPicker extends LitElement {
this.excludeEntities,
this.value
);
const sortedItems = this._hasRelatedContext
? this._sortByRelatedContext(
entityItems,
this._relatedIdSets!,
this.hass.entities,
this.hass.devices,
this.hass.locale.language
)
: entityItems;
if (this.extraOptions?.length) {
const resolvedExtras = this.extraOptions.map((opt) => ({
...opt,
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
}));
return [...resolvedExtras, ...sortedItems];
return [...resolvedExtras, ...items];
}
return sortedItems;
return items;
};
private _shouldHideClearIcon() {
@@ -417,7 +375,6 @@ export class HaEntityPicker extends LitElement {
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys}
.noSort=${this._hasRelatedContext}
use-top-label
.addButtonLabel=${this.addButton
? (this.addButtonLabel ??
@@ -436,23 +393,17 @@ export class HaEntityPicker extends LitElement {
search,
filteredItems
) => {
// Float related items to the top by closeness, keeping search relevance
// order within each tier.
const items = this._hasRelatedContext
? sortEntitiesByRelatedRank(filteredItems)
: filteredItems;
// If there is exact match for entity id, put it first
const index = items.findIndex(
const index = filteredItems.findIndex(
(item) => item.stateObj?.entity_id === search
);
if (index === -1) {
return items;
return filteredItems;
}
const [exactMatch] = items.splice(index, 1);
items.unshift(exactMatch);
return items;
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
};
public async open() {
-3
View File
@@ -5,7 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
@@ -14,8 +13,6 @@ import "./ha-list";
@customElement("ha-filter-states")
export class HaFilterStates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@@ -4,7 +4,6 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { ColorTempSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-labeled-slider";
import { generateColorTemperatureGradient } from "../../dialogs/more-info/components/lights/light-color-temp-picker";
import {
@@ -15,8 +14,6 @@ import {
@customElement("ha-selector-color_temp")
export class HaColorTempSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ColorTempSelector;
@property() public value?: string;
@@ -84,7 +84,6 @@ export class HaLocationSelector extends LitElement {
<p>${this.label ? this.label : ""}</p>
<ha-locations-editor
class="flex"
.hass=${this.hass}
.helper=${this.helper}
.locations=${this._location(this.selector, this.value)}
@location-updated=${this._locationChanged}
@@ -3,15 +3,13 @@ import { customElement, property, query } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { StringSelector } from "../../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent } from "../../types";
import "../ha-textarea";
import "../input/ha-input";
import "../input/ha-input-multi";
@customElement("ha-selector-text")
export class HaTextSelector extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: any;
@property() public name?: string;
+1 -3
View File
@@ -13,7 +13,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { HomeAssistant, ThemeMode } from "../../types";
import type { ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap } from "./ha-map";
@@ -45,8 +45,6 @@ export interface MarkerLocation {
@customElement("ha-locations-editor")
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public locations?: MarkerLocation[];
@property() public helper?: string;
+2 -2
View File
@@ -7,8 +7,8 @@ import { customElement, property } from "lit/decorators";
export class HaProgressRing extends ProgressRing {
@property() public size?: "tiny" | "small" | "medium" | "large";
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
public updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("size")) {
switch (this.size) {
-4
View File
@@ -44,7 +44,6 @@ import type {
IfActionTraceStep,
TraceExtended,
} from "../../data/trace";
import type { HomeAssistant } from "../../types";
import "../ha-icon-button";
import "../ha-service-icon";
import "./hat-graph-branch";
@@ -76,8 +75,6 @@ export class HatScriptGraph extends LitElement {
@query("hat-graph-node[active], hat-graph-branch[active]")
private _activeNode?: HTMLElement;
public hass!: HomeAssistant;
public renderedNodes: Record<string, NodeInfo> = {};
public trackedNodes: Record<string, NodeInfo> = {};
@@ -457,7 +454,6 @@ export class HatScriptGraph extends LitElement {
${node.action
? html`<ha-service-icon
slot="icon"
.hass=${this.hass}
.service=${node.action}
></ha-service-icon>`
: nothing}
+1 -30
View File
@@ -1,6 +1,6 @@
import { createContext } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import type {
HomeAssistant,
HomeAssistantApi,
@@ -196,33 +196,4 @@ declare global {
}
}
/**
* Set the related context to an entity (or clear it when no entity), so nearby
* pickers float relevant entities.
* @param node - The node to fire the event on.
* @param context - The context to set, or undefined to clear.
*/
export const fireRelatedContext = (
node: HTMLElement,
context: RelatedContextItem | undefined
): void => {
fireEvent(node, "hass-related-context", context);
};
/**
* Set the related context to an entity (or clear it when no entity), so nearby
* pickers float relevant entities. Fired by editors.
* @param node - The node to fire the event on.
* @param entityId - The entity to set, or undefined to clear.
*/
export const fireEntityRelatedContext = (
node: HTMLElement,
entityId: string | undefined
): void => {
fireRelatedContext(
node,
entityId ? { itemType: "entity", itemId: entityId } : undefined
);
};
// #endregion related-context
-73
View File
@@ -1,10 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { RelatedIdSets } from "../../common/search/related-context";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
@@ -15,7 +12,6 @@ import type { HaEntityPickerEntityFilterFunc } from "./entity";
export interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
relatedRank?: number;
}
export const entityComboBoxKeys: FuseWeightedKey[] = [
@@ -190,72 +186,3 @@ export const getEntities = (
return items;
};
const RELATED_RANK_UNRELATED = 3;
const entityRelatedRank = (
entityId: string | undefined,
related: RelatedIdSets,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): number => {
if (!entityId) {
return RELATED_RANK_UNRELATED;
}
if (related.entities.has(entityId)) {
return 0;
}
const deviceId = entities[entityId]?.device_id;
if (deviceId && related.devices.has(deviceId)) {
return 1;
}
const areaId = getEntityAreaId(entityId, entities, devices);
if (areaId && related.areas.has(areaId)) {
return 2;
}
return RELATED_RANK_UNRELATED;
};
/**
* Annotate entity items with their closeness to the related context, so they
* can be floated to the top. The entity itself ranks closest, then its device,
* then its area; anything unrelated keeps the lowest rank.
*/
export const markEntitiesRelated = (
items: EntityComboBoxItem[],
related: RelatedIdSets,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): EntityComboBoxItem[] =>
items.map((item) => ({
...item,
relatedRank: entityRelatedRank(
item.stateObj?.entity_id,
related,
entities,
devices
),
}));
/**
* Sort entity items by related closeness (entity, then device, then area, then
* the rest). Pass `language` to break ties within a tier alphabetically by
* label; omit it to keep the incoming order (e.g. search relevance).
*/
export const sortEntitiesByRelatedRank = (
items: EntityComboBoxItem[],
language?: string
): EntityComboBoxItem[] =>
[...items].sort((a, b) => {
const rankDiff =
(a.relatedRank ?? RELATED_RANK_UNRELATED) -
(b.relatedRank ?? RELATED_RANK_UNRELATED);
if (rankDiff !== 0 || language === undefined) {
return rankDiff;
}
return caseInsensitiveStringCompare(
a.sorting_label ?? "",
b.sorting_label ?? "",
language
);
});
-1
View File
@@ -176,7 +176,6 @@ class OnboardingLocation extends LitElement {
</div>
<ha-locations-editor
class="flex"
.hass=${this.hass}
.locations=${this._markerLocations(
this._location,
this._places,
@@ -228,7 +228,6 @@ export class HaAutomationTrace extends LitElement {
<div class="main">
<div class="graph">
<hat-script-graph
.hass=${this.hass}
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
@@ -508,7 +508,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
</div>
<ha-filter-states
.hass=${this.hass}
.label=${this.hass.localize("ui.panel.config.backup.backup_type")}
.value=${this._filters[TYPE_FILTER]}
.states=${this._states(this.hass.localize, isHassio)}
@@ -517,7 +516,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
></ha-filter-states>
<ha-filter-states
.hass=${this.hass}
.label=${this.hass.localize("ui.panel.config.backup.locations")}
.value=${this._filters[LOCATIONS_FILTER]}
.states=${this._locations(
@@ -2,7 +2,6 @@ import { mdiDotsVertical } from "@mdi/js";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import { navigate } from "../../../common/navigate";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -92,11 +91,7 @@ class PanelDeveloperTools extends LitElement {
panel=${tab.panel}
.active=${page === tab.panel}
>
<a
href="/config/developer-tools/${tab.panel}"
@click=${this._handleTabAnchorClick}
>${this.hass.localize(tab.translationKey)}</a
>
${this.hass.localize(tab.translationKey)}
</ha-tab-group-tab>
`
)}
@@ -110,14 +105,6 @@ class PanelDeveloperTools extends LitElement {
`;
}
private _handleTabAnchorClick(ev: MouseEvent) {
ev.stopPropagation();
const href = isNavigationClick(ev);
if (href) {
navigate(href);
}
}
private _handlePageSelected(ev: CustomEvent<{ name: string }>) {
const newPage = ev.detail.name;
if (!newPage) {
@@ -155,16 +142,6 @@ class PanelDeveloperTools extends LitElement {
--ha-tab-indicator-color: var(--app-header-text-color, white);
--ha-tab-track-color: transparent;
}
ha-tab-group-tab::part(base) {
padding: 0;
}
ha-tab-group-tab a {
color: inherit;
text-decoration: none;
display: flex;
align-items: center;
padding: 1em 1.5em;
}
`;
}
@@ -859,7 +859,6 @@ export class HaConfigDeviceDashboard extends LitElement {
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
<ha-filter-states
.hass=${this.hass}
.value=${this._filters["ha-filter-states"]?.value}
.states=${this._states(this.hass.localize)}
.label=${this.hass.localize("ui.panel.config.devices.picker.state")}
@@ -1019,7 +1019,6 @@ export class HaConfigEntities extends LitElement {
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
<ha-filter-states
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.entities.picker.headers.status"
)}
@@ -210,7 +210,6 @@ export class HaScriptTrace extends LitElement {
<div class="main">
<div class="graph">
<hat-script-graph
.hass=${this.hass}
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
-1
View File
@@ -255,7 +255,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
? html`
<div class="flex">
<ha-locations-editor
.hass=${this.hass}
.locations=${this._getZones(
this._storageItems,
this._stateItems
+3 -31
View File
@@ -49,33 +49,6 @@ export const computeShowHeaderToggle = <
return !!config.show_header_toggle;
};
export const migrateEntitiesCardConfig = (
config: EntitiesCardConfig
): EntitiesCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: (rest as EntityConfig).time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (LovelaceRowConfig | string)[],
};
};
@customElement("hui-entities-card")
class HuiEntitiesCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
@@ -180,12 +153,11 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
throw new Error("Entities must be specified");
}
const migratedConfig = migrateEntitiesCardConfig(config);
const entities = processConfigEntities(migratedConfig.entities);
const entities = processConfigEntities(config.entities);
this._config = migratedConfig;
this._config = config;
this._configEntities = entities;
this._showHeaderToggle = computeShowHeaderToggle(migratedConfig, entities);
this._showHeaderToggle = computeShowHeaderToggle(config, entities);
if (this._config.header) {
this._headerElement = createHeaderFooterElement(
this._config.header
+8 -40
View File
@@ -32,34 +32,6 @@ import { createEntityNotFoundWarning } from "../components/hui-warning";
import "../components/hui-warning-element";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import type { GlanceCardConfig, GlanceConfigEntity } from "./types";
import { TIMESTAMP_STATE_DOMAINS } from "../../../common/const";
export const migrateGlanceCardConfig = (
config: GlanceCardConfig
): GlanceCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: rest.time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (GlanceConfigEntity | string)[],
};
};
@customElement("hui-glance-card")
export class HuiGlanceCard extends LitElement implements LovelaceCard {
@@ -106,15 +78,14 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
}
public setConfig(config: GlanceCardConfig): void {
const migratedConfig = migrateGlanceCardConfig(config);
this._config = {
show_name: true,
show_state: true,
show_icon: true,
state_color: true,
...migratedConfig,
...config,
};
const entities = processConfigEntities(migratedConfig.entities).map(
const entities = processConfigEntities(config.entities).map(
(entityConf) => ({
hold_action: { action: "more-info" } as MoreInfoActionConfig,
...entityConf,
@@ -136,8 +107,7 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
}
}
const columns =
migratedConfig.columns || Math.min(migratedConfig.entities.length, 5);
const columns = config.columns || Math.min(config.entities.length, 5);
this.style.setProperty("--glance-column-width", `${100 / columns}%`);
this._configEntities = entities;
@@ -286,7 +256,6 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
}
const name = this.hass!.formatEntityName(stateObj, entityConf.name);
const domain = computeDomain(entityConf.entity);
return html`
<div
@@ -320,18 +289,17 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
${this._config!.show_state && entityConf.show_state !== false
? html`
<div>
${(TIMESTAMP_STATE_DOMAINS.has(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
stateObj.attributes.device_class
))) &&
${computeDomain(entityConf.entity) === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
stateObj.attributes.device_class
) &&
stateObj.state !== UNAVAILABLE &&
stateObj.state !== UNKNOWN
? html`
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
.format=${entityConf.time_format ??
.format=${entityConf.format ??
(stateObj.attributes.device_class ===
SENSOR_DEVICE_CLASS_UPTIME
? "total"
+1 -1
View File
@@ -334,7 +334,7 @@ export interface GlanceConfigEntity extends ConfigEntity {
image?: string;
show_state?: boolean;
state_color?: boolean;
time_format?: TimestampRenderingFormat;
format?: TimestampRenderingFormat;
}
export interface GlanceCardConfig extends LovelaceCardConfig {
@@ -1,13 +0,0 @@
import type { RelatedContextItem } from "../../../data/context";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { getConfigEntityId } from "./get-config-entity-id";
export const getConfigRelatedContext = (
config: LovelaceCardConfig
): RelatedContextItem | undefined => {
if (config.type === "area" && typeof config.area === "string") {
return { itemType: "area", itemId: config.area };
}
const entityId = getConfigEntityId(config);
return entityId ? { itemType: "entity", itemId: entityId } : undefined;
};
@@ -60,7 +60,7 @@ const LAZY_LOAD_TYPES = {
attribute: () => import("../special-rows/hui-attribute-row"),
text: () => import("../special-rows/hui-text-row"),
};
export const DOMAIN_TO_ELEMENT_TYPE = {
const DOMAIN_TO_ELEMENT_TYPE = {
_domain_not_found: "simple",
alert: "toggle",
automation: "toggle",
@@ -5,7 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { fireEntityRelatedContext } from "../../../../data/context";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import { stripDefaults } from "../../../../common/util/strip-defaults";
import { withViewTransition } from "../../../../common/util/view-transition";
@@ -32,7 +31,6 @@ import {
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge";
import { getConfigEntityId } from "../../common/get-config-entity-id";
import "../../sections/hui-section";
import { addBadge, replaceBadge } from "../config-util";
import { getBadgeDefaultConfig } from "../get-badge-default-config";
@@ -141,38 +139,25 @@ export class HuiDialogEditBadge
this._badgeConfig = undefined;
this._error = undefined;
this._documentationURL = undefined;
this._updateRelatedContext(undefined);
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected updated(changedProps: PropertyValues): void {
if (!changedProps.has("_badgeConfig")) {
if (
!this._badgeConfig ||
this._documentationURL !== undefined ||
!changedProps.has("_badgeConfig")
) {
return;
}
if (this._badgeConfig && this._documentationURL === undefined) {
const oldConfig = changedProps.get("_badgeConfig") as LovelaceBadgeConfig;
const oldConfig = changedProps.get("_badgeConfig") as LovelaceBadgeConfig;
if (oldConfig?.type !== this._badgeConfig.type) {
this._documentationURL = this._badgeConfig.type
? getBadgeDocumentationURL(this.hass, this._badgeConfig.type)
: undefined;
}
if (oldConfig?.type !== this._badgeConfig!.type) {
this._documentationURL = this._badgeConfig!.type
? getBadgeDocumentationURL(this.hass, this._badgeConfig!.type)
: undefined;
}
this._updateRelatedContext(
this._badgeConfig ? getConfigEntityId(this._badgeConfig) : undefined
);
}
private _relatedEntityId?: string;
private _updateRelatedContext(entityId: string | undefined): void {
if (entityId === this._relatedEntityId) {
return;
}
this._relatedEntityId = entityId;
fireEntityRelatedContext(this, entityId);
}
protected render() {
@@ -6,10 +6,6 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
fireRelatedContext,
type RelatedContextItem,
} from "../../../../data/context";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import { stripDefaults } from "../../../../common/util/strip-defaults";
import { withViewTransition } from "../../../../common/util/view-transition";
@@ -37,7 +33,6 @@ import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import { getConfigRelatedContext } from "../../common/get-config-related-context";
import "../../sections/hui-section";
import { getCardDefaultConfig } from "../get-card-default-config";
import { getCardDocumentationURL } from "../get-dashboard-documentation-url";
@@ -130,7 +125,6 @@ export class HuiDialogEditCard
this._cardConfig = undefined;
this._error = undefined;
this._documentationURL = undefined;
this._updateRelatedContext(undefined);
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -147,21 +141,6 @@ export class HuiDialogEditCard
this._cardConfig!.type
);
}
this._updateRelatedContext(getConfigRelatedContext(this._cardConfig));
}
private _relatedContext?: RelatedContextItem;
private _updateRelatedContext(context: RelatedContextItem | undefined): void {
if (
context?.itemType === this._relatedContext?.itemType &&
context?.itemId === this._relatedContext?.itemId
) {
return;
}
this._relatedContext = context;
fireRelatedContext(this, context);
}
protected render() {
@@ -29,10 +29,7 @@ import "../../../../components/ha-theme-picker";
import "../../../../components/input/ha-input";
import { isCustomType } from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types";
import {
computeShowHeaderToggle,
migrateEntitiesCardConfig,
} from "../../cards/hui-entities-card";
import { computeShowHeaderToggle } from "../../cards/hui-entities-card";
import type { EntitiesCardConfig } from "../../cards/types";
import { processConfigEntities } from "../../common/process-config-entities";
import { timeFormatConfigStruct } from "../../components/types";
@@ -122,7 +119,7 @@ const attributeEntitiesRowConfigStruct = object({
suffix: optional(string()),
name: optional(string()),
icon: optional(string()),
time_format: optional(timeFormatConfigStruct),
format: optional(timeFormatConfigStruct),
});
const textEntitiesRowConfigStruct = object({
@@ -210,10 +207,9 @@ export class HuiEntitiesCardEditor
@state() private _subElementEditorConfig?: SubElementEditorConfig;
public setConfig(config: EntitiesCardConfig): void {
const migratedConfig = migrateEntitiesCardConfig(config);
assert(migratedConfig, cardConfigStruct);
this._config = migratedConfig;
this._configEntities = processEditorEntities(migratedConfig.entities);
assert(config, cardConfigStruct);
this._config = config;
this._configEntities = processEditorEntities(config.entities);
}
private _showHeaderToggle = memoizeOne((config: EntitiesCardConfig) => {
@@ -15,8 +15,6 @@ import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../../../data/sensor";
import type { EntitiesCardEntityConfig } from "../../cards/types";
import type { LovelaceRowEditor } from "../../types";
import { entitiesConfigStruct } from "../structs/entities-struct";
import { DOMAIN_TO_ELEMENT_TYPE } from "../../create-element/create-row-element";
import { TIMESTAMP_STATE_DOMAINS } from "../../../../common/const";
const SECONDARY_INFO_VALUES = {
none: {},
@@ -90,7 +88,7 @@ export class HuiGenericEntityRowEditor
...(showTimeFormat
? ([
{
name: "time_format",
name: "format",
selector: {
ui_time_format: {},
},
@@ -107,17 +105,13 @@ export class HuiGenericEntityRowEditor
}
const entity = this._config.entity;
const domain = entity ? computeDomain(entity) : "";
const simpleEntity =
(DOMAIN_TO_ELEMENT_TYPE[domain] ||
DOMAIN_TO_ELEMENT_TYPE["_domain_not_found"]) === "simple";
const showTimeFormat = simpleEntity
? TIMESTAMP_STATE_DOMAINS.has(domain)
: domain === "event" ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
this.hass.states[entity]?.attributes.device_class
));
const domain = entity ? computeDomain(entity) : undefined;
const showTimeFormat =
domain === "event" ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
this.hass.states[entity]?.attributes.device_class
));
const schema =
this.schema ||
@@ -146,6 +140,10 @@ export class HuiGenericEntityRowEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.entity-row.${schema.name}`
);
case "format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.time_format`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
@@ -11,17 +11,11 @@ import {
string,
union,
} from "superstruct";
import memoizeOne from "memoize-one";
import { computeDomain } from "../../../../common/entity/compute_domain";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
SchemaUnion,
HaFormSchema,
} from "../../../../components/ha-form/types";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import { migrateGlanceCardConfig } from "../../cards/hui-glance-card";
import type { ConfigEntity, GlanceCardConfig } from "../../cards/types";
import "../../components/hui-entity-editor";
import type { EntityConfig } from "../../entity-rows/types";
@@ -32,8 +26,6 @@ import { processEditorEntities } from "../process-editor-entities";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { entitiesConfigStruct } from "../structs/entities-struct";
import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
import { TIMESTAMP_STATE_DOMAINS } from "../../../../common/const";
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../../../data/sensor";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
@@ -49,6 +41,59 @@ const cardConfigStruct = assign(
})
);
const SUB_FORM = {
schema: [
{ name: "entity", selector: { entity: {} }, required: true },
{
name: "name",
selector: { entity_name: {} },
context: {
entity: "entity",
},
},
{
type: "grid",
name: "",
schema: [
{
name: "icon",
selector: {
icon: {},
},
context: {
icon_entity: "entity",
},
},
{ name: "show_last_changed", selector: { boolean: {} } },
{ name: "show_state", selector: { boolean: {} }, default: true },
],
},
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (["hold_action", "double_tap_action"] as const).map((action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})),
},
] as const,
};
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{
@@ -86,97 +131,22 @@ export class HuiGlanceCardEditor
@state() private _configEntities?: ConfigEntity[];
public setConfig(config: GlanceCardConfig): void {
const migratedConfig = migrateGlanceCardConfig(config);
assert(migratedConfig, cardConfigStruct);
this._config = migratedConfig;
this._configEntities = processEditorEntities(migratedConfig.entities);
assert(config, cardConfigStruct);
this._config = config;
this._configEntities = processEditorEntities(config.entities);
}
private _subForm = memoizeOne((showTimeFormat?: boolean) => ({
schema: [
{ name: "entity", selector: { entity: {} }, required: true },
{
name: "name",
selector: { entity_name: {} },
context: {
entity: "entity",
},
},
{
type: "grid",
name: "",
schema: [
{
name: "icon",
selector: {
icon: {},
},
context: {
icon_entity: "entity",
},
},
{ name: "show_last_changed", selector: { boolean: {} } },
{ name: "show_state", selector: { boolean: {} }, default: true },
],
},
...(showTimeFormat
? ([
{
name: "time_format",
selector: {
ui_time_format: {},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (["hold_action", "double_tap_action"] as const).map(
(action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})
),
},
] as const,
}));
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
if (this._subElementEditorConfig) {
const entity =
this._configEntities![this._subElementEditorConfig.index!]?.entity;
const domain = entity ? computeDomain(entity) : "";
const showTimeFormat =
TIMESTAMP_STATE_DOMAINS.has(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
this.hass.states[entity]?.attributes.device_class
));
return html`
<hui-sub-element-editor
.hass=${this.hass}
.config=${this._subElementEditorConfig}
.form=${this._subForm(showTimeFormat)}
.form=${SUB_FORM}
@go-back=${this._goBack}
@config-changed=${this._handleSubEntityChanged}
>
@@ -13,7 +13,7 @@ export const entitiesConfigStruct = union([
icon: optional(string()),
image: optional(string()),
secondary_info: optional(string()),
time_format: optional(timeFormatConfigStruct),
format: optional(timeFormatConfigStruct),
state_color: optional(boolean()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
@@ -73,7 +73,7 @@ class HuiEventEntityRow extends LitElement implements LovelaceRow {
: html`<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
.format=${this._config.time_format}
.format=${this._config.format}
capitalize
></hui-timestamp-display>`}
</div>
@@ -12,9 +12,12 @@ import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-generic-entity-row";
import "../components/hui-timestamp-display";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type { TimestampRenderingFormat } from "../components/types";
import type { LovelaceRow } from "./types";
interface SensorEntityConfig extends EntitiesCardEntityConfig {}
interface SensorEntityConfig extends EntitiesCardEntityConfig {
format?: TimestampRenderingFormat;
}
@customElement("hui-sensor-entity-row")
class HuiSensorEntityRow extends LitElement implements LovelaceRow {
@@ -59,7 +62,7 @@ class HuiSensorEntityRow extends LitElement implements LovelaceRow {
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
.format=${this._config.time_format ??
.format=${this._config.format ??
(stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_UPTIME
? "total"
: undefined)}
@@ -5,12 +5,8 @@ import type { HomeAssistant } from "../../../types";
import type { EntitiesCardEntityConfig } from "../cards/types";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-generic-entity-row";
import "../components/hui-timestamp-display";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type { LovelaceRow } from "./types";
import { TIMESTAMP_STATE_DOMAINS } from "../../../common/const";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import { computeDomain } from "../../../common/entity/compute_domain";
@customElement("hui-simple-entity-row")
class HuiSimpleEntityRow extends LitElement implements LovelaceRow {
@@ -46,18 +42,7 @@ class HuiSimpleEntityRow extends LitElement implements LovelaceRow {
return html`
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
${TIMESTAMP_STATE_DOMAINS.has(computeDomain(this._config.entity)) &&
stateObj.state !== UNAVAILABLE &&
stateObj.state !== UNKNOWN
? html`
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
.format=${this._config.time_format}
capitalize
></hui-timestamp-display>
`
: this.hass.formatEntityState(stateObj)}
${this.hass.formatEntityState(stateObj)}
</hui-generic-entity-row>
`;
}
+1 -1
View File
@@ -14,7 +14,6 @@ export interface EntityConfig {
name?: string | EntityNameItem | EntityNameItem[];
icon?: string;
image?: string;
time_format?: TimestampRenderingFormat;
}
export interface ConfirmableRowConfig extends EntityConfig {
@@ -108,4 +107,5 @@ export interface AttributeRowConfig extends EntityConfig {
attribute: string;
prefix?: string;
suffix?: string;
format?: TimestampRenderingFormat;
}
@@ -50,18 +50,18 @@ class HuiAttributeRow extends LitElement implements LovelaceRow {
const attribute = stateObj.attributes[this._config.attribute];
let date: Date | undefined;
if (this._config.time_format) {
if (this._config.format) {
date = new Date(attribute);
}
return html`
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
${this._config.prefix}
${this._config.time_format && checkValidDate(date)
${this._config.format && checkValidDate(date)
? html` <hui-timestamp-display
.hass=${this.hass}
.ts=${date}
.format=${this._config.time_format}
.format=${this._config.format}
capitalize
></hui-timestamp-display>`
: attribute !== undefined
+4 -7
View File
@@ -78,12 +78,9 @@ export class RelatedContextProvider {
private _onRelatedContext = (
ev: HASSDomEvent<RelatedContextItem | undefined>
): void => {
// `fireEvent` coerces an undefined detail to `{}`, so a clear arrives
// without an itemId; normalise that back to undefined.
const context = ev.detail?.itemId ? ev.detail : undefined;
this._relatedContext = context;
this._contextPathname = context ? mainWindow.location.pathname : undefined;
this._resolveRelatedContext(context);
this._relatedContext = ev.detail;
this._contextPathname = mainWindow.location.pathname;
this._resolveRelatedContext(this._relatedContext);
};
/**
@@ -121,7 +118,7 @@ export class RelatedContextProvider {
context.itemId
);
if (this._contextMatches(context)) {
this._setValue(buildRelatedIdSets(related, context));
this._setValue(buildRelatedIdSets(related));
}
} catch (_err) {
if (this._contextMatches(context)) {
-136
View File
@@ -1,136 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildRelatedIdSets,
sortRelatedFirst,
} from "../../../src/common/search/related-context";
import type { RelatedIdSets } from "../../../src/common/search/related-context";
import type { PickerComboBoxItem } from "../../../src/components/ha-picker-combo-box";
import type { RelatedResult } from "../../../src/data/search";
const toArrays = (value: RelatedIdSets) => ({
entities: [...value.entities].sort(),
devices: [...value.devices].sort(),
areas: [...value.areas].sort(),
});
const item = (id: string, isRelated?: boolean): PickerComboBoxItem => ({
id,
primary: id,
...(isRelated === undefined ? {} : { isRelated }),
});
const orderOf = (items: PickerComboBoxItem[]) => items.map((i) => i.id);
describe("buildRelatedIdSets", () => {
it("builds empty sets with no arguments", () => {
expect(toArrays(buildRelatedIdSets())).toEqual({
entities: [],
devices: [],
areas: [],
});
});
it("builds sets from a related result", () => {
const related: RelatedResult = {
entity: ["light.kitchen"],
device: ["dev1"],
area: ["area1"],
};
expect(toArrays(buildRelatedIdSets(related))).toEqual({
entities: ["light.kitchen"],
devices: ["dev1"],
areas: ["area1"],
});
});
it("merges a current entity into the entities group", () => {
const related: RelatedResult = { device: ["dev1"], area: ["area1"] };
const result = buildRelatedIdSets(related, {
itemType: "entity",
itemId: "light.ac",
});
expect(toArrays(result)).toEqual({
entities: ["light.ac"],
devices: ["dev1"],
areas: ["area1"],
});
});
it("merges a current device into the devices group", () => {
const result = buildRelatedIdSets(undefined, {
itemType: "device",
itemId: "dev1",
});
expect([...result.devices]).toEqual(["dev1"]);
});
it("merges a current area into the areas group", () => {
const result = buildRelatedIdSets(undefined, {
itemType: "area",
itemId: "area1",
});
expect([...result.areas]).toEqual(["area1"]);
});
it("ignores a current item whose type is not tracked", () => {
const result = buildRelatedIdSets(undefined, {
itemType: "automation",
itemId: "auto1",
});
expect(toArrays(result)).toEqual({ entities: [], devices: [], areas: [] });
});
it("deduplicates a current item already in the related result", () => {
const related: RelatedResult = { entity: ["light.ac"] };
const result = buildRelatedIdSets(related, {
itemType: "entity",
itemId: "light.ac",
});
expect([...result.entities]).toEqual(["light.ac"]);
});
});
describe("sortRelatedFirst", () => {
it("floats related items above unrelated ones", () => {
const items = [
item("a", false),
item("b", true),
item("c", false),
item("d", true),
];
expect(orderOf(sortRelatedFirst(items))).toEqual(["b", "d", "a", "c"]);
});
it("preserves relative order within each group (stable)", () => {
const items = [
item("r1", true),
item("u1", false),
item("r2", true),
item("u2", false),
item("r3", true),
];
expect(orderOf(sortRelatedFirst(items))).toEqual([
"r1",
"r2",
"r3",
"u1",
"u2",
]);
});
it("treats a missing isRelated flag as unrelated", () => {
const items = [item("plain"), item("related", true)];
expect(orderOf(sortRelatedFirst(items))).toEqual(["related", "plain"]);
});
it("keeps order when nothing is related", () => {
const items = [item("a"), item("b"), item("c")];
expect(orderOf(sortRelatedFirst(items))).toEqual(["a", "b", "c"]);
});
it("does not mutate the input array", () => {
const items = [item("a", false), item("b", true)];
sortRelatedFirst(items);
expect(orderOf(items)).toEqual(["a", "b"]);
});
});
+1 -132
View File
@@ -1,11 +1,5 @@
import { describe, expect, it } from "vitest";
import type { RelatedIdSets } from "../../../src/common/search/related-context";
import {
getEntities,
markEntitiesRelated,
sortEntitiesByRelatedRank,
type EntityComboBoxItem,
} from "../../../src/data/entity/entity_picker";
import { getEntities } from "../../../src/data/entity/entity_picker";
import type { HomeAssistant } from "../../../src/types";
const makeHass = (entityIds: string[]): HomeAssistant => {
@@ -104,128 +98,3 @@ describe("getEntities", () => {
expect(items[0].id).toBe("entity-sensor.temp");
});
});
const item = (entityId: string): EntityComboBoxItem => ({
id: entityId,
primary: entityId,
sorting_label: entityId,
stateObj: { entity_id: entityId } as any,
});
const emptyRelated = (): RelatedIdSets => ({
areas: new Set(),
devices: new Set(),
entities: new Set(),
});
// entity.in_area sits on device "dev" in area "area";
// entity.in_device sits on device "dev"; entity.lonely has no device or area.
const relatedHass = {
entities: {
"light.in_area": { entity_id: "light.in_area", device_id: "dev" },
"light.in_device": { entity_id: "light.in_device", device_id: "dev2" },
"light.lonely": { entity_id: "light.lonely" },
},
devices: {
dev: { id: "dev", area_id: "area" },
dev2: { id: "dev2" },
},
} as unknown as HomeAssistant;
describe("markEntitiesRelated", () => {
it("ranks the entity itself closest", () => {
const related = emptyRelated();
related.entities.add("light.lonely");
const [marked] = markEntitiesRelated(
[item("light.lonely")],
related,
relatedHass.entities,
relatedHass.devices
);
expect(marked.relatedRank).toBe(0);
});
it("ranks an entity by its device when not directly related", () => {
const related = emptyRelated();
related.devices.add("dev2");
const [marked] = markEntitiesRelated(
[item("light.in_device")],
related,
relatedHass.entities,
relatedHass.devices
);
expect(marked.relatedRank).toBe(1);
});
it("ranks an entity by its (device-inherited) area", () => {
const related = emptyRelated();
related.areas.add("area");
const [marked] = markEntitiesRelated(
[item("light.in_area")],
related,
relatedHass.entities,
relatedHass.devices
);
expect(marked.relatedRank).toBe(2);
});
it("marks unrelated entities with the lowest rank", () => {
const related = emptyRelated();
related.entities.add("light.other");
const [marked] = markEntitiesRelated(
[item("light.lonely")],
related,
relatedHass.entities,
relatedHass.devices
);
expect(marked.relatedRank).toBe(3);
});
});
describe("sortEntitiesByRelatedRank", () => {
it("sorts by closeness: entity, then device, then area, then the rest", () => {
const related = emptyRelated();
related.entities.add("light.in_device"); // direct entity match wins
related.devices.add("dev"); // covers light.in_area via its device
related.areas.add("area");
const marked = markEntitiesRelated(
[item("light.lonely"), item("light.in_area"), item("light.in_device")],
related,
relatedHass.entities,
relatedHass.devices
);
const sorted = sortEntitiesByRelatedRank(marked, "en");
expect(sorted.map((i) => i.id)).toEqual([
"light.in_device", // rank 0 (entity)
"light.in_area", // rank 1 (device)
"light.lonely", // rank 3 (unrelated)
]);
});
it("breaks ties alphabetically by label when a language is given", () => {
const sorted = sortEntitiesByRelatedRank(
[item("light.zebra"), item("light.apple")],
"en"
);
expect(sorted.map((i) => i.id)).toEqual(["light.apple", "light.zebra"]);
});
it("keeps incoming order within a tier when no language is given", () => {
const sorted = sortEntitiesByRelatedRank([
item("light.zebra"),
item("light.apple"),
]);
expect(sorted.map((i) => i.id)).toEqual(["light.zebra", "light.apple"]);
});
it("falls back to plain alphabetical when nothing is related", () => {
const marked = markEntitiesRelated(
[item("light.zebra"), item("light.apple")],
emptyRelated(),
relatedHass.entities,
relatedHass.devices
);
const sorted = sortEntitiesByRelatedRank(marked, "en");
expect(sorted.map((i) => i.id)).toEqual(["light.apple", "light.zebra"]);
});
});
@@ -1,38 +0,0 @@
import { describe, expect, it } from "vitest";
import { getConfigRelatedContext } from "../../../../src/panels/lovelace/common/get-config-related-context";
describe("getConfigRelatedContext", () => {
it("returns an area context for area cards", () => {
expect(getConfigRelatedContext({ type: "area", area: "kitchen" })).toEqual({
itemType: "area",
itemId: "kitchen",
});
});
it("returns an entity context for entity cards", () => {
expect(
getConfigRelatedContext({ type: "entity", entity: "light.kitchen" })
).toEqual({
itemType: "entity",
itemId: "light.kitchen",
});
});
it("prefers the area context for area cards with entity-like fields", () => {
expect(
getConfigRelatedContext({
type: "area",
area: "kitchen",
entity: "light.kitchen",
})
).toEqual({
itemType: "area",
itemId: "kitchen",
});
});
it("returns undefined when no related context can be derived", () => {
expect(getConfigRelatedContext({ type: "markdown" })).toBeUndefined();
});
});
-213
View File
@@ -1,213 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RelatedIdSets } from "../../src/common/search/related-context";
import type { RelatedContextItem } from "../../src/data/context";
import { findRelated } from "../../src/data/search";
import type { RelatedResult } from "../../src/data/search";
import type { HassBaseEl } from "../../src/state/hass-base-mixin";
import { RelatedContextProvider } from "../../src/state/related-context-provider";
vi.mock("../../src/data/search", () => ({
findRelated: vi.fn(),
}));
const findRelatedMock = vi.mocked(findRelated);
const tick = () =>
new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const toArrays = (value: RelatedIdSets | undefined) =>
value && {
entities: [...value.entities].sort(),
devices: [...value.devices].sort(),
areas: [...value.areas].sort(),
};
describe("RelatedContextProvider", () => {
let host: HassBaseEl;
let provider: RelatedContextProvider;
// Read the value the provider has published to its context consumers.
const published = () =>
(provider as unknown as { _provider: { value: RelatedIdSets | undefined } })
._provider.value;
const createProvider = (hass: unknown) => {
const el = document.createElement("div");
(el as unknown as { hass: unknown }).hass = hass;
host = el as unknown as HassBaseEl;
provider = new RelatedContextProvider(host);
provider.connect();
};
const dispatch = (detail: unknown) => {
const event = new Event("hass-related-context");
(event as unknown as { detail: unknown }).detail = detail;
host.dispatchEvent(event);
};
const fireItem = (item: RelatedContextItem) => dispatch(item);
const navigate = (path: string) => {
window.history.pushState({}, "", path);
window.dispatchEvent(new Event("popstate"));
};
beforeEach(() => {
findRelatedMock.mockReset();
window.haContext = {};
window.history.pushState({}, "", "/lovelace");
});
afterEach(() => {
provider?.disconnect();
delete window.haContext;
});
it("resolves an item and publishes the related set, merging the item itself", async () => {
findRelatedMock.mockResolvedValue({ device: ["dev1"], area: ["area1"] });
createProvider({});
fireItem({ itemType: "entity", itemId: "light.ac" });
await tick();
expect(findRelatedMock).toHaveBeenCalledWith(
host.hass,
"entity",
"light.ac"
);
expect(toArrays(published())).toEqual({
entities: ["light.ac"],
devices: ["dev1"],
areas: ["area1"],
});
expect(toArrays(window.haContext?.related)).toEqual({
entities: ["light.ac"],
devices: ["dev1"],
areas: ["area1"],
});
});
it("clears when the detail has no itemId (fireEvent coerces undefined to {})", async () => {
findRelatedMock.mockResolvedValue({ device: ["dev1"] });
createProvider({});
fireItem({ itemType: "entity", itemId: "light.ac" });
await tick();
expect(published()).toBeDefined();
findRelatedMock.mockClear();
dispatch({});
await tick();
expect(published()).toBeUndefined();
expect(window.haContext?.related).toBeUndefined();
expect(findRelatedMock).not.toHaveBeenCalled();
});
it("publishes nothing when hass is not available", async () => {
createProvider(undefined);
fireItem({ itemType: "entity", itemId: "light.ac" });
await tick();
expect(findRelatedMock).not.toHaveBeenCalled();
expect(published()).toBeUndefined();
});
it("publishes nothing when the related lookup fails", async () => {
findRelatedMock.mockRejectedValue(new Error("boom"));
createProvider({});
fireItem({ itemType: "entity", itemId: "light.ac" });
await tick();
expect(published()).toBeUndefined();
});
it("ignores a stale in-flight resolve superseded by a newer item", async () => {
const first = deferred<RelatedResult>();
const second = deferred<RelatedResult>();
findRelatedMock
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
createProvider({});
fireItem({ itemType: "entity", itemId: "light.first" });
fireItem({ itemType: "entity", itemId: "light.second" });
second.resolve({ device: ["dev2"] });
await tick();
expect(toArrays(published())!.entities).toEqual(["light.second"]);
// The older lookup resolves late and must not overwrite the newer value.
first.resolve({ device: ["dev1"] });
await tick();
expect(toArrays(published())!.entities).toEqual(["light.second"]);
expect(toArrays(published())!.devices).toEqual(["dev2"]);
});
it("memoizes repeated identical items", async () => {
findRelatedMock.mockResolvedValue({ device: ["dev1"] });
createProvider({});
fireItem({ itemType: "entity", itemId: "light.ac" });
await tick();
fireItem({ itemType: "entity", itemId: "light.ac" });
await tick();
expect(findRelatedMock).toHaveBeenCalledTimes(1);
});
it("clears the context on a real navigation (pathname change)", async () => {
findRelatedMock.mockResolvedValue({ device: ["dev1"] });
createProvider({});
fireItem({ itemType: "entity", itemId: "light.ac" });
await tick();
expect(published()).toBeDefined();
navigate("/config/devices");
await tick();
expect(published()).toBeUndefined();
});
it("keeps the context on a same-path popstate (dialog history)", async () => {
findRelatedMock.mockResolvedValue({ device: ["dev1"] });
createProvider({});
fireItem({ itemType: "entity", itemId: "light.ac" });
await tick();
const before = toArrays(published());
// popstate without a pathname change (dialog open/close)
window.dispatchEvent(new Event("popstate"));
await tick();
expect(toArrays(published())).toEqual(before);
});
it("stops responding to events after disconnect", async () => {
findRelatedMock.mockResolvedValue({ device: ["dev1"] });
createProvider({});
provider.disconnect();
fireItem({ itemType: "entity", itemId: "light.ac" });
await tick();
expect(findRelatedMock).not.toHaveBeenCalled();
expect(published()).toBeUndefined();
});
});