mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-20 23:31:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e8d38ea63 |
@@ -502,10 +502,6 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
selector: { text: { type: "password" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+2
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>`
|
||||
: ""}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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(", ");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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,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("••••••••");
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user