Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Bottein 9e8d38ea63 Add entity type selector for main and additional entities 2026-06-18 17:02:21 +02:00
52 changed files with 832 additions and 1987 deletions
@@ -502,10 +502,6 @@ const SCHEMAS: {
},
},
},
password: {
label: "Password",
selector: { text: { type: "password" } },
},
},
},
},
+2 -3
View File
@@ -43,7 +43,7 @@
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.15",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-locale": "5.3.9",
@@ -63,7 +63,6 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
@@ -196,7 +195,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.1",
"typescript-eslint": "8.61.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"webpack-stats-plugin": "1.1.3",
@@ -1,29 +0,0 @@
import { Task, type TaskConfig } from "@lit/task";
import type { ReactiveControllerHost } from "lit";
/**
* A `@lit/task` Task with a sticky `resolved` flag: false until the task has
* completed once, then true. Lets callers tell "still loading" apart from
* "resolved with an empty value" without a null sentinel, while keeping the
* previous value during a re-run.
*/
export class AsyncValueTask<T extends readonly unknown[], R> extends Task<
T,
R
> {
private _resolved = false;
constructor(host: ReactiveControllerHost, config: TaskConfig<T, R>) {
super(host, {
...config,
onComplete: (value) => {
this._resolved = true;
config.onComplete?.(value);
},
});
}
public get resolved(): boolean {
return this._resolved;
}
}
+3 -2
View File
@@ -30,7 +30,7 @@ export const computeEntityEntryName = (
fallbackStateObj?: HassEntity
): string | undefined => {
const name =
entry.name ||
entry.name ??
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
@@ -59,7 +59,8 @@ export const computeEntityEntryName = (
return stripPrefixFromEntityName(name, deviceName) || name;
}
return name;
// Empty name = main entity → undefined, so callers fall back to the device name.
return name || undefined;
};
export const entityUseDeviceName = (
-26
View File
@@ -1,26 +0,0 @@
/**
* Return a shallow copy of an object with every key removed whose value is
* `undefined` or equals that key's default, so a key left at its default
* (whether absent or explicit) does not count as a difference. A key's default
* comes from `defaults` when present, otherwise `false`.
*
* Non-plain-object values are returned unchanged; only top-level keys are
* compared.
*/
export const stripDefaults = <T>(
value: T,
defaults?: Record<string, unknown>
): T => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const defaultValue = defaults && key in defaults ? defaults[key] : false;
if (val === undefined || val === defaultValue) {
continue;
}
result[key] = val;
}
return result as T;
};
+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 &&
@@ -352,22 +333,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,
@@ -378,23 +345,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() {
@@ -426,7 +384,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 ??
@@ -445,23 +402,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() {
+16 -46
View File
@@ -1,10 +1,9 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { until } from "lit/directives/until";
import {
configContext,
connectionContext,
@@ -36,47 +35,6 @@ export class HaAttributeIcon extends LitElement {
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([
icon,
config,
connection,
entities,
stateObj,
attribute,
attributeValue,
]) => {
if (
icon ||
!config ||
!connection ||
!entities ||
!stateObj ||
!attribute
) {
return initialState;
}
return attributeIcon(
config.config,
connection.connection,
entities,
stateObj,
attribute,
attributeValue
);
},
args: () =>
[
this.icon,
this._config,
this._connection,
this._entities,
this.stateObj,
this.attribute,
this.attributeValue,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -90,9 +48,21 @@ export class HaAttributeIcon extends LitElement {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: nothing;
const icon = attributeIcon(
this._config.config,
this._connection.connection,
this._entities,
this.stateObj,
this.attribute,
this.attributeValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return nothing;
});
return html`${until(icon)}`;
}
}
+8 -19
View File
@@ -1,18 +1,13 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { until } from "lit/directives/until";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { getValueAttribute } from "../common/entity/get_states";
import { formattersContext } from "../data/context";
const isObjectValue = (value: unknown): boolean =>
(Array.isArray(value) && value.some((val) => val instanceof Object)) ||
(!Array.isArray(value) && value instanceof Object);
@customElement("ha-attribute-value")
class HaAttributeValue extends LitElement {
@state()
@@ -25,17 +20,6 @@ class HaAttributeValue extends LitElement {
@property({ type: Boolean, attribute: "hide-unit" }) public hideUnit = false;
private _yamlTask = new AsyncValueTask(this, {
task: async ([attributeValue]) => {
if (!isObjectValue(attributeValue)) {
return initialState;
}
const { dump } = await import("js-yaml");
return dump(attributeValue);
},
args: () => [this.stateObj?.attributes[this.attribute]] as const,
});
protected render() {
if (!this.stateObj) {
return nothing;
@@ -65,8 +49,13 @@ class HaAttributeValue extends LitElement {
}
}
if (isObjectValue(attributeValue)) {
return html`<pre>${this._yamlTask.value ?? ""}</pre>`;
if (
(Array.isArray(attributeValue) &&
attributeValue.some((val) => val instanceof Object)) ||
(!Array.isArray(attributeValue) && attributeValue instanceof Object)
) {
const yaml = import("js-yaml").then(({ dump }) => dump(attributeValue));
return html`<pre>${until(yaml, "")}</pre>`;
}
// Options-list attributes (effect_list, preset_modes, …) translated through
+13 -19
View File
@@ -12,11 +12,10 @@ import {
mdiWeatherSunny,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassConfig, Connection } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -58,17 +57,6 @@ export class HaConditionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, condition]) => {
if (icon || !connection || !config || !condition) {
return initialState;
}
return conditionIcon(connection, config, condition);
},
args: () =>
[this.icon, this._connection, this._config, this.condition] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -82,12 +70,18 @@ export class HaConditionIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = conditionIcon(
this._connection,
this._config,
this.condition
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
+1 -4
View File
@@ -388,10 +388,7 @@ export class HaControlSlider extends LitElement {
private _isVisuallyInverted() {
let inverted = this.inverted;
// RTL only mirrors the horizontal axis. A vertical slider always fills
// bottom-to-top regardless of text direction, so it must not be flipped,
// otherwise its value mapping ends up upside down in RTL languages.
if (!this.vertical && mainWindow.document.dir === "rtl") {
if (mainWindow.document.dir === "rtl") {
inverted = !inverted;
}
+16 -32
View File
@@ -1,8 +1,7 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { until } from "lit/directives/until";
import { configContext, connectionContext, uiContext } from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
@@ -37,30 +36,6 @@ export class HaDomainIcon extends LitElement {
@consume({ context: uiContext, subscribe: true })
private _hassUi?: ContextType<typeof uiContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, domain, deviceClass, domainState]) => {
if (icon || !connection || !config || !domain) {
return initialState;
}
return domainIcon(
connection.connection,
config.config,
domain,
deviceClass,
domainState
);
},
args: () =>
[
this.icon,
this._connection,
this._hassConfig,
this.domain,
this.deviceClass,
this.state,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -74,12 +49,21 @@ export class HaDomainIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = domainIcon(
this._connection.connection,
this._hassConfig.config,
this.domain,
this.deviceClass,
this.state
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
+13 -37
View File
@@ -1,8 +1,6 @@
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../../common/controllers/async-value-task";
import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { entityIcon } from "../../data/icons";
import type { IconSelector } from "../../data/selector";
@@ -30,45 +28,23 @@ export class HaIconSelector extends LitElement {
icon_entity?: string;
};
private get _stateObj(): HassEntity | undefined {
const iconEntity = this.context?.icon_entity;
return iconEntity ? this.hass.states[iconEntity] : undefined;
}
private _placeholderTask = new AsyncValueTask(this, {
task: ([
placeholder,
attributeIcon,
entities,
config,
connection,
stateObj,
]) => {
if (placeholder || attributeIcon || !stateObj) {
return initialState;
}
return entityIcon(entities, config, connection, stateObj);
},
args: () => {
const stateObj = this._stateObj;
return [
this.selector.icon?.placeholder,
stateObj?.attributes.icon,
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj,
] as const;
},
});
protected render() {
const stateObj = this._stateObj;
const iconEntity = this.context?.icon_entity;
const stateObj = iconEntity ? this.hass.states[iconEntity] : undefined;
const placeholder =
this.selector.icon?.placeholder ||
stateObj?.attributes.icon ||
(stateObj && this._placeholderTask.value);
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
));
return html`
<ha-icon-picker
+11 -19
View File
@@ -1,9 +1,8 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -35,17 +34,6 @@ export class HaServiceIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service]) => {
if (icon || !connection || !config || !service) {
return initialState;
}
return serviceIcon(connection, config, service);
},
args: () =>
[this.icon, this._connection, this._config, this.service] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -59,12 +47,16 @@ export class HaServiceIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = serviceIcon(this._connection, this._config, this.service).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
return html`${until(icon)}`;
}
private _renderFallback() {
+14 -25
View File
@@ -1,9 +1,8 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import { serviceSectionIcon } from "../data/icons";
@@ -32,23 +31,6 @@ export class HaServiceSectionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service, section]) => {
if (icon || !connection || !config || !service || !section) {
return initialState;
}
return serviceSectionIcon(connection, config, service, section);
},
args: () =>
[
this.icon,
this._connection,
this._config,
this.service,
this.section,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -62,12 +44,19 @@ export class HaServiceSectionIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = serviceSectionIcon(
this._connection,
this._config,
this.service,
this.section
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
+17 -47
View File
@@ -1,9 +1,8 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { until } from "lit/directives/until";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
configContext,
@@ -38,47 +37,11 @@ export class HaStateIcon extends LitElement {
@consume({ context: entitiesContext, subscribe: true })
protected _entities?: ContextType<typeof entitiesContext>;
private get _overrideIcon(): string | undefined {
return (
protected render() {
const overrideIcon =
this.icon ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
this.stateObj?.attributes.icon
);
}
private _iconTask = new AsyncValueTask(this, {
task: ([
overrideIcon,
entities,
config,
connection,
stateObj,
stateValue,
]) => {
if (overrideIcon || !entities || !config || !connection || !stateObj) {
return initialState;
}
return entityIcon(
entities,
config.config,
connection.connection,
stateObj,
stateValue
);
},
args: () =>
[
this._overrideIcon,
this._entities,
this._config,
this._connection,
this.stateObj,
this.stateValue,
] as const,
});
protected render() {
const overrideIcon = this._overrideIcon;
this.stateObj?.attributes.icon;
if (overrideIcon) {
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
}
@@ -88,12 +51,19 @@ export class HaStateIcon extends LitElement {
if (!this._config || !this._connection || !this._entities) {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = entityIcon(
this._entities,
this._config.config,
this._connection.connection,
this.stateObj,
this.stateValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
+11 -19
View File
@@ -18,11 +18,10 @@ import {
mdiWebhook,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -72,17 +71,6 @@ export class HaTriggerIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, trigger]) => {
if (icon || !connection || !config || !trigger) {
return initialState;
}
return triggerIcon(connection, config, trigger);
},
args: () =>
[this.icon, this._connection, this._config, this.trigger] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -96,12 +84,16 @@ export class HaTriggerIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = triggerIcon(this._connection, this._config, this.trigger).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
return html`${until(icon)}`;
}
private _renderFallback() {
@@ -33,12 +33,6 @@ const TRACE_PATH_TABS = [
"logbook",
] as const;
// A repeat keeps only its last iterations, so the array index is not the real
// one. Use the recorded repeat.index when we have it.
const iterationNumber = (trace: ActionTraceStep, index: number): number =>
(trace.changed_variables?.repeat as { index?: number } | undefined)?.index ??
index + 1;
@customElement("ha-trace-path-details")
export class HaTracePathDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -220,7 +214,7 @@ export class HaTracePathDetails extends LitElement {
: html`<h3>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: iterationNumber(trace, idx) }
{ number: idx + 1 }
)}
</h3>`}
${curPath
@@ -324,7 +318,7 @@ export class HaTracePathDetails extends LitElement {
? html`<p>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: iterationNumber(trace, idx) }
{ number: idx + 1 }
)}
</p>`
: ""}
+1 -1
View File
@@ -180,7 +180,7 @@ export interface PersistentNotificationTrigger extends BaseTrigger {
export interface ZoneTrigger extends BaseTrigger {
trigger: "zone";
entity_id: string | string[];
entity_id: string;
zone: string;
event: "enter" | "leave";
}
-6
View File
@@ -486,12 +486,6 @@ export const getFormattedBackupTime = memoizeOne(
export const SUPPORTED_UPLOAD_FORMAT = "application/x-tar";
// Browsers report the MIME type of a .tar inconsistently (Firefox on Windows
// gives an empty or different type), so accept it by extension as well.
export const isSupportedBackupFile = (file: File): boolean =>
file.type === SUPPORTED_UPLOAD_FORMAT ||
file.name.toLowerCase().endsWith(".tar");
export interface BackupUploadFileFormData {
file?: File;
}
-7
View File
@@ -10,13 +10,6 @@ export interface DirtyStateContext<
> {
/** Whether any contributor's current slice differs from its initial snapshot */
isDirty: boolean;
/**
* Like `isDirty`, but treats `false` and `undefined`/absent object keys as
* the same value, so a toggle that ends at its off-default (e.g.
* `show_entity_picture: false`) reads as clean and does not warn on a scrim
* close. `isDirty` still reports the raw change so save can stay enabled.
*/
isEffectiveDirty: boolean;
/**
* Push a state slice. The first push for a slice sets its baseline.
* Subsequent pushes are compared against that baseline using the provider's
-77
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,11 +12,6 @@ import type { HaEntityPickerEntityFilterFunc } from "./entity";
export interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
/**
* Closeness to the active related context: 0 = the entity itself, 1 = its
* device, 2 = its area, 3 = unrelated. Lower sorts first.
*/
relatedRank?: number;
}
export const entityComboBoxKeys: FuseWeightedKey[] = [
@@ -194,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
);
});
+2 -6
View File
@@ -8,13 +8,9 @@ export const uploadFile = async (hass: HomeAssistant, file: File) => {
body: fd,
});
if (resp.status === 413) {
throw new Error(
hass.localize("ui.common.upload_file_too_large", {
name: file.name,
})
);
throw new Error(`Uploaded file is too large (${file.name})`);
} else if (resp.status !== 200) {
throw new Error(hass.localize("ui.common.unknown_error"));
throw new Error("Unknown error");
}
const data = await resp.json();
return data.file_id;
+2 -6
View File
@@ -57,13 +57,9 @@ export const createImage = async (
body: fd,
});
if (resp.status === 413) {
throw new Error(
hass.localize("ui.common.upload_image_too_large", {
name: file.name,
})
);
throw new Error(`Uploaded image is too large (${file.name})`);
} else if (resp.status !== 200) {
throw new Error(hass.localize("ui.common.unknown_error"));
throw new Error("Unknown error");
}
return resp.json();
};
+2 -6
View File
@@ -54,13 +54,9 @@ export const uploadLocalMedia = async (
}
);
if (resp.status === 413) {
throw new Error(
hass.localize("ui.common.upload_file_too_large", {
name: file.name,
})
);
throw new Error(`Uploaded file is too large (${file.name})`);
} else if (resp.status !== 200) {
throw new Error(hass.localize("ui.common.unknown_error"));
throw new Error("Unknown error");
}
return resp.json();
};
+1 -7
View File
@@ -18,15 +18,9 @@ export const formatSelectorValue = (
}
if ("text" in selector) {
const { prefix, suffix, type } = selector.text || {};
const { prefix, suffix } = selector.text || {};
const texts = ensureArray(value);
// Never reveal secret values in a read-only preview.
if (type === "password") {
return texts.map(() => "••••••••").join(", ");
}
return texts
.map((text) => `${prefix || ""}${text}${suffix || ""}`)
.join(", ");
+6 -6
View File
@@ -5,7 +5,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-control-button";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
@@ -111,7 +111,7 @@ export class DialogEnterCode
if (isText) {
return html`
<ha-adaptive-dialog
<ha-dialog
.open=${this._open}
header-title=${this._dialogParams.title ??
this.hass.localize("ui.dialogs.enter_code.title")}
@@ -143,12 +143,12 @@ export class DialogEnterCode
this.hass.localize("ui.common.submit")}
</ha-button>
</ha-dialog-footer>
</ha-adaptive-dialog>
</ha-dialog>
`;
}
return html`
<ha-adaptive-dialog
<ha-dialog
.open=${this._open}
header-title=${this._dialogParams.title ?? "Enter code"}
width="small"
@@ -202,12 +202,12 @@ export class DialogEnterCode
)}
</div>
</div>
</ha-adaptive-dialog>
</ha-dialog>
`;
}
static styles = css`
ha-adaptive-dialog {
ha-dialog {
/* Place above other dialogs */
--dialog-z-index: 104;
}
+5 -53
View File
@@ -26,15 +26,6 @@ export type CompareStrategy<State> =
* so independent contributors (e.g. a helper form alongside the entity
* registry editor) can coexist without overwriting each other.
*
* `isEffectiveDirty` runs the same comparison, but first passes each slice's
* initial and current value through the optional `effectiveNormalize` function
* given to `_initDirtyTracking`. Provide a normalizer that collapses values you
* consider equivalent (e.g. a config with a toggle left at its default vs the
* key being absent) so they do not read as dirty. Without a normalizer it is
* identical to `isDirty`. Use `isEffectiveDirtyState` to decide whether closing
* needs a "discard changes?" prompt, and `isDirtyState` to decide whether save
* is enabled.
*
* @example Eager init for the provider's own slice:
* ```ts
* class MyDialog extends DirtyStateProviderMixin<MyDialogState>()(LitElement) {
@@ -72,39 +63,23 @@ export const DirtyStateProviderMixin =
class DirtyStateProviderMixinClass extends superClass {
private _dirtySlices = new Map<
Key | DefaultDirtyStateKey,
{ initial: State; current: State; normalizedInitial: State }
{ initial: State; current: State }
>();
private _dirtyCompareFn: (a: State, b: State) => boolean = deepEqual;
private _dirtyCloneFn: (value: State) => State = (value) => value;
private _effectiveNormalize?: (value: State) => State;
@provide({ context: dirtyStateContext })
@state()
private _dirtyStateContext: DirtyStateContext<State, Key> =
this._buildContextValue();
private _normalizeEffective(value: State): State {
return this._effectiveNormalize
? this._effectiveNormalize(value)
: value;
}
private _buildContextValue(): DirtyStateContext<State, Key> {
const slices = Array.from(this._dirtySlices.values());
return {
isDirty: slices.some(
isDirty: Array.from(this._dirtySlices.values()).some(
({ initial, current }) => !this._dirtyCompareFn(initial, current)
),
isEffectiveDirty: slices.some(
({ normalizedInitial, current }) =>
!this._dirtyCompareFn(
normalizedInitial,
this._normalizeEffective(current)
)
),
setState: (value: State, key: Key) => {
this._writeSlice(key, value);
},
@@ -122,11 +97,9 @@ export const DirtyStateProviderMixin =
const slice = this._dirtySlices.get(key);
if (!slice) {
// First push for this key becomes the baseline.
const initial = this._dirtyCloneFn(value);
this._dirtySlices.set(key, {
initial,
initial: this._dirtyCloneFn(value),
current: value,
normalizedInitial: this._normalizeEffective(initial),
});
this._publishContext();
return;
@@ -146,19 +119,12 @@ export const DirtyStateProviderMixin =
* push for any key (via the provider helper or a consumer's `setState`)
* becomes that key's baseline.
*
* `effectiveNormalize` transforms a slice value before the
* `isEffectiveDirty` comparison, letting the caller treat values it
* considers equivalent as clean (e.g. a config with a toggle at its
* default vs the key being absent). It does not affect `isDirty`.
*
* Call again to reset (e.g. when the underlying entity changes).
*/
protected _initDirtyTracking(
strategy: CompareStrategy<State>,
initialState?: State,
effectiveNormalize?: (value: State) => State
initialState?: State
): void {
this._effectiveNormalize = effectiveNormalize;
switch (strategy.type) {
case "deep":
this._dirtyCompareFn = (a, b) => deepEqual(a, b);
@@ -174,11 +140,9 @@ export const DirtyStateProviderMixin =
}
this._dirtySlices.clear();
if (initialState !== undefined) {
const initial = this._dirtyCloneFn(initialState);
this._dirtySlices.set(DEFAULT_DIRTY_STATE_KEY, {
initial,
initial: this._dirtyCloneFn(initialState),
current: initialState,
normalizedInitial: this._normalizeEffective(initial),
});
}
this._publishContext();
@@ -200,7 +164,6 @@ export const DirtyStateProviderMixin =
protected _markDirtyStateClean(): void {
for (const slice of this._dirtySlices.values()) {
slice.initial = this._dirtyCloneFn(slice.current);
slice.normalizedInitial = this._normalizeEffective(slice.initial);
}
this._publishContext();
}
@@ -222,17 +185,6 @@ export const DirtyStateProviderMixin =
public get isDirtyState(): boolean {
return this._dirtyStateContext.isDirty;
}
/**
* Like `isDirtyState`, but compares values after the `effectiveNormalize`
* function passed to `_initDirtyTracking`, so values the caller treats as
* equivalent (e.g. a toggle left at its default) do not read as dirty. Use
* it to decide whether closing needs a "discard changes?" prompt, while
* `isDirtyState` decides whether save is enabled.
*/
public get isEffectiveDirtyState(): boolean {
return this._dirtyStateContext.isEffectiveDirty;
}
}
return DirtyStateProviderMixinClass;
};
@@ -9,7 +9,6 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
isSupportedBackupFile,
SUPPORTED_UPLOAD_FORMAT,
} from "../../data/backup";
import type { LocalizeFunc } from "../../common/translations/localize";
@@ -77,7 +76,7 @@ class OnboardingRestoreBackupUpload extends LitElement {
this._error = undefined;
const file = ev.detail.files[0];
if (!file || !isSupportedBackupFile(file)) {
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
showAlertDialog(this, {
title: this.localize(
"ui.panel.page-onboarding.restore.unsupported.title"
@@ -1,10 +1,8 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeStateDomain } from "../../../../../common/entity/compute_state_domain";
import { hasLocation } from "../../../../../common/entity/has_location";
import "../../../../../components/entity/ha-entities-picker";
import "../../../../../components/entity/ha-entity-picker";
import "../../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../../components/radio/ha-radio-group";
@@ -29,7 +27,7 @@ export class HaZoneTrigger extends LitElement {
public static get defaultConfig(): ZoneTrigger {
return {
trigger: "zone",
entity_id: [],
entity_id: "",
zone: "",
event: "enter" as ZoneTrigger["event"],
};
@@ -38,16 +36,16 @@ export class HaZoneTrigger extends LitElement {
protected render() {
const { entity_id, zone, event } = this.trigger;
return html`
<ha-entities-picker
<ha-entity-picker
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.zone.entity"
)}
.value=${entity_id ? ensureArray(entity_id) : []}
.value=${entity_id}
.disabled=${this.disabled}
@value-changed=${this._entityPicked}
.hass=${this.hass}
.entityFilter=${zoneAndLocationFilter}
></ha-entities-picker>
></ha-entity-picker>
<ha-entity-picker
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.zone.zone"
@@ -83,7 +81,7 @@ export class HaZoneTrigger extends LitElement {
`;
}
private _entityPicked(ev: ValueChangedEvent<string[]>) {
private _entityPicked(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: { ...this.trigger, entity_id: ev.detail.value },
@@ -16,7 +16,6 @@ import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
INITIAL_UPLOAD_FORM_DATA,
isSupportedBackupFile,
SUPPORTED_UPLOAD_FORMAT,
uploadBackup,
type BackupUploadFileFormData,
@@ -142,7 +141,7 @@ export class DialogUploadBackup
private async _upload() {
const { file } = this._formData!;
if (!file || !isSupportedBackupFile(file)) {
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.backup.dialogs.upload.unsupported.title"
+31 -41
View File
@@ -69,24 +69,6 @@ export async function generateMetadataSuggestionTask<T>(
include.floor ? fetchFloors(connection) : Promise.resolve(undefined),
]);
const categoryOptions = categories
? Object.entries(categories).map(([id, name]) => ({
value: id,
label: name,
}))
: [];
const floorOptions = floors
? Object.values(floors).map((floor) => ({
value: floor.floor_id,
label: floor.name,
}))
: [];
// Only offer the select fields when there is at least one option. Some AI
// providers reject a select/enum schema with an empty options list.
const includeCategories = categoryOptions.length > 0;
const includeFloor = floorOptions.length > 0;
const structure: AITaskStructure = {
...(include.name && {
name: {
@@ -117,42 +99,50 @@ export async function generateMetadataSuggestionTask<T>(
},
},
}),
...(includeCategories && {
category: {
description: `The category of the ${domain}`,
required: false,
selector: {
select: {
options: categoryOptions,
...(include.categories &&
categories && {
category: {
description: `The category of the ${domain}`,
required: false,
selector: {
select: {
options: Object.entries(categories).map(([id, name]) => ({
value: id,
label: name,
})),
},
},
},
},
}),
...(includeFloor && {
floor: {
description: `The floor of the ${domain}`,
required: false,
selector: {
select: {
options: floorOptions,
}),
...(include.floor &&
floors && {
floor: {
description: `The floor of the ${domain}`,
required: false,
selector: {
select: {
options: Object.values(floors).map((floor) => ({
value: floor.floor_id,
label: floor.name,
})),
},
},
},
},
}),
}),
};
const requestedParts = [
include.name ? "a name" : null,
include.description ? "a description" : null,
includeCategories ? "a category" : null,
include.categories ? "a category" : null,
include.labels ? "labels" : null,
includeFloor ? "a floor" : null,
include.floor ? "a floor" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabels: string[] = [
includeCategories ? "category" : null,
include.categories ? "category" : null,
include.labels ? "labels" : null,
includeFloor ? "floor" : null,
include.floor ? "floor" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabelsText = PROMPT_LIST_FORMAT.format(categoryLabels);
@@ -178,7 +168,7 @@ export async function generateMetadataSuggestionTask<T>(
`The name should be in same style and sentence capitalization as existing ${domain}s.`,
]
: []),
...(includeCategories || include.labels || includeFloor
...(include.categories || include.labels || include.floor
? [
`Suggest ${categoryLabelsText} if relevant to the ${domain}'s purpose.`,
`Only suggest ${categoryLabelsText} that are already used by existing ${domain}s.`,
@@ -1,4 +1,5 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import { mdiContentCopy, mdiRestore } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -6,9 +7,9 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consume } from "@lit/context";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { formatNumber } from "../../../common/number/format_number";
@@ -19,14 +20,16 @@ import type {
LocalizeKeys,
} from "../../../common/translations/localize";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
import "../../../components/ha-area-picker";
import "../../../components/ha-color-picker";
import "../../../components/ha-dropdown-item";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-icon-picker";
import "../../../components/ha-input-helper-text";
import "../../../components/ha-labels-picker";
import "../../../components/ha-list-item";
import "../../../components/ha-md-list-item";
@@ -45,16 +48,16 @@ import {
STREAM_TYPE_HLS,
updateCameraPrefs,
} from "../../../data/camera";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteConfigEntry } from "../../../data/config_entries";
import {
createConfigFlow,
handleConfigFlowStep,
} from "../../../data/config_flow";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type { DataEntryFlowStepCreateEntry } from "../../../data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../../data/device/device_registry";
@@ -193,6 +196,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _name!: string;
@state() private _type: "main" | "additional" = "additional";
@state() private _icon!: string;
@state() private _entityId!: EntitySettingsState["entityId"];
@@ -261,7 +266,12 @@ export class EntityRegistrySettingsEditor extends LitElement {
return;
}
this._name = this.entry.name || "";
this._type =
this.entry.device_id &&
!computeEntityEntryName(this.entry, this.hass.devices)
? "main"
: "additional";
this._name = this._type === "main" ? "" : this.entry.name || "";
this._icon = this.entry.icon || "";
this._deviceClass =
this.entry.device_class || this.entry.original_device_class;
@@ -386,7 +396,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._dirtyState?.setState(
{
name: this._name.trim() || null,
name: this._computeName(),
icon: this._icon.trim() || null,
entityId: this._entityId.trim(),
areaId: this._areaId ?? null,
@@ -468,34 +478,75 @@ export class EntityRegistrySettingsEditor extends LitElement {
return html`
${this.hideName
? nothing
: html`<ha-input
inset-label
class="name"
.value=${this._name}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.name"
)}
.disabled=${this.disabled}
@input=${this._nameChanged}
>
${this._device
? html`<span slot="hint"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.device_name_tip",
{
link: html`<button
class="link"
@click=${this._resetNameAndOpenDeviceSettings}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.open_device_settings"
)}
</button>`,
}
)}</span
>`
: nothing}
</ha-input>`}
: this._device
? html`<ha-select
class="type"
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.entity_type"
)}
.value=${this._type}
.options=${[
{
value: "main",
label: this.hass.localize(
"ui.dialogs.entity_registry.editor.main_entity"
),
},
{
value: "additional",
label: this.hass.localize(
"ui.dialogs.entity_registry.editor.additional_entity"
),
},
]}
.disabled=${this.disabled}
@selected=${this._entityNameModeChanged}
></ha-select>
<ha-input-helper-text>
${this._type === "main"
? html`${this.hass.localize(
"ui.dialogs.entity_registry.editor.main_entity_description"
)}
${this.hass.localize(
"ui.dialogs.entity_registry.editor.change_device_settings",
{
link: html`<button
class="link"
@click=${this._openDeviceSettings}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.change_device_name_link"
)}
</button>`,
}
)}`
: this.hass.localize(
"ui.dialogs.entity_registry.editor.additional_entity_description"
)}
</ha-input-helper-text>
${this._type === "main"
? nothing
: html`<ha-input
inset-label
class="name"
.value=${this._name}
.placeholder=${this.entry.original_name || ""}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.entity_name"
)}
.disabled=${this.disabled}
@input=${this._nameChanged}
></ha-input>`}`
: html`<ha-input
inset-label
class="name"
.value=${this._name}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.name"
)}
.disabled=${this.disabled}
@input=${this._nameChanged}
></ha-input>`}
${this.hideIcon
? nothing
: html`
@@ -1178,7 +1229,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
const params: Partial<EntityRegistryEntryUpdateParams> = {
name: this._name.trim() || null,
name: this._computeName(),
icon: this._icon.trim() || null,
area_id: this._areaId || null,
labels: this._labels || [],
@@ -1625,9 +1676,24 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
}
private _resetNameAndOpenDeviceSettings() {
this._name = this.entry.name || "";
this._openDeviceSettings();
private _entityNameModeChanged(ev: HaSelectSelectEvent): void {
const value = ev.detail.value;
if (!value) {
return;
}
this._type = value === "main" ? "main" : "additional";
this._name = this._type === "main" ? "" : this.entry.name || "";
}
private _computeName(): string | null {
if (this._device && this._type === "main") {
// No original_name → keep null; forcing main on a named entity → "".
if (this.entry.name == null && !this.entry.original_name) {
return null;
}
return "";
}
return this._name.trim() || null;
}
private _openDeviceSettings() {
@@ -1764,6 +1830,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
.entityId {
direction: ltr;
}
ha-input-helper-text {
display: block;
margin: 0 0 var(--ha-space-2);
}
`,
];
}
@@ -121,7 +121,8 @@ class ZWaveJSNodeConfig extends LitElement {
.header=${this.hass.localize(
"ui.panel.config.zwave_js.node_config.header"
)}
back-path="/config/devices/device/${this.deviceId}"
back-path="/config/zwave_js/dashboard?config_entry=${this
.configEntryId}"
>
<ha-config-section
.narrow=${this.narrow}
@@ -84,10 +84,6 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
};
}
public static getDefaultConfig(): Partial<EntityBadgeConfig> {
return DEFAULT_CONFIG;
}
@property({ attribute: false }) public hass?: HomeAssistant;
@state() protected _config?: EntityBadgeConfig;
@@ -1,7 +1,6 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
@@ -24,8 +23,6 @@ import type {
LovelaceCardFeatureContext,
} from "./types";
const OPTION_MIN_WIDTH = 30;
type NumericFavoriteEntity = HassEntity & {
attributes: HassEntity["attributes"] & {
current_position?: number;
@@ -96,16 +93,6 @@ export abstract class HuiNumericFavoriteCardFeatureBase<
private _subscribedConnection?: HomeAssistant["connection"];
private _resizeController = new ResizeController<number | undefined>(this, {
callback: (entries: { contentRect?: { width: number } }[]) => {
const width = entries[0]?.contentRect?.width;
if (!width) {
return undefined;
}
return Math.max(1, Math.floor(width / OPTION_MIN_WIDTH));
},
});
protected abstract get _definition(): NumericFavoriteCardFeatureDefinition<TEntity>;
protected get _stateObj(): TEntity | undefined {
@@ -314,11 +301,7 @@ export abstract class HuiNumericFavoriteCardFeatureBase<
return null;
}
const maxVisible = this._resizeController.value;
const visiblePositions =
maxVisible != null ? positions.slice(0, maxVisible) : positions;
const options = visiblePositions.map((position) => ({
const options = positions.map((position) => ({
value: String(position),
label: `${position}%`,
ariaLabel: hass.localize(this._definition.setPositionLabelKey, {
@@ -347,13 +330,6 @@ export abstract class HuiNumericFavoriteCardFeatureBase<
}
static get styles() {
return [
cardFeatureStyles,
css`
:host {
display: block;
}
`,
];
return cardFeatureStyles;
}
}
@@ -53,15 +53,6 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
};
}
public static getDefaultConfig(): Partial<PictureEntityCardConfig> {
return {
show_name: true,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@@ -73,12 +73,6 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
};
}
public static getDefaultConfig(): Partial<TileCardConfig> {
return {
features_position: "bottom",
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: TileCardConfig;
@@ -6,7 +6,6 @@ import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import { stripDefaults } from "../../../../common/util/strip-defaults";
import { withViewTransition } from "../../../../common/util/view-transition";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
@@ -33,7 +32,6 @@ import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge";
import "../../sections/hui-section";
import { addBadge, replaceBadge } from "../config-util";
import { getBadgeDefaultConfig } from "../get-badge-default-config";
import { getBadgeDocumentationURL } from "../get-dashboard-documentation-url";
import type { ConfigChangedEvent } from "../hui-element-editor";
import { findLovelaceContainer } from "../lovelace-path";
@@ -111,21 +109,16 @@ export class HuiDialogEditBadge
if (this._badgeConfig && !Object.isFrozen(this._badgeConfig)) {
this._badgeConfig = deepFreeze(this._badgeConfig);
}
const effectiveDefaults = this._badgeConfig?.type
? await getBadgeDefaultConfig(this._badgeConfig.type)
: undefined;
const normalize = (config: LovelaceBadgeConfig) =>
stripDefaults(config, effectiveDefaults);
if ("badgeConfig" in params && this._badgeConfig) {
this._initDirtyTracking({ type: "deep" }, { type: "" }, normalize);
this._initDirtyTracking({ type: "deep" }, { type: "" });
this._updateDirtyState(this._badgeConfig);
} else {
this._initDirtyTracking({ type: "deep" }, this._badgeConfig, normalize);
this._initDirtyTracking({ type: "deep" }, this._badgeConfig);
}
}
public closeDialog(): boolean {
if (this.isEffectiveDirtyState) {
if (this.isDirtyState) {
this._confirmCancel();
return false;
}
@@ -203,7 +196,7 @@ export class HuiDialogEditBadge
<ha-dialog
.open=${this._open}
.width=${this.large ? "full" : "large"}
.preventScrimClose=${this.isEffectiveDirtyState}
.preventScrimClose=${this.isDirtyState}
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
@opened=${this._opened}
@@ -7,7 +7,6 @@ import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import { stripDefaults } from "../../../../common/util/strip-defaults";
import { withViewTransition } from "../../../../common/util/view-transition";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
@@ -34,7 +33,6 @@ import { showToast } from "../../../../util/toast";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import "../../sections/hui-section";
import { getCardDefaultConfig } from "../get-card-default-config";
import { getCardDocumentationURL } from "../get-dashboard-documentation-url";
import type { ConfigChangedEvent } from "../hui-element-editor";
import type { GUIModeChangedEvent } from "../types";
@@ -97,21 +95,16 @@ export class HuiDialogEditCard
if (this._cardConfig && !Object.isFrozen(this._cardConfig)) {
this._cardConfig = deepFreeze(this._cardConfig);
}
const effectiveDefaults = this._cardConfig?.type
? await getCardDefaultConfig(this._cardConfig.type)
: undefined;
const normalize = (config: LovelaceCardConfig) =>
stripDefaults(config, effectiveDefaults);
if (params.isNew && this._cardConfig) {
this._initDirtyTracking({ type: "deep" }, { type: "" }, normalize);
this._initDirtyTracking({ type: "deep" }, { type: "" });
this._updateDirtyState(this._cardConfig);
} else {
this._initDirtyTracking({ type: "deep" }, this._cardConfig, normalize);
this._initDirtyTracking({ type: "deep" }, this._cardConfig);
}
}
public closeDialog(): boolean {
if (this.isEffectiveDirtyState) {
if (this.isDirtyState) {
this._confirmCancel();
return false;
}
@@ -179,7 +172,7 @@ export class HuiDialogEditCard
<ha-dialog
.open=${this._open}
.width=${this.large ? "full" : "large"}
.preventScrimClose=${this.isEffectiveDirtyState}
.preventScrimClose=${this.isDirtyState}
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
@opened=${this._opened}
@@ -1,13 +0,0 @@
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { getBadgeElementClass } from "../create-element/create-badge-element";
export const getBadgeDefaultConfig = async (
type: string
): Promise<Partial<LovelaceBadgeConfig> | undefined> => {
try {
const elClass = await getBadgeElementClass(type);
return elClass?.getDefaultConfig?.();
} catch (_err) {
return undefined;
}
};
@@ -1,13 +0,0 @@
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { getCardElementClass } from "../create-element/create-card-element";
export const getCardDefaultConfig = async (
type: string
): Promise<Partial<LovelaceCardConfig> | undefined> => {
try {
const elClass = await getCardElementClass(type);
return elClass?.getDefaultConfig?.();
} catch (_err) {
return undefined;
}
};
-2
View File
@@ -96,7 +96,6 @@ export interface LovelaceCardConstructor extends Constructor<LovelaceCard> {
entities: string[],
entitiesFallback: string[]
) => LovelaceCardConfig;
getDefaultConfig?: () => Partial<LovelaceCardConfig>;
getConfigElement?: () => LovelaceCardEditor;
getConfigForm?: () => LovelaceConfigForm;
}
@@ -107,7 +106,6 @@ export interface LovelaceBadgeConstructor extends Constructor<LovelaceBadge> {
entities: string[],
entitiesFallback: string[]
) => LovelaceBadgeConfig;
getDefaultConfig?: () => Partial<LovelaceBadgeConfig>;
getConfigElement?: () => LovelaceBadgeEditor;
getConfigForm?: () => LovelaceConfigForm;
}
@@ -32,6 +32,7 @@ export const maintenanceEntityFilters: EntityFilter[] = [
{
domain: "binary_sensor",
device_class: ["battery"],
entity_category: "none",
},
];
+9 -5
View File
@@ -444,9 +444,6 @@
"loading": "Loading",
"refresh": "Refresh",
"cancel": "Cancel",
"upload_file_too_large": "Uploaded file {name} is too large",
"upload_image_too_large": "Uploaded image {name} is too large",
"unknown_error": "Unknown error",
"delete": "Delete",
"delete_all": "Delete all",
"download": "Download",
@@ -1923,6 +1920,12 @@
"enable_entity": "Enable",
"open_device_settings": "Open device settings",
"device_name_tip": "Consider renaming the device instead to update all its entities at once. {link}",
"entity_type": "Entity type",
"main_entity": "Main entity",
"main_entity_description": "Represents the device.",
"additional_entity_description": "Has its own name, shown with the device name.",
"additional_entity": "Additional entity",
"entity_name": "Entity name",
"switch_as_x_confirm": "This switch will be hidden and a new {domain} will be added. Your existing configurations using the switch will continue to work.",
"switch_as_x_remove_confirm": "This {domain} will be removed and the original switch will be visible again. Your existing configurations using the {domain} will no longer work!",
"switch_as_x_change_confirm": "This {domain_1} will be removed and will be replaced by a new {domain_2}. Your existing configurations using the {domain_1} will no longer work!",
@@ -1945,6 +1948,7 @@
"use_device_area": "Use device area",
"change_device_settings": "You can {link} in the device settings",
"change_device_area_link": "change the device area",
"change_device_name_link": "change the device name",
"configure_state": "{integration} options",
"configure_state_secondary": "Specific settings for {integration}",
"stream": {
@@ -5352,7 +5356,7 @@
"entity": "Entity with timestamp",
"offset_by": "offset by {offset}",
"mode": "Mode",
"weekday": "Days of the week",
"weekday": "Days of the Week",
"weekdays": {
"mon": "[%key:ui::weekdays::monday%]",
"tue": "[%key:ui::weekdays::tuesday%]",
@@ -5563,7 +5567,7 @@
"label": "[%key:ui::panel::config::automation::editor::triggers::type::time::label%]",
"after": "After",
"before": "Before",
"weekday": "Days of the week",
"weekday": "Weekdays",
"mode_after": "[%key:ui::panel::config::automation::editor::conditions::type::time::after%]",
"mode_before": "[%key:ui::panel::config::automation::editor::conditions::type::time::before%]",
"weekdays": {
-178
View File
@@ -1,178 +0,0 @@
import { describe, expect, it } from "vitest";
import { deepEqual } from "../../../src/common/util/deep-equal";
import { stripDefaults } from "../../../src/common/util/strip-defaults";
describe("stripDefaults", () => {
describe("without a defaults map (default is false)", () => {
it("removes keys whose value is false", () => {
expect(stripDefaults({ a: 1, b: false })).toEqual({ a: 1 });
});
it("removes keys whose value is undefined", () => {
expect(stripDefaults({ a: 1, b: undefined })).toEqual({ a: 1 });
});
it("keeps other falsy values (0, empty string, null)", () => {
expect(stripDefaults({ a: 0, b: "", c: null })).toEqual({
a: 0,
b: "",
c: null,
});
});
it("keeps true and non-empty values", () => {
const obj = { a: true, b: "x", c: { d: 1 }, e: [1, 2] };
expect(stripDefaults(obj)).toEqual(obj);
});
it("does not recurse into nested objects", () => {
expect(stripDefaults({ a: { b: false } })).toEqual({ a: { b: false } });
});
it("returns non-plain-object values unchanged", () => {
expect(stripDefaults(undefined)).toBe(undefined);
expect(stripDefaults(null)).toBe(null);
expect(stripDefaults(5)).toBe(5);
expect(stripDefaults("x")).toBe("x");
const arr = [1, false, undefined];
expect(stripDefaults(arr)).toBe(arr);
});
});
describe("with a defaults map", () => {
it("drops a key that equals its default (default true)", () => {
expect(stripDefaults({ show_name: true }, { show_name: true })).toEqual(
{}
);
});
it("keeps a default-true key set to false (a real change)", () => {
expect(stripDefaults({ show_name: false }, { show_name: true })).toEqual({
show_name: false,
});
});
it("drops a non-boolean key that equals its default", () => {
expect(
stripDefaults(
{ features_position: "bottom" },
{ features_position: "bottom" }
)
).toEqual({});
});
it("keeps a non-boolean key that differs from its default", () => {
expect(
stripDefaults(
{ features_position: "inline" },
{ features_position: "bottom" }
)
).toEqual({ features_position: "inline" });
});
it("still drops false for keys absent from the map", () => {
expect(
stripDefaults({ vertical: false }, { features_position: "bottom" })
).toEqual({});
});
it("always drops undefined, even when the default is true", () => {
expect(
stripDefaults({ show_name: undefined }, { show_name: true })
).toEqual({});
});
});
});
// How the card/badge dialogs compare configs for the effective-dirty signal.
describe("effective dirty comparison", () => {
const effectivelyEqual = (
a: unknown,
b: unknown,
defaults?: Record<string, unknown>
) => deepEqual(stripDefaults(a, defaults), stripDefaults(b, defaults));
it("tile: baked defaults (incl. features_position) read as unchanged", () => {
const defaults = { features_position: "bottom" };
expect(
effectivelyEqual(
{ type: "tile", entity: "cover.curtain", features: [{ type: "f" }] },
{
type: "tile",
entity: "cover.curtain",
show_entity_picture: false,
vertical: false,
features: [{ type: "f" }],
features_position: "bottom",
},
defaults
)
).toBe(true);
});
it("tile: a real toggle (show_entity_picture: true) reads as changed", () => {
const defaults = { features_position: "bottom" };
expect(
effectivelyEqual(
{ type: "tile", entity: "cover.curtain", features: [{ type: "f" }] },
{
type: "tile",
entity: "cover.curtain",
show_entity_picture: true,
vertical: false,
features: [{ type: "f" }],
features_position: "bottom",
},
defaults
)
).toBe(false);
});
it("picture-entity: baked default-true toggles read as unchanged", () => {
const defaults = {
show_name: true,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
};
expect(
effectivelyEqual(
{ type: "picture-entity", entity: "light.x", image: "x.png" },
{
type: "picture-entity",
entity: "light.x",
image: "x.png",
show_name: true,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
},
defaults
)
).toBe(true);
});
it("picture-entity: turning off a default-true toggle reads as changed", () => {
const defaults = {
show_name: true,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
};
expect(
effectivelyEqual(
{ type: "picture-entity", entity: "light.x", image: "x.png" },
{
type: "picture-entity",
entity: "light.x",
image: "x.png",
show_name: false,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
},
defaults
)
).toBe(false);
});
});
-50
View File
@@ -1,50 +0,0 @@
import { describe, it, expect, afterEach } from "vitest";
import "../../src/components/ha-control-slider";
import type { HaControlSlider } from "../../src/components/ha-control-slider";
const createSlider = (props: Partial<HaControlSlider>): HaControlSlider => {
const el = document.createElement("ha-control-slider") as HaControlSlider;
Object.assign(el, props);
return el;
};
describe("ha-control-slider value mapping", () => {
afterEach(() => {
document.dir = "ltr";
});
it("maps a vertical slider bottom-to-top in LTR", () => {
const el = createSlider({ vertical: true, min: 0, max: 100 });
expect(el.valueToPercentage(100)).toBe(1);
expect(el.valueToPercentage(0)).toBe(0);
expect(el.percentageToValue(1)).toBe(100);
});
it("does not invert a vertical slider in RTL", () => {
document.dir = "rtl";
const el = createSlider({ vertical: true, min: 0, max: 100 });
// A vertical slider must ignore RTL: the top stays at the maximum.
expect(el.valueToPercentage(100)).toBe(1);
expect(el.percentageToValue(1)).toBe(100);
expect(el.percentageToValue(0)).toBe(0);
});
it("still mirrors a horizontal slider in RTL", () => {
document.dir = "rtl";
const el = createSlider({ vertical: false, min: 0, max: 100 });
expect(el.valueToPercentage(100)).toBe(0);
expect(el.percentageToValue(0)).toBe(100);
});
it("keeps an explicitly inverted vertical slider inverted in both directions", () => {
const el = createSlider({
vertical: true,
inverted: true,
min: 0,
max: 100,
});
expect(el.valueToPercentage(100)).toBe(0);
document.dir = "rtl";
expect(el.valueToPercentage(100)).toBe(0);
});
});
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import { isSupportedBackupFile } from "../../src/data/backup";
const makeFile = (name: string, type: string): File =>
new File([""], name, { type });
describe("isSupportedBackupFile", () => {
it("accepts a .tar with the correct MIME type", () => {
expect(
isSupportedBackupFile(makeFile("backup.tar", "application/x-tar"))
).toBe(true);
});
it("accepts a .tar when the browser reports no MIME type (Firefox on Windows)", () => {
expect(isSupportedBackupFile(makeFile("backup.tar", ""))).toBe(true);
});
it("accepts a .tar reported as a generic binary type", () => {
expect(
isSupportedBackupFile(makeFile("backup.tar", "application/octet-stream"))
).toBe(true);
});
it("accepts regardless of extension casing", () => {
expect(isSupportedBackupFile(makeFile("BACKUP.TAR", ""))).toBe(true);
});
it("rejects a non-tar file", () => {
expect(isSupportedBackupFile(makeFile("photo.png", "image/png"))).toBe(
false
);
});
});
+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"]);
});
});
-63
View File
@@ -1,63 +0,0 @@
import { describe, it, expect } from "vitest";
import { formatSelectorValue } from "../../src/data/selector/format_selector_value";
import type { HomeAssistant } from "../../src/types";
// formatSelectorValue only touches hass for floor/area/entity/device
// selectors, none of which these tests exercise.
const hass = {} as HomeAssistant;
describe("formatSelectorValue", () => {
it("returns an empty string for nullish values", () => {
expect(formatSelectorValue(hass, null)).toBe("");
expect(formatSelectorValue(hass, undefined)).toBe("");
});
it("renders a plain text value", () => {
expect(formatSelectorValue(hass, "hello", { text: { type: "text" } })).toBe(
"hello"
);
});
it("applies prefix and suffix for text selectors", () => {
expect(
formatSelectorValue(hass, "5", {
text: { type: "text", prefix: "$", suffix: " each" },
})
).toBe("$5 each");
});
it("masks a password text value instead of revealing it", () => {
const result = formatSelectorValue(hass, "hunter2", {
text: { type: "password" },
});
expect(result).not.toContain("hunter2");
expect(result).toBe("••••••••");
});
it("masks every value of a multiple password selector", () => {
const result = formatSelectorValue(hass, ["one", "two"], {
text: { type: "password" },
});
expect(result).not.toContain("one");
expect(result).not.toContain("two");
expect(result).toBe("••••••••, ••••••••");
});
it("masks a nested password field in an object selector preview", () => {
const result = formatSelectorValue(
hass,
{ username: "admin", password: "hunter2" },
{
object: {
fields: {
username: { selector: { text: { type: "text" } } },
password: { selector: { text: { type: "password" } } },
},
},
}
);
expect(result).toContain("admin");
expect(result).not.toContain("hunter2");
expect(result).toContain("••••••••");
});
});
-7
View File
@@ -84,12 +84,6 @@ describe("image_upload", () => {
const file = new File([""], "image.png", { type: "image/png" });
const hass = {
fetchWithAuth: vi.fn().mockResolvedValue({ status: 413 }),
localize: vi.fn((key: string, values?: Record<string, string>) => {
if (key === "ui.common.upload_image_too_large") {
return `Uploaded image is too large (${values?.name})`;
}
return "Unknown error";
}),
} as unknown as HomeAssistant;
await expect(createImage(hass, file)).rejects.toThrow(
"Uploaded image is too large (image.png)"
@@ -100,7 +94,6 @@ describe("image_upload", () => {
const file = new File([""], "image.png", { type: "image/png" });
const hass = {
fetchWithAuth: vi.fn().mockResolvedValue({ status: 500 }),
localize: vi.fn(() => "Unknown error"),
} as unknown as HomeAssistant;
await expect(createImage(hass, file)).rejects.toThrow("Unknown error");
});
@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { generateMetadataSuggestionTask } from "../../../../src/panels/config/common/suggest-metadata-ai";
import type { MetadataSuggestionInclude } from "../../../../src/panels/config/common/suggest-metadata-ai";
import type { HomeAssistant } from "../../../../src/types";
const fetchCategories = vi.hoisted(() => vi.fn());
const fetchFloors = vi.hoisted(() => vi.fn());
const fetchLabels = vi.hoisted(() => vi.fn());
vi.mock(
"../../../../src/panels/config/common/suggest-metadata-helpers",
() => ({
fetchCategories,
fetchFloors,
fetchLabels,
})
);
const connection = {} as HomeAssistant["connection"];
const INCLUDE_ALL: MetadataSuggestionInclude = {
name: true,
description: true,
categories: true,
labels: true,
floor: true,
};
const generate = (include: MetadataSuggestionInclude) =>
generateMetadataSuggestionTask(
connection,
"en",
"automation",
{ alias: "Test" },
[],
include
);
describe("generateMetadataSuggestionTask", () => {
beforeEach(() => {
fetchCategories.mockResolvedValue({});
fetchFloors.mockResolvedValue({});
fetchLabels.mockResolvedValue({});
});
it("omits the category field when there are no categories", async () => {
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.category).toBeUndefined();
expect(result.task.instructions).not.toContain("a category");
});
it("includes the category field when categories exist", async () => {
fetchCategories.mockResolvedValue({ work: "Work" });
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.category).toEqual({
description: "The category of the automation",
required: false,
selector: { select: { options: [{ value: "work", label: "Work" }] } },
});
expect(result.task.instructions).toContain("a category");
});
it("omits the floor field when there are no floors", async () => {
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.floor).toBeUndefined();
expect(result.task.instructions).not.toContain("a floor");
});
it("includes the floor field when floors exist", async () => {
fetchFloors.mockResolvedValue({
ground: { floor_id: "ground", name: "Ground floor" },
});
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.floor).toEqual({
description: "The floor of the automation",
required: false,
selector: {
select: { options: [{ value: "ground", label: "Ground floor" }] },
},
});
});
it("always includes the free-text labels field regardless of registries", async () => {
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.labels).toEqual({
description: "Labels for the automation",
required: false,
selector: { text: { multiple: true } },
});
});
});
+506 -661
View File
File diff suppressed because it is too large Load Diff