mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-26 08:41:32 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6fdb8a3dc | ||
|
|
2b02c608d3 | ||
|
|
fb35194041 | ||
|
|
d4e3ec858e | ||
|
|
6516b8de68 | ||
|
|
edb2f98d03 | ||
|
|
5ae95a1ea2 | ||
|
|
d0371e506f | ||
|
|
872205c352 |
+1
-1
@@ -143,7 +143,7 @@
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.1",
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
|
||||
@@ -33,7 +33,10 @@ const normalizeFilterArray = <T>(
|
||||
};
|
||||
|
||||
export const generateEntityFilter = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>,
|
||||
filter: EntityFilter
|
||||
): EntityFilterFunc => {
|
||||
const domains = filter.domain
|
||||
|
||||
+38
-15
@@ -1,34 +1,57 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDelete } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "./state-badge";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-settings-row";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
} from "../../data/context";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"delete-favorite-entity": { index: number };
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
"home-favorite-entity-list-item": HomeFavoriteEntityListItem;
|
||||
"ha-favorite-entity-list-item": HaFavoriteEntityListItem;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("home-favorite-entity-list-item")
|
||||
export class HomeFavoriteEntityListItem extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@customElement("ha-favorite-entity-list-item")
|
||||
export class HaFavoriteEntityListItem extends LitElement {
|
||||
@property({ attribute: "entity-id" }) public entityId!: string;
|
||||
|
||||
@property({ type: Number }) public index = 0;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["entityId"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
protected render() {
|
||||
const stateObj = this.hass.states[this.entityId];
|
||||
const stateObj = this._stateObj;
|
||||
const { primary, secondary } = stateObj
|
||||
? computeEntityPickerDisplay(this.hass, stateObj)
|
||||
? computeEntityPickerDisplay(
|
||||
{
|
||||
...this._registries,
|
||||
language: this._i18n.language,
|
||||
translationMetadata: this._i18n.translationMetadata,
|
||||
},
|
||||
stateObj
|
||||
)
|
||||
: { primary: this.entityId, secondary: undefined };
|
||||
|
||||
return html`
|
||||
@@ -42,7 +65,7 @@ export class HomeFavoriteEntityListItem extends LitElement {
|
||||
}
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.label=${this._i18n.localize("ui.common.delete")}
|
||||
@click=${this._delete}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>
|
||||
+23
-21
@@ -2,24 +2,28 @@ import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "./home-favorite-entity-list-item";
|
||||
|
||||
@customElement("home-favorites-editor")
|
||||
export class HomeFavoritesEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "./ha-entity-picker";
|
||||
import "./ha-entity-picker";
|
||||
import "../ha-sortable";
|
||||
import "../ha-svg-icon";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { ValueChangedEvent } from "../../types";
|
||||
import "./ha-favorite-entity-list-item";
|
||||
|
||||
@customElement("ha-favorites-editor")
|
||||
export class HaFavoritesEditor extends LitElement {
|
||||
@property({ attribute: false }) public favorites: string[] = [];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
${this.label ? html`<p class="field-label">${this.label}</p>` : nothing}
|
||||
@@ -27,22 +31,21 @@ export class HomeFavoritesEditor extends LitElement {
|
||||
this.helper ? html`<p class="field-helper">${this.helper}</p>` : nothing
|
||||
}
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
|
||||
<div class="home-list">
|
||||
<div class="favorites-list">
|
||||
${repeat(
|
||||
this.favorites,
|
||||
(entityId) => entityId,
|
||||
(entityId, index) => html`
|
||||
<div class="home-list-item favorite-row">
|
||||
<div class="favorite-row">
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<home-favorite-entity-list-item
|
||||
<ha-favorite-entity-list-item
|
||||
class="favorite-content"
|
||||
.hass=${this.hass}
|
||||
.entityId=${entityId}
|
||||
.index=${index}
|
||||
@delete-favorite-entity=${this._remove}
|
||||
></home-favorite-entity-list-item>
|
||||
></ha-favorite-entity-list-item>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
@@ -50,10 +53,9 @@ export class HomeFavoritesEditor extends LitElement {
|
||||
</ha-sortable>
|
||||
<ha-entity-picker
|
||||
add-button
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
|
||||
)}
|
||||
.addButtonLabel=${this.addButtonLabel}
|
||||
.excludeEntities=${this.favorites}
|
||||
.entityFilter=${this.entityFilter}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
@@ -102,7 +104,7 @@ export class HomeFavoritesEditor extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
.home-list {
|
||||
.favorites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -131,6 +133,6 @@ export class HomeFavoritesEditor extends LitElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"home-favorites-editor": HomeFavoritesEditor;
|
||||
"ha-favorites-editor": HaFavoritesEditor;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { dump } from "js-yaml";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -7,8 +8,16 @@ import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_tim
|
||||
import type { Trigger } from "../../data/automation";
|
||||
import { migrateAutomationTrigger } from "../../data/automation";
|
||||
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
|
||||
import { fullEntitiesContext, labelsContext } from "../../data/context";
|
||||
import type { ConditionDescriptions } from "../../data/condition";
|
||||
import {
|
||||
conditionDescriptionsContext,
|
||||
fullEntitiesContext,
|
||||
labelsContext,
|
||||
manifestsContext,
|
||||
triggerDescriptionsContext,
|
||||
} from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { DomainManifestLookup } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { describeAction } from "../../data/script_i18n";
|
||||
@@ -17,7 +26,12 @@ import type {
|
||||
ChooseActionTraceStep,
|
||||
TraceExtended,
|
||||
} from "../../data/trace";
|
||||
import type { TargetSelector } from "../../data/selector";
|
||||
import { getDataFromPath, isTriggerPath } from "../../data/trace";
|
||||
import type { TriggerDescriptions } from "../../data/trigger";
|
||||
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
|
||||
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
|
||||
import "../../panels/config/automation/target/ha-automation-row-targets";
|
||||
import "../../panels/logbook/ha-logbook-renderer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
@@ -67,6 +81,18 @@ export class HaTracePathDetails extends LitElement {
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelReg!: LabelRegistryEntry[];
|
||||
|
||||
@state()
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
private _manifests?: DomainManifestLookup;
|
||||
|
||||
@state()
|
||||
@consume({ context: triggerDescriptionsContext, subscribe: true })
|
||||
private _triggerDescriptions?: TriggerDescriptions;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions?: ConditionDescriptions;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="padded-box trace-info">
|
||||
@@ -191,51 +217,8 @@ export class HaTracePathDetails extends LitElement {
|
||||
)}`;
|
||||
}
|
||||
|
||||
const selectedType = this.selected.type;
|
||||
|
||||
return html`
|
||||
${
|
||||
curPath === this.selected.path
|
||||
? currentDetail.alias
|
||||
? html`<h2>${currentDetail.alias}</h2>`
|
||||
: selectedType === "trigger"
|
||||
? html`<h2>
|
||||
${describeTrigger(
|
||||
migrateAutomationTrigger({
|
||||
...currentDetail,
|
||||
}) as Trigger,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}
|
||||
</h2>`
|
||||
: selectedType === "condition"
|
||||
? html`<h2>
|
||||
${describeCondition(
|
||||
currentDetail,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}
|
||||
</h2>`
|
||||
: selectedType === "action"
|
||||
? html`<h2>
|
||||
${describeAction(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
currentDetail
|
||||
)}
|
||||
</h2>`
|
||||
: selectedType === "chooseOption"
|
||||
? html`<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.choose.option",
|
||||
{ number: pathParts[pathParts.length - 1] }
|
||||
)}
|
||||
</h2>`
|
||||
: nothing
|
||||
: html`<h2>
|
||||
${curPath.substring(this.selected.path.length + 1)}
|
||||
</h2>`
|
||||
}
|
||||
${this._renderStepHeading(curPath, currentDetail, pathParts)}
|
||||
${
|
||||
data.length === 1
|
||||
? nothing
|
||||
@@ -246,17 +229,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
)}
|
||||
</h3>`
|
||||
}
|
||||
${
|
||||
curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
.includes("condition")
|
||||
? html`[${describeCondition(
|
||||
currentDetail,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}]<br />`
|
||||
: nothing
|
||||
}
|
||||
${this._renderNestedCondition(curPath, currentDetail)}
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.executed",
|
||||
{
|
||||
@@ -324,6 +297,130 @@ export class HaTracePathDetails extends LitElement {
|
||||
return parts;
|
||||
}
|
||||
|
||||
private _renderStepHeading(
|
||||
curPath: string,
|
||||
currentDetail: any,
|
||||
pathParts: string[]
|
||||
) {
|
||||
if (curPath !== this.selected.path) {
|
||||
return html`<div class="heading">
|
||||
<h2>${curPath.substring(this.selected.path.length + 1)}</h2>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const selectedType = this.selected.type;
|
||||
|
||||
const description = currentDetail.alias
|
||||
? currentDetail.alias
|
||||
: selectedType === "trigger"
|
||||
? describeTrigger(
|
||||
migrateAutomationTrigger({ ...currentDetail }) as Trigger,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)
|
||||
: selectedType === "condition"
|
||||
? describeCondition(currentDetail, this.hass, this._entityReg)
|
||||
: selectedType === "action"
|
||||
? describeAction(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
currentDetail,
|
||||
undefined,
|
||||
false,
|
||||
this._manifests
|
||||
)
|
||||
: selectedType === "chooseOption"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.choose.option",
|
||||
{ number: pathParts[pathParts.length - 1] }
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (description === undefined) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="heading">
|
||||
<h2>${description}</h2>
|
||||
${this._renderTargets(currentDetail, selectedType)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderNestedCondition(curPath: string, currentDetail: any) {
|
||||
if (
|
||||
!curPath.substring(this.selected.path.length + 1).includes("condition")
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="nested-condition">
|
||||
${describeCondition(currentDetail, this.hass, this._entityReg)}
|
||||
${this._renderTargets(currentDetail, "condition", "s")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderTargets(
|
||||
config: any,
|
||||
type: NodeInfo["type"],
|
||||
size: "s" | "m" = "m"
|
||||
) {
|
||||
const target = this._getTarget(config, type);
|
||||
if (!target) {
|
||||
return nothing;
|
||||
}
|
||||
const targetSpec = this._getTargetSelector(config, type);
|
||||
return html`<div class="targets">
|
||||
<ha-automation-row-targets
|
||||
.target=${target}
|
||||
.selector=${targetSpec ? { target: targetSpec } : undefined}
|
||||
.size=${size}
|
||||
interactive
|
||||
></ha-automation-row-targets>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _getTargetSelector(
|
||||
config: any,
|
||||
type: NodeInfo["type"]
|
||||
): TargetSelector["target"] | undefined {
|
||||
if (type === "trigger") {
|
||||
return this._triggerDescriptions?.[config.trigger]?.target;
|
||||
}
|
||||
if (type === "condition") {
|
||||
return this._conditionDescriptions?.[config.condition]?.target;
|
||||
}
|
||||
if (type === "action" && typeof config.action === "string") {
|
||||
const [domain, service] = config.action.split(".", 2);
|
||||
return this.hass.services?.[domain]?.[service]?.target;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getTarget(
|
||||
config: any,
|
||||
type: NodeInfo["type"]
|
||||
): HassServiceTarget | undefined {
|
||||
if (config.target) {
|
||||
return config.target;
|
||||
}
|
||||
if (type === "trigger" || type === "condition") {
|
||||
const element = type === "trigger" ? config.trigger : config.condition;
|
||||
if (element === "state" || element === "numeric_state") {
|
||||
return getEntityTarget(config.entity_id);
|
||||
}
|
||||
if (element === "device") {
|
||||
return getDeviceTarget(config.device_id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (type === "action") {
|
||||
return config.entity_id
|
||||
? getEntityTarget(config.entity_id)
|
||||
: getDeviceTarget(config.device_id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _renderSelectedConfig() {
|
||||
if (!this.selected?.path) {
|
||||
return nothing;
|
||||
@@ -463,6 +560,42 @@ export class HaTracePathDetails extends LitElement {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
margin: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.heading .targets {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.targets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
margin-top: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.nested-condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.nested-condition .targets {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
+35
-223
@@ -149,9 +149,6 @@ const formatNumericLimitValue = (
|
||||
export interface DescribeOptions {
|
||||
// Skip the user defined alias and describe the underlying config.
|
||||
ignoreAlias?: boolean;
|
||||
// Leave the entities out of the sentence, for rows that render them as
|
||||
// target badges.
|
||||
hideEntities?: boolean;
|
||||
}
|
||||
|
||||
export const describeTrigger = (
|
||||
@@ -210,8 +207,7 @@ const tryDescribeTrigger = (
|
||||
const description = describeLegacyTrigger(
|
||||
trigger as LegacyTrigger,
|
||||
hass,
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
entityRegistry
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -235,8 +231,7 @@ const tryDescribeTrigger = (
|
||||
const describeLegacyTrigger = (
|
||||
trigger: LegacyTrigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
) => {
|
||||
// Event Trigger
|
||||
if (trigger.trigger === "event" && trigger.event_type) {
|
||||
@@ -267,12 +262,7 @@ const describeLegacyTrigger = (
|
||||
}
|
||||
|
||||
// Numeric State Trigger
|
||||
if (
|
||||
trigger.trigger === "numeric_state" &&
|
||||
(trigger.entity_id || hideEntities)
|
||||
) {
|
||||
const states = hass.states;
|
||||
|
||||
if (trigger.trigger === "numeric_state") {
|
||||
const stateObj = Array.isArray(trigger.entity_id)
|
||||
? hass.states[trigger.entity_id[0]]
|
||||
: (hass.states[trigger.entity_id] as HassEntity | undefined);
|
||||
@@ -292,82 +282,23 @@ const describeLegacyTrigger = (
|
||||
? describeDuration(hass.locale, trigger.for)
|
||||
: undefined;
|
||||
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(trigger);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
const suffix = numericThresholdSuffix(trigger);
|
||||
if (!suffix) {
|
||||
return hass.localize(`${triggerTranslationBaseKey}.numeric_state.label`);
|
||||
}
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
|
||||
{
|
||||
attribute: attribute,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
|
||||
{
|
||||
attribute: attribute,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(trigger.entity_id)) {
|
||||
for (const entity of trigger.entity_id.values()) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (trigger.entity_id) {
|
||||
entities.push(
|
||||
states[trigger.entity_id]
|
||||
? computeStateName(states[trigger.entity_id])
|
||||
: trigger.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
if (trigger.above !== undefined && trigger.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
{
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (trigger.above !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.above`,
|
||||
{
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (trigger.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.below`,
|
||||
{
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// State Trigger
|
||||
if (trigger.trigger === "state") {
|
||||
const states = hass.states;
|
||||
|
||||
const entityArray: string[] = ensureArray(trigger.entity_id);
|
||||
|
||||
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
|
||||
@@ -463,39 +394,12 @@ const describeLegacyTrigger = (
|
||||
duration = describeDuration(hass.locale, trigger.for) ?? "";
|
||||
}
|
||||
|
||||
if (hideEntities) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.changed`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
anyChange: toChoice === "special" ? "true" : "false",
|
||||
fromChoice: fromChoice,
|
||||
fromString: fromString,
|
||||
toChoice: toChoice,
|
||||
toString: toString,
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (entityArray) {
|
||||
for (const entity of entityArray) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.full`,
|
||||
`${triggerTranslationBaseKey}.state.description.changed`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
hasEntity: entities.length !== 0 ? "true" : "false",
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
anyChange: toChoice === "special" ? "true" : "false",
|
||||
fromChoice: fromChoice,
|
||||
fromString: fromString,
|
||||
toChoice: toChoice,
|
||||
@@ -1037,8 +941,7 @@ const tryDescribeCondition = (
|
||||
const description = describeLegacyCondition(
|
||||
condition as LegacyCondition,
|
||||
hass,
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
entityRegistry
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -1064,8 +967,7 @@ const tryDescribeCondition = (
|
||||
const describeLegacyCondition = (
|
||||
condition: LegacyCondition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
) => {
|
||||
if (condition.condition === "or") {
|
||||
const conditions = ensureArray(condition.conditions);
|
||||
@@ -1122,12 +1024,6 @@ const describeLegacyCondition = (
|
||||
|
||||
// State Condition
|
||||
if (condition.condition === "state") {
|
||||
if (!condition.entity_id && !hideEntities) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.no_entity`
|
||||
);
|
||||
}
|
||||
|
||||
const stateObj = hass.states[
|
||||
Array.isArray(condition.entity_id)
|
||||
? condition.entity_id[0]
|
||||
@@ -1184,51 +1080,14 @@ const describeLegacyCondition = (
|
||||
duration = describeDuration(hass.locale, condition.for) || "";
|
||||
}
|
||||
|
||||
if (hideEntities) {
|
||||
if (states.length === 0) {
|
||||
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.is`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
states: formatListWithOrs(hass.locale, states),
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
if (states.length === 0) {
|
||||
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(condition.entity_id)) {
|
||||
for (const entity of condition.entity_id.values()) {
|
||||
if (hass.states[entity]) {
|
||||
entities.push(computeStateName(hass.states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (condition.entity_id) {
|
||||
entities.push(
|
||||
hass.states[condition.entity_id]
|
||||
? computeStateName(hass.states[condition.entity_id])
|
||||
: condition.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.full`,
|
||||
`${conditionsTranslationBaseKey}.state.description.is`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
numberOfEntities: entities.length,
|
||||
// With "any", entities are joined with "or", which takes a singular
|
||||
// verb in English even for multiple entities ("A or B is ...").
|
||||
matchAny: condition.match === "any" ? "true" : "false",
|
||||
entities:
|
||||
condition.match === "any"
|
||||
? formatListWithOrs(hass.locale, entities)
|
||||
: formatListWithAnds(hass.locale, entities),
|
||||
numberOfStates: states.length,
|
||||
states: formatListWithOrs(hass.locale, states),
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
@@ -1237,10 +1096,7 @@ const describeLegacyCondition = (
|
||||
}
|
||||
|
||||
// Numeric State Condition
|
||||
if (
|
||||
condition.condition === "numeric_state" &&
|
||||
(condition.entity_id || hideEntities)
|
||||
) {
|
||||
if (condition.condition === "numeric_state") {
|
||||
const entity_ids = condition.entity_id
|
||||
? ensureArray(condition.entity_id)
|
||||
: [];
|
||||
@@ -1257,64 +1113,20 @@ const describeLegacyCondition = (
|
||||
: condition.attribute
|
||||
: undefined;
|
||||
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
|
||||
{
|
||||
attribute,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
|
||||
{
|
||||
attribute,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entity = formatListWithAnds(
|
||||
hass.locale,
|
||||
entity_ids.map((id) =>
|
||||
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
|
||||
)
|
||||
);
|
||||
|
||||
if (condition.above !== undefined && condition.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
{
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
if (condition.above !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.above`,
|
||||
{
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
}
|
||||
);
|
||||
}
|
||||
if (condition.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.below`,
|
||||
{
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Time condition
|
||||
|
||||
@@ -35,6 +35,18 @@ export interface HomeFrontendSystemData {
|
||||
shortcuts?: ShortcutItem[];
|
||||
}
|
||||
|
||||
export type SecurityAlertSeverity = "alert" | "warning";
|
||||
|
||||
export interface SecurityAlertEntityConfig {
|
||||
entity: string;
|
||||
severity?: SecurityAlertSeverity;
|
||||
}
|
||||
|
||||
export interface SecurityFrontendSystemData {
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
}
|
||||
|
||||
export interface EnergyFrontendSystemData {
|
||||
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
|
||||
// hidden. An absent key or array means nothing is hidden (all cards visible),
|
||||
@@ -51,6 +63,7 @@ declare global {
|
||||
core: CoreFrontendSystemData;
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,9 +62,11 @@ import type {
|
||||
AutomationClipboard,
|
||||
Condition,
|
||||
} from "../../../../data/automation";
|
||||
import type { ConditionDescriptions } from "../../../../data/condition";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
|
||||
import { validateConfig } from "../../../../data/config";
|
||||
import {
|
||||
conditionDescriptionsContext,
|
||||
fullEntitiesContext,
|
||||
manifestsContext,
|
||||
} from "../../../../data/context";
|
||||
@@ -90,6 +92,8 @@ import { isMac } from "../../../../util/is_mac";
|
||||
import { showEditorToast } from "../editor-toast";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { overflowStyles, rowStyles } from "../styles";
|
||||
import { getDeviceTarget } from "../target/get_device_target";
|
||||
import { getEntityTarget } from "../target/get_entity_target";
|
||||
import "../target/ha-automation-row-targets";
|
||||
import "./ha-automation-action-editor";
|
||||
import type HaAutomationActionEditor from "./ha-automation-action-editor";
|
||||
@@ -205,6 +209,10 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
private _manifests?: DomainManifestLookup;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions?: ConditionDescriptions;
|
||||
|
||||
@state() private _running = false;
|
||||
|
||||
@state() private _runResult?: {
|
||||
@@ -292,13 +300,19 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
? this._extractTargets(this.action as ServiceAction)
|
||||
: type === "device_id" && (this.action as DeviceAction).device_id
|
||||
? { device_id: (this.action as DeviceAction).device_id }
|
||||
: undefined;
|
||||
: type === "condition"
|
||||
? this._extractConditionTarget(this.action as Condition)
|
||||
: undefined;
|
||||
|
||||
const serviceTargetSpec =
|
||||
type === "service" && action
|
||||
? this.hass.services?.[computeDomain(action)]?.[computeObjectId(action)]
|
||||
type === "condition"
|
||||
? this._conditionDescriptions?.[(this.action as Condition).condition]
|
||||
?.target
|
||||
: undefined;
|
||||
: type === "service" && action
|
||||
? this.hass.services?.[computeDomain(action)]?.[
|
||||
computeObjectId(action)
|
||||
]?.target
|
||||
: undefined;
|
||||
|
||||
const noteTooltipText = truncateWithEllipsis(
|
||||
this.action.note?.trim() || "",
|
||||
@@ -777,6 +791,28 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
return {};
|
||||
}
|
||||
|
||||
private _extractConditionTarget(
|
||||
condition: Condition
|
||||
): HassServiceTarget | undefined {
|
||||
if (typeof condition !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if ("target" in condition && condition.target) {
|
||||
return condition.target;
|
||||
}
|
||||
if (
|
||||
(condition.condition === "state" ||
|
||||
condition.condition === "numeric_state") &&
|
||||
"entity_id" in condition
|
||||
) {
|
||||
return getEntityTarget(condition.entity_id);
|
||||
}
|
||||
if (condition.condition === "device" && "device_id" in condition) {
|
||||
return getDeviceTarget(condition.device_id as string);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _renderTargets = memoizeOne(
|
||||
(
|
||||
target?: HassServiceTarget,
|
||||
|
||||
@@ -226,9 +226,7 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
}
|
||||
<h3 slot="header">
|
||||
${capitalizeFirstLetter(
|
||||
describeCondition(this.condition, this.hass, this._entityReg, {
|
||||
hideEntities: true,
|
||||
})
|
||||
describeCondition(this.condition, this.hass, this._entityReg)
|
||||
)}
|
||||
${
|
||||
target !== undefined || targetRequired
|
||||
|
||||
@@ -68,6 +68,9 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public interactive = false;
|
||||
|
||||
@property({ reflect: true })
|
||||
public size: "s" | "m" = "m";
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
@@ -652,6 +655,23 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:host([size="s"]) {
|
||||
min-height: 24px;
|
||||
}
|
||||
:host([size="s"]) .target {
|
||||
height: 24px;
|
||||
}
|
||||
/* A default 24px icon would fill the whole small chip. */
|
||||
:host([size="s"]) .target ha-icon,
|
||||
:host([size="s"]) .target ha-svg-icon,
|
||||
:host([size="s"]) .target ha-domain-icon,
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
button.target {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -251,9 +251,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
}
|
||||
<h3 slot="header">
|
||||
${capitalizeFirstLetter(
|
||||
describeTrigger(this.trigger, this.hass, this._entityReg, {
|
||||
hideEntities: true,
|
||||
})
|
||||
describeTrigger(this.trigger, this.hass, this._entityReg)
|
||||
)}
|
||||
${
|
||||
target !== undefined || targetRequired
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "../components/home-favorites-editor";
|
||||
import "../../../components/entity/ha-favorites-editor";
|
||||
import "../components/home-shortcuts-editor";
|
||||
import type { EditHomeDialogParams } from "./show-dialog-edit-home";
|
||||
|
||||
@@ -123,14 +123,16 @@ export class DialogEditHome
|
||||
@value-changed=${this._welcomeChanged}
|
||||
></ha-form>
|
||||
|
||||
<home-favorites-editor
|
||||
.hass=${this.hass}
|
||||
<ha-favorites-editor
|
||||
.favorites=${this._state.favorite_entities}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
|
||||
)}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
|
||||
)}
|
||||
@value-changed=${this._favoriteEntitiesChanged}
|
||||
></home-favorites-editor>
|
||||
></ha-favorites-editor>
|
||||
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
@@ -309,7 +311,7 @@ export class DialogEditHome
|
||||
display: block;
|
||||
}
|
||||
|
||||
home-favorites-editor {
|
||||
ha-favorites-editor {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-4);
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { LovelacePanelConfig } from "../../../data/lovelace";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
|
||||
import { fetchConfig, saveConfig } from "../../../data/lovelace/config/types";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../data/lovelace/config/types";
|
||||
import {
|
||||
fetchConfig,
|
||||
isStrategyDashboard,
|
||||
saveConfig,
|
||||
} from "../../../data/lovelace/config/types";
|
||||
import { fetchDashboards } from "../../../data/lovelace/dashboard";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -37,7 +44,15 @@ export const addEntitiesToLovelaceView = async (
|
||||
return;
|
||||
}
|
||||
|
||||
let lovelaceConfig;
|
||||
// A strategy dashboard has no views of its own until the user takes control.
|
||||
const hasViewList = (
|
||||
config: LovelaceRawConfig | undefined
|
||||
): config is LovelaceConfig => !!config && !isStrategyDashboard(config);
|
||||
|
||||
const hasViews = (config: LovelaceRawConfig | undefined) =>
|
||||
hasViewList(config) && !!config.views?.length;
|
||||
|
||||
let lovelaceConfig: LovelaceRawConfig | undefined;
|
||||
let urlPath: string | null = null;
|
||||
if (mainLovelaceMode === "storage") {
|
||||
try {
|
||||
@@ -47,25 +62,36 @@ export const addEntitiesToLovelaceView = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (!lovelaceConfig && storageDashs.length) {
|
||||
// find first dashoard not in generated mode
|
||||
if (!hasViews(lovelaceConfig)) {
|
||||
// Prefer a dashboard that has views to add the card to, but keep the first
|
||||
// usable one as a fallback so the user still gets the dashboard picker.
|
||||
for (const storageDash of storageDashs) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
lovelaceConfig = await fetchConfig(
|
||||
const dashConfig = await fetchConfig(
|
||||
hass.connection,
|
||||
storageDash.url_path,
|
||||
false
|
||||
);
|
||||
urlPath = storageDash.url_path;
|
||||
break;
|
||||
if (!hasViewList(dashConfig)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasViewList(lovelaceConfig)) {
|
||||
lovelaceConfig = dashConfig;
|
||||
urlPath = storageDash.url_path;
|
||||
}
|
||||
if (hasViews(dashConfig)) {
|
||||
lovelaceConfig = dashConfig;
|
||||
urlPath = storageDash.url_path;
|
||||
break;
|
||||
}
|
||||
} catch (_err: any) {
|
||||
// dashboard is in generated mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lovelaceConfig) {
|
||||
if (!hasViewList(lovelaceConfig)) {
|
||||
if (dashboards.length > storageDashs.length) {
|
||||
// all storage dashboards are generated, but we have YAML dashboards just show the YAML config
|
||||
showSuggestCardDialog(element, {
|
||||
@@ -94,7 +120,7 @@ export const addEntitiesToLovelaceView = async (
|
||||
showSuggestCardDialog(element, {
|
||||
cardConfig,
|
||||
sectionConfig,
|
||||
lovelaceConfig: lovelaceConfig!,
|
||||
lovelaceConfig,
|
||||
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
|
||||
try {
|
||||
await saveConfig(hass!, null, newConfig);
|
||||
|
||||
@@ -9,13 +9,14 @@ import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-list";
|
||||
import "../../../../components/ha-radio-list-item";
|
||||
import "../../../../components/ha-select";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-dialog";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import { fetchConfig } from "../../../../data/lovelace/config/types";
|
||||
import { isStrategyView } from "../../../../data/lovelace/config/view";
|
||||
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
|
||||
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
|
||||
import { getDefaultPanelUrlPath } from "../../../../data/panel";
|
||||
import { LOVELACE_PANEL } from "../../../../data/panel";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { SelectViewDialogParams } from "./show-select-view-dialog";
|
||||
@@ -42,11 +43,15 @@ export class HuiDialogSelectView extends LitElement {
|
||||
|
||||
@state() private _selectedViewIdx = 0;
|
||||
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public showDialog(params: SelectViewDialogParams): void {
|
||||
this._config = params.lovelaceConfig;
|
||||
this._urlPath = params.urlPath;
|
||||
this._selectedViewIdx = 0;
|
||||
this._loading = false;
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
if (this._params.allowDashboardChange) {
|
||||
@@ -68,8 +73,6 @@ export class HuiDialogSelectView extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const defaultPanel = getDefaultPanelUrlPath(this.hass);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
@@ -86,19 +89,19 @@ export class HuiDialogSelectView extends LitElement {
|
||||
"ui.panel.lovelace.editor.select_view.dashboard_label"
|
||||
)}
|
||||
.disabled=${!this._dashboards.length}
|
||||
.value=${this._urlPath || defaultPanel}
|
||||
.value=${this._urlPath ?? LOVELACE_PANEL}
|
||||
@selected=${this._dashboardChanged}
|
||||
autofocus
|
||||
.options=${this._dashboards
|
||||
.map((dashboard) => ({
|
||||
value: dashboard.url_path,
|
||||
label: `${dashboard.title}${dashboard.id === "lovelace" ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
|
||||
label: `${dashboard.title}${dashboard.id === LOVELACE_PANEL ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
|
||||
disabled: dashboard.mode !== "storage",
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.value === "lovelace"
|
||||
a.value === LOVELACE_PANEL
|
||||
? -1
|
||||
: b.value === "lovelace"
|
||||
: b.value === LOVELACE_PANEL
|
||||
? 1
|
||||
: a.label.localeCompare(b.label)
|
||||
)}
|
||||
@@ -106,54 +109,7 @@ export class HuiDialogSelectView extends LitElement {
|
||||
</ha-select>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
!this._config || (this._config.views || []).length < 1
|
||||
? html`<ha-alert alert-type="error"
|
||||
>${this.hass.localize(
|
||||
this._config
|
||||
? "ui.panel.lovelace.editor.select_view.no_views"
|
||||
: "ui.panel.lovelace.editor.select_view.no_config"
|
||||
)}</ha-alert
|
||||
>`
|
||||
: this._config.views.length > 1
|
||||
? html`
|
||||
<ha-list>
|
||||
${this._config.views.map((view, idx) => {
|
||||
const isStrategy = isStrategyView(view);
|
||||
|
||||
return html`
|
||||
<ha-radio-list-item
|
||||
.graphic=${
|
||||
this._config?.views.some(({ icon }) => icon)
|
||||
? "icon"
|
||||
: nothing
|
||||
}
|
||||
@click=${this._viewChanged}
|
||||
.value=${idx.toString()}
|
||||
.selected=${this._selectedViewIdx === idx}
|
||||
.disabled=${
|
||||
isStrategy && !this._params?.includeStrategyViews
|
||||
}
|
||||
?autofocus=${
|
||||
idx === 0 && !this._params!.allowDashboardChange
|
||||
}
|
||||
>
|
||||
<span>
|
||||
${view.title}${
|
||||
isStrategy
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
|
||||
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
|
||||
</ha-radio-list-item>
|
||||
`;
|
||||
})}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._renderViews()}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@@ -164,7 +120,7 @@ export class HuiDialogSelectView extends LitElement {
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.disabled=${!this._config || (this._config.views || []).length < 1}
|
||||
.disabled=${!this._selectableConfig}
|
||||
@click=${this._selectView}
|
||||
>
|
||||
${this._params.actionLabel || this.hass!.localize("ui.common.move")}
|
||||
@@ -174,30 +130,97 @@ export class HuiDialogSelectView extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// While a config is loading the views on screen still belong to the
|
||||
// previously selected dashboard, so nothing may be picked from them.
|
||||
private get _selectableConfig(): LovelaceConfig | undefined {
|
||||
return !this._loading && this._config?.views?.length
|
||||
? this._config
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _renderViews() {
|
||||
if (this._loading) {
|
||||
return html`<div class="loading">
|
||||
<ha-spinner size="medium"></ha-spinner>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (!this._selectableConfig) {
|
||||
return html`<ha-alert alert-type="error">
|
||||
${this.hass.localize(
|
||||
this._config
|
||||
? "ui.panel.lovelace.editor.select_view.no_views"
|
||||
: "ui.panel.lovelace.editor.select_view.no_config"
|
||||
)}
|
||||
</ha-alert>`;
|
||||
}
|
||||
|
||||
const views = this._selectableConfig.views;
|
||||
if (views.length < 2) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const hasIcon = views.some(({ icon }) => icon);
|
||||
|
||||
return html`
|
||||
<ha-list>
|
||||
${views.map((view, idx) => {
|
||||
const isStrategy = isStrategyView(view);
|
||||
|
||||
return html`
|
||||
<ha-radio-list-item
|
||||
.graphic=${hasIcon ? "icon" : nothing}
|
||||
@click=${this._viewChanged}
|
||||
.value=${idx.toString()}
|
||||
.selected=${this._selectedViewIdx === idx}
|
||||
.disabled=${isStrategy && !this._params?.includeStrategyViews}
|
||||
?autofocus=${idx === 0 && !this._params!.allowDashboardChange}
|
||||
>
|
||||
<span>
|
||||
${view.title}${
|
||||
isStrategy
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
|
||||
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
|
||||
</ha-radio-list-item>
|
||||
`;
|
||||
})}
|
||||
</ha-list>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _getDashboards() {
|
||||
this._dashboards =
|
||||
this._params!.dashboards || (await fetchDashboards(this.hass));
|
||||
}
|
||||
|
||||
private async _dashboardChanged(ev: ValueChangedEvent<string>) {
|
||||
let urlPath: string | null = ev.detail.value;
|
||||
const urlPath = ev.detail.value === LOVELACE_PANEL ? null : ev.detail.value;
|
||||
if (urlPath === this._urlPath) {
|
||||
return;
|
||||
}
|
||||
if (urlPath === "lovelace") {
|
||||
urlPath = null;
|
||||
}
|
||||
this._urlPath = urlPath;
|
||||
this._selectedViewIdx = 0;
|
||||
this._loading = true;
|
||||
let config: LovelaceConfig | undefined;
|
||||
try {
|
||||
this._config = (await fetchConfig(
|
||||
config = (await fetchConfig(
|
||||
this.hass.connection,
|
||||
urlPath,
|
||||
false
|
||||
)) as LovelaceConfig;
|
||||
} catch (_err: any) {
|
||||
this._config = undefined;
|
||||
config = undefined;
|
||||
}
|
||||
// Responses can resolve out of order.
|
||||
if (urlPath !== this._urlPath) {
|
||||
return;
|
||||
}
|
||||
this._config = config;
|
||||
this._selectedViewIdx = 0;
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
private _viewChanged(e) {
|
||||
@@ -209,10 +232,14 @@ export class HuiDialogSelectView extends LitElement {
|
||||
}
|
||||
|
||||
private _selectView(): void {
|
||||
const config = this._selectableConfig;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "view-selected", { view: this._selectedViewIdx });
|
||||
this._params!.viewSelectedCallback(
|
||||
this._urlPath!,
|
||||
this._config!,
|
||||
config,
|
||||
this._selectedViewIdx
|
||||
);
|
||||
this.closeDialog();
|
||||
@@ -225,6 +252,10 @@ export class HuiDialogSelectView extends LitElement {
|
||||
ha-select {
|
||||
width: 100%;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
mwc-radio-list-item {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
type HASSDomEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import type {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityAlertSeverity,
|
||||
} from "../../../data/frontend";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-control-select-menu";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
|
||||
type SecurityEditorContext = SecurityEntityContext & {
|
||||
language: ContextType<typeof internationalizationContext>["language"];
|
||||
translationMetadata: ContextType<
|
||||
typeof internationalizationContext
|
||||
>["translationMetadata"];
|
||||
};
|
||||
|
||||
@customElement("security-alerts-editor")
|
||||
export class SecurityAlertsEditor extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public alertEntities: SecurityAlertEntityConfig[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
private _entityContext?: SecurityEditorContext;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (
|
||||
changedProps.has("_states") ||
|
||||
changedProps.has("_registries") ||
|
||||
changedProps.has("_i18n")
|
||||
) {
|
||||
this._entityContext = {
|
||||
states: this._states,
|
||||
...this._registries,
|
||||
language: this._i18n.language,
|
||||
translationMetadata: this._i18n.translationMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
|
||||
<div class="alert-list">
|
||||
${repeat(
|
||||
this.alertEntities,
|
||||
(alertEntity) => alertEntity.entity,
|
||||
(alertEntity, index) => this._renderAlertEntity(alertEntity, index)
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<ha-entity-picker
|
||||
add-button
|
||||
.addButtonLabel=${this._i18n.localize(
|
||||
"ui.panel.security.editor.add_alert_entity"
|
||||
)}
|
||||
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
|
||||
.entityFilter=${this._entityFilter}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAlertEntity(
|
||||
alertEntity: SecurityAlertEntityConfig,
|
||||
index: number
|
||||
) {
|
||||
const stateObj = this._states[alertEntity.entity];
|
||||
const { primary, secondary } =
|
||||
stateObj && this._entityContext
|
||||
? computeEntityPickerDisplay(this._entityContext, stateObj)
|
||||
: { primary: alertEntity.entity, secondary: undefined };
|
||||
const severity =
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
|
||||
return html`
|
||||
<div class="alert-row">
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<ha-settings-row slim>
|
||||
<state-badge slot="prefix" .stateObj=${stateObj}></state-badge>
|
||||
<span slot="heading">${primary}</span>
|
||||
${
|
||||
secondary
|
||||
? html`<span slot="description">${secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
<ha-control-select-menu
|
||||
show-arrow
|
||||
hide-label
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.security.editor.severity.label"
|
||||
)}
|
||||
.value=${severity}
|
||||
.options=${(["alert", "warning"] as const).map((option) => ({
|
||||
value: option,
|
||||
label: this._i18n.localize(
|
||||
`ui.panel.security.editor.severity.${option}`
|
||||
),
|
||||
}))}
|
||||
data-index=${index}
|
||||
@wa-select=${this._severityChanged}
|
||||
></ha-control-select-menu>
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this._i18n.localize("ui.common.delete")}
|
||||
data-index=${index}
|
||||
@click=${this._removeClicked}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _changed(next: SecurityAlertEntityConfig[]): void {
|
||||
fireEvent(this, "value-changed", { value: next });
|
||||
}
|
||||
|
||||
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
|
||||
private _getIndex(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement>
|
||||
): number | undefined {
|
||||
const index = Number(ev.currentTarget.dataset.index);
|
||||
return Number.isInteger(index) ? index : undefined;
|
||||
}
|
||||
|
||||
private _removeClicked(ev: HASSDomCurrentTargetEvent<HTMLElement>): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index !== undefined) {
|
||||
const next = [...this.alertEntities];
|
||||
next.splice(index, 1);
|
||||
this._changed(next);
|
||||
}
|
||||
}
|
||||
|
||||
private _severityChanged(
|
||||
ev: HaDropdownSelectEvent<SecurityAlertSeverity> &
|
||||
HASSDomCurrentTargetEvent<HTMLElement>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
const next = [...this.alertEntities];
|
||||
next[index] = { ...next[index], severity: ev.detail.item.value };
|
||||
this._changed(next);
|
||||
}
|
||||
|
||||
private _add(
|
||||
ev: ValueChangedEvent<string | undefined> &
|
||||
HASSDomCurrentTargetEvent<HaEntityPicker>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
const entity = ev.detail.value;
|
||||
if (!entity) return;
|
||||
|
||||
ev.currentTarget.value = "";
|
||||
|
||||
if (
|
||||
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._changed([...this.alertEntities, { entity }]);
|
||||
}
|
||||
|
||||
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const next = [...this.alertEntities];
|
||||
const [moved] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
this._changed(next);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.alert-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.alert-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.handle {
|
||||
cursor: grab;
|
||||
color: var(--secondary-text-color);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
ha-settings-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
gap: var(--ha-space-3);
|
||||
min-height: 48px;
|
||||
--settings-row-prefix-display: contents;
|
||||
--settings-row-content-display: contents;
|
||||
--settings-row-body-padding-top: var(--ha-space-1);
|
||||
--settings-row-body-padding-bottom: var(--ha-space-1);
|
||||
}
|
||||
state-badge {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
--state-icon-color: var(--secondary-text-color);
|
||||
}
|
||||
[slot="heading"],
|
||||
[slot="description"] {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
padding-top: var(--ha-space-3);
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
ha-control-select-menu {
|
||||
width: 118px;
|
||||
--control-select-menu-height: 42px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"security-alerts-editor": SecurityAlertsEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-expansion-panel";
|
||||
import "../../../components/ha-icon";
|
||||
import type { SecurityFrontendSystemData } from "../../../data/frontend";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import { DialogMixin } from "../../../dialogs/dialog-mixin";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
import "../../../components/entity/ha-favorites-editor";
|
||||
import "../components/security-alerts-editor";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
|
||||
|
||||
@customElement("dialog-edit-security")
|
||||
export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(
|
||||
DialogMixin<EditSecurityDialogParams>(LitElement)
|
||||
) {
|
||||
@state() private _state?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
private _entityContext?: SecurityEntityContext;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("_states") || changedProps.has("_registries")) {
|
||||
this._entityContext = {
|
||||
states: this._states,
|
||||
...this._registries,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!this.params) {
|
||||
return;
|
||||
}
|
||||
this._state = {
|
||||
...this.params.config,
|
||||
favorite_entities: this.params.config.favorite_entities
|
||||
? [...this.params.config.favorite_entities]
|
||||
: [],
|
||||
alert_entities: this.params.config.alert_entities
|
||||
? [...this.params.config.alert_entities]
|
||||
: [],
|
||||
};
|
||||
this._initDirtyTracking({ type: "deep" }, this._state);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params || !this._state) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
width="medium"
|
||||
.headerTitle=${this._i18n.localize("ui.panel.security.editor.title")}
|
||||
.headerSubtitle=${this._i18n.localize(
|
||||
"ui.panel.security.editor.description"
|
||||
)}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
>
|
||||
${this._renderMainEditor()}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
autofocus
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this._i18n.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._submitting || !this.isDirtyState}
|
||||
>
|
||||
${this._i18n.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMainEditor() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
no-collapse
|
||||
.header=${this._i18n.localize(
|
||||
"ui.panel.security.editor.favorite_entities"
|
||||
)}
|
||||
.secondary=${this._i18n.localize(
|
||||
"ui.panel.security.editor.favorite_entities_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<ha-favorites-editor
|
||||
.favorites=${this._state?.favorite_entities ?? []}
|
||||
.entityFilter=${this._entityFilter}
|
||||
.addButtonLabel=${this._i18n.localize(
|
||||
"ui.panel.security.editor.add_favorite_entity"
|
||||
)}
|
||||
@value-changed=${this._favoriteEntitiesChanged}
|
||||
></ha-favorites-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
no-collapse
|
||||
.header=${this._i18n.localize(
|
||||
"ui.panel.security.editor.active_alert_entities"
|
||||
)}
|
||||
.secondary=${this._i18n.localize(
|
||||
"ui.panel.security.editor.active_alert_entities_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:shield-alert-outline"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<security-alerts-editor
|
||||
.alertEntities=${this._state?.alert_entities ?? []}
|
||||
@value-changed=${this._alertEntitiesChanged}
|
||||
></security-alerts-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _alertEntitiesChanged(
|
||||
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state,
|
||||
alert_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _favoriteEntitiesChanged(
|
||||
ev: ValueChangedEvent<SecurityFrontendSystemData["favorite_entities"]>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state,
|
||||
favorite_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this.params || !this._state) return;
|
||||
|
||||
this._submitting = true;
|
||||
|
||||
try {
|
||||
await this.params.saveConfig({
|
||||
...this.params.config,
|
||||
favorite_entities: this._state.favorite_entities?.length
|
||||
? this._state.favorite_entities
|
||||
: undefined,
|
||||
alert_entities: this._state.alert_entities?.length
|
||||
? this._state.alert_entities
|
||||
: undefined,
|
||||
});
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
display: block;
|
||||
--expansion-panel-content-padding: 0;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
--ha-card-border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
ha-expansion-panel + ha-expansion-panel {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.expansion-content {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-edit-security": DialogEditSecurity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { SecurityFrontendSystemData } from "../../../data/frontend";
|
||||
|
||||
export interface EditSecurityDialogParams {
|
||||
config: SecurityFrontendSystemData;
|
||||
saveConfig: (config: SecurityFrontendSystemData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const loadEditSecurityDialog = () => import("./dialog-edit-security");
|
||||
|
||||
export const showEditSecurityDialog = (
|
||||
element: HTMLElement,
|
||||
params: EditSecurityDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-edit-security",
|
||||
dialogImport: loadEditSecurityDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
@@ -1,15 +1,24 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
type SecurityFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import { showEditSecurityDialog } from "./dialogs/show-dialog-edit-security";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import "../lovelace/views/hui-view-background";
|
||||
@@ -30,10 +39,16 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _loadConfigRevision = 0;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -47,13 +62,22 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (
|
||||
oldHass &&
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
// Refresh the generated view when registries or panels change.
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
@@ -63,22 +87,44 @@ class PanelSecurity extends LitElement {
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If ha started, refresh the config
|
||||
if (
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
this._setLovelace();
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
this._loadConfigPromise,
|
||||
]);
|
||||
await this._setLovelace();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
const revision = ++this._loadConfigRevision;
|
||||
this._config = undefined;
|
||||
try {
|
||||
const data = await fetchFrontendSystemData(
|
||||
this.hass.connection,
|
||||
"security"
|
||||
);
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load security configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.security.editor.load_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
@@ -97,6 +143,16 @@ class PanelSecurity extends LitElement {
|
||||
.backButton=${this._searchParms.has("historyBack")}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.security")}</div>
|
||||
${
|
||||
this.hass.user?.is_admin && this._config
|
||||
? html`<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.security.editor.title")}
|
||||
@click=${this._editSecurity}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._lovelace
|
||||
? html`
|
||||
@@ -118,8 +174,18 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
SECURITY_LOVELACE_VIEW_CONFIG,
|
||||
{
|
||||
strategy: {
|
||||
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
|
||||
alert_entities: this._config?.alert_entities,
|
||||
favorite_entities: this._config?.favorite_entities,
|
||||
},
|
||||
},
|
||||
this.hass
|
||||
);
|
||||
|
||||
@@ -146,6 +212,38 @@ class PanelSecurity extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _editSecurity = () => {
|
||||
if (!this.hass.user?.is_admin || !this._config) {
|
||||
return;
|
||||
}
|
||||
showEditSecurityDialog(this, {
|
||||
config: this._config,
|
||||
saveConfig: async (config) => {
|
||||
await this._saveConfig(config);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
|
||||
try {
|
||||
await saveFrontendSystemData(this.hass.connection, "security", config);
|
||||
this._config = config;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save security configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.security.editor.save_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.successfully_saved"),
|
||||
});
|
||||
await this._setLovelace();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { registriesContext, statesContext } from "../../data/context";
|
||||
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
|
||||
|
||||
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
|
||||
states: ContextType<typeof statesContext>;
|
||||
};
|
||||
|
||||
export const createSecurityEntityFilter =
|
||||
(getContext: () => SecurityEntityContext | undefined) =>
|
||||
(entity: HassEntity): boolean => {
|
||||
const context = getContext();
|
||||
return context ? isSecurityPanelEntity(context, entity) : false;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityAlertSeverity,
|
||||
} from "../../../data/frontend";
|
||||
import type { StateCondition } from "../../lovelace/common/validate-condition";
|
||||
import type { AlertCardConfig } from "../../lovelace/cards/types";
|
||||
|
||||
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
|
||||
"carbon_monoxide",
|
||||
"gas",
|
||||
"moisture",
|
||||
"safety",
|
||||
"smoke",
|
||||
] as const;
|
||||
|
||||
type DangerBinarySensorDeviceClass =
|
||||
(typeof DANGER_BINARY_SENSOR_DEVICE_CLASSES)[number];
|
||||
|
||||
const DANGER_BINARY_SENSOR_DEVICE_CLASS_SET =
|
||||
new Set<DangerBinarySensorDeviceClass>(DANGER_BINARY_SENSOR_DEVICE_CLASSES);
|
||||
|
||||
const isDangerBinarySensorDeviceClass = (
|
||||
deviceClass: string
|
||||
): deviceClass is DangerBinarySensorDeviceClass =>
|
||||
DANGER_BINARY_SENSOR_DEVICE_CLASS_SET.has(
|
||||
deviceClass as DangerBinarySensorDeviceClass
|
||||
);
|
||||
|
||||
export const computeDefaultSecurityAlertSeverity = (
|
||||
stateObj?: HassEntity
|
||||
): SecurityAlertSeverity => {
|
||||
if (!stateObj) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
if (domain === "alarm_control_panel") {
|
||||
return "alert";
|
||||
}
|
||||
if (domain === "binary_sensor") {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
return typeof deviceClass === "string" &&
|
||||
isDangerBinarySensorDeviceClass(deviceClass)
|
||||
? "alert"
|
||||
: "warning";
|
||||
}
|
||||
return "warning";
|
||||
};
|
||||
|
||||
export const computeDefaultSecurityAlertVisibility = (
|
||||
entityId: string
|
||||
): StateCondition[] => {
|
||||
const condition: StateCondition = {
|
||||
condition: "state",
|
||||
entity: entityId,
|
||||
};
|
||||
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
condition.state = "triggered";
|
||||
break;
|
||||
case "cover":
|
||||
condition.state = "open";
|
||||
break;
|
||||
case "lock":
|
||||
condition.state = ["jammed", "unlocked", "open"];
|
||||
break;
|
||||
default:
|
||||
condition.state = "on";
|
||||
}
|
||||
|
||||
return [condition];
|
||||
};
|
||||
|
||||
export const computeSecurityAlertCardConfig = (
|
||||
stateObj: HassEntity | undefined,
|
||||
alertEntity: SecurityAlertEntityConfig
|
||||
): AlertCardConfig => {
|
||||
const severity =
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
return {
|
||||
type: "alert",
|
||||
entity: alertEntity.entity,
|
||||
color: severity === "alert" ? "red" : "amber",
|
||||
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
@@ -15,12 +16,19 @@ import type {
|
||||
LovelaceSectionRawConfig,
|
||||
} from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LogbookCardConfig } from "../../lovelace/cards/types";
|
||||
import type {
|
||||
LogbookCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../lovelace/cards/types";
|
||||
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
import { computeSecurityAlertCardConfig } from "./security-alerts";
|
||||
|
||||
export interface SecurityViewStrategyConfig {
|
||||
type: "security";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
}
|
||||
|
||||
export const securityEntityFilters: EntityFilter[] = [
|
||||
@@ -69,6 +77,17 @@ export const securityEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const isSecurityPanelEntity = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): boolean =>
|
||||
securityEntityFilters.some((filter) =>
|
||||
generateEntityFilter(hass, filter)(stateObj.entity_id)
|
||||
);
|
||||
|
||||
const processAreasForSecurity = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
@@ -132,7 +151,7 @@ const processUnassignedEntities = (
|
||||
@customElement("security-view-strategy")
|
||||
export class SecurityViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
_config: SecurityViewStrategyConfig,
|
||||
config: SecurityViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const areas = Object.values(hass.areas);
|
||||
@@ -149,6 +168,37 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const entities = findEntities(allEntities, securityFilters);
|
||||
|
||||
const favoriteEntities = (config.favorite_entities ?? []).filter(
|
||||
(entityId) =>
|
||||
hass.states[entityId] &&
|
||||
isSecurityPanelEntity(hass, hass.states[entityId])
|
||||
);
|
||||
|
||||
if (favoriteEntities.length > 0) {
|
||||
sections.push({
|
||||
type: "grid",
|
||||
column_span: 2,
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.favorites"
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...favoriteEntities.map(
|
||||
(entityId) =>
|
||||
({
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
}) satisfies TileCardConfig
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
|
||||
@@ -242,37 +292,87 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const logbookEntityIds = [...entities, ...personEntities];
|
||||
|
||||
const sidebarSection: LovelaceSectionConfig | undefined =
|
||||
hasLogbook && logbookEntityIds.length > 0
|
||||
? {
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.activity"
|
||||
),
|
||||
heading_style: "title",
|
||||
} as LovelaceCardConfig,
|
||||
{
|
||||
type: "logbook",
|
||||
target: {
|
||||
entity_id: logbookEntityIds,
|
||||
},
|
||||
hours_to_show: 24,
|
||||
grid_options: { columns: 12 },
|
||||
} satisfies LogbookCardConfig,
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
const sidebarSections: LovelaceSectionConfig[] = [];
|
||||
|
||||
const alertCards = config.alert_entities?.map((alertEntity) =>
|
||||
computeSecurityAlertCardConfig(
|
||||
hass.states[alertEntity.entity],
|
||||
alertEntity
|
||||
)
|
||||
);
|
||||
|
||||
if (alertCards?.length) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
visibility: [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: alertCards.map((alertCard) => ({
|
||||
condition: "and",
|
||||
conditions: alertCard.visibility!,
|
||||
})),
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.active_alerts"
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...alertCards.map((alertCard) => ({
|
||||
...alertCard,
|
||||
grid_options: { columns: 12 },
|
||||
})),
|
||||
] satisfies LovelaceCardConfig[],
|
||||
});
|
||||
}
|
||||
|
||||
const hasLogbookSection = hasLogbook && logbookEntityIds.length > 0;
|
||||
if (hasLogbookSection) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.activity"
|
||||
),
|
||||
heading_style: "title",
|
||||
} as LovelaceCardConfig,
|
||||
{
|
||||
type: "logbook",
|
||||
target: {
|
||||
entity_id: logbookEntityIds,
|
||||
},
|
||||
hours_to_show: 24,
|
||||
grid_options: { columns: 12 },
|
||||
} satisfies LogbookCardConfig,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "sections",
|
||||
max_columns: 3,
|
||||
sections: sections,
|
||||
...(sidebarSection && {
|
||||
...(sidebarSections.length > 0 && {
|
||||
sidebar: {
|
||||
sections: [sidebarSection],
|
||||
sections: sidebarSections,
|
||||
...(!hasLogbookSection && alertCards?.length
|
||||
? {
|
||||
visibility: [
|
||||
{
|
||||
condition: "or" as const,
|
||||
conditions: alertCards.map((alertCard) => ({
|
||||
condition: "and" as const,
|
||||
conditions: alertCard.visibility!,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
content_label: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.devices"
|
||||
),
|
||||
|
||||
+22
-10
@@ -2640,6 +2640,25 @@
|
||||
"learn_more": "Learn more"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"editor": {
|
||||
"title": "Edit security and safety page",
|
||||
"description": "Configure your security and safety display preferences.",
|
||||
"favorite_entities": "[%key:ui::panel::lovelace::editor::strategy::home::favorite_entities%]",
|
||||
"favorite_entities_description": "Pin entities to the top of the page.",
|
||||
"add_favorite_entity": "[%key:ui::panel::lovelace::editor::strategy::home::add_favorite_entity%]",
|
||||
"active_alert_entities": "Active alert entities",
|
||||
"active_alert_entities_description": "Display any entities that require attention.",
|
||||
"add_alert_entity": "Add entity",
|
||||
"severity": {
|
||||
"label": "Severity",
|
||||
"alert": "Alert",
|
||||
"warning": "Warning"
|
||||
},
|
||||
"load_failed": "Failed to load security and safety page configuration",
|
||||
"save_failed": "Failed to save security and safety page configuration"
|
||||
}
|
||||
},
|
||||
"my": {
|
||||
"not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.",
|
||||
"component_not_loaded": "This redirect is not supported by your Home Assistant instance. You need the integration {integration} to use this redirect.",
|
||||
@@ -5386,7 +5405,6 @@
|
||||
"any_state_ignore_attributes": "Any state (ignoring attribute changes)",
|
||||
"description": {
|
||||
"picker": "Triggers when the state of an entity (or attribute) changes.",
|
||||
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}",
|
||||
"changed": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n}{anyChange, select, \n true { or any attribute} \n other {}\n} changed{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
|
||||
},
|
||||
"for_type": {
|
||||
@@ -5427,9 +5445,6 @@
|
||||
"type_input": "Value of an entity",
|
||||
"description": {
|
||||
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
|
||||
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"crossed_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above}{duration, select, \n undefined {} \n other { for {duration}}\n}",
|
||||
"crossed_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}",
|
||||
"crossed_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}"
|
||||
@@ -5684,9 +5699,6 @@
|
||||
"value_template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::value_template%]",
|
||||
"description": {
|
||||
"picker": "Tests if the numeric value of an entity's state (or attribute's value) is above or below a given threshold.",
|
||||
"above": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}",
|
||||
"below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}",
|
||||
"above-below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}",
|
||||
"is_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above}",
|
||||
"is_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is below {below}",
|
||||
"is_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above} and below {below}"
|
||||
@@ -5705,8 +5717,6 @@
|
||||
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
|
||||
"description": {
|
||||
"picker": "Tests if an entity (or attribute) is in a specific state.",
|
||||
"no_entity": "If state confirmed",
|
||||
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }",
|
||||
"is": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n} is {states}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
|
||||
}
|
||||
},
|
||||
@@ -6036,7 +6046,7 @@
|
||||
},
|
||||
"check_condition": {
|
||||
"description": {
|
||||
"full": "Test {condition}"
|
||||
"full": "Test: {condition}"
|
||||
}
|
||||
},
|
||||
"set_conversation_response": {
|
||||
@@ -9161,6 +9171,8 @@
|
||||
"security": {
|
||||
"devices": "Devices",
|
||||
"other_devices": "Other devices",
|
||||
"active_alerts": "Active alerts",
|
||||
"favorites": "[%key:ui::panel::lovelace::strategy::home::favorites%]",
|
||||
"activity": "Activity"
|
||||
},
|
||||
"climate": {
|
||||
|
||||
@@ -71,30 +71,6 @@ describe("ha-control-slider display rounding", () => {
|
||||
// stepped percentage such as 29 snaps to 26 * step = 28.5714…
|
||||
const FAN_STEP = 100 / 91;
|
||||
|
||||
const ariaValueNow = (el: HaControlSlider) =>
|
||||
el
|
||||
.shadowRoot!.querySelector('[role="slider"]')!
|
||||
.getAttribute("aria-valuenow");
|
||||
|
||||
const tooltipText = (el: HaControlSlider) =>
|
||||
el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim();
|
||||
|
||||
it("shows the fractional stepped value by default", async () => {
|
||||
const el = await mountSlider({ step: FAN_STEP, value: 29 });
|
||||
expect(tooltipText(el)).toBe("28.57");
|
||||
expect(ariaValueNow(el)).toBe(el.steppedValue(29).toString());
|
||||
});
|
||||
|
||||
it("rounds the displayed value to an integer when round-value is set", async () => {
|
||||
const el = await mountSlider({
|
||||
step: FAN_STEP,
|
||||
value: 29,
|
||||
roundValue: true,
|
||||
});
|
||||
expect(tooltipText(el)).toBe("29");
|
||||
expect(ariaValueNow(el)).toBe("29");
|
||||
});
|
||||
|
||||
it("still snaps to the real step grid when rounding the display", async () => {
|
||||
const el = await mountSlider({
|
||||
step: FAN_STEP,
|
||||
@@ -105,13 +81,6 @@ describe("ha-control-slider display rounding", () => {
|
||||
// the number of speed steps (and keyboard granularity) is preserved.
|
||||
expect(el.steppedValue(29)).toBeCloseTo(28.5714, 3);
|
||||
});
|
||||
|
||||
it("keeps decimal steps intact unless round-value is set", async () => {
|
||||
// A temperature-style slider must keep showing halves.
|
||||
const el = await mountSlider({ step: 0.5, value: 21.5 });
|
||||
expect(tooltipText(el)).toBe("21.5");
|
||||
expect(ariaValueNow(el)).toBe("21.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ha-control-slider step bounds", () => {
|
||||
@@ -157,22 +126,6 @@ describe("ha-control-slider step bounds", () => {
|
||||
expect(el.steppedValue(el.percentageToValue(1))).toBe(100);
|
||||
});
|
||||
|
||||
it("shows and announces the bound, not the overshoot", async () => {
|
||||
const el = await mountSlider({
|
||||
...RANGE,
|
||||
value: 99,
|
||||
tooltipMode: "always",
|
||||
});
|
||||
expect(el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim()).toBe(
|
||||
"99"
|
||||
);
|
||||
expect(
|
||||
el
|
||||
.shadowRoot!.querySelector('[role="slider"]')!
|
||||
.getAttribute("aria-valuenow")
|
||||
).toBe("99");
|
||||
});
|
||||
|
||||
it("keeps paging inside the bounds", async () => {
|
||||
const el = await mountSlider({ ...RANGE, value: 91 });
|
||||
const values = changedValues(el);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { html, nothing, render } from "lit";
|
||||
import "../../src/components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableRowData,
|
||||
HaDataTable,
|
||||
} from "../../src/components/data-table/ha-data-table";
|
||||
|
||||
const columns: DataTableColumnContainer = {
|
||||
name: { title: "Name", main: true },
|
||||
area: { title: "Area" },
|
||||
category: { title: "Category" },
|
||||
empty_template: { title: "Empty", template: () => nothing },
|
||||
filled_template: { title: "Filled", template: () => html`filled` },
|
||||
};
|
||||
|
||||
// The narrow row puts every non-main column on a secondary line, joined by dots.
|
||||
const renderNarrowSecondary = (row: DataTableRowData) => {
|
||||
const el = document.createElement("ha-data-table") as HaDataTable;
|
||||
const container = document.createElement("div");
|
||||
render((el as any)._renderRow(columns, true, row, 0), container);
|
||||
return container.querySelector(".secondary")!.textContent!.trim();
|
||||
};
|
||||
|
||||
describe("ha-data-table narrow secondary line", () => {
|
||||
it("does not render separators for empty columns", () => {
|
||||
expect(renderNarrowSecondary({ id: "1", name: "Test" })).toBe("filled");
|
||||
});
|
||||
|
||||
it("separates only the columns that have a value", () => {
|
||||
expect(
|
||||
renderNarrowSecondary({ id: "1", name: "Test", area: "Kitchen" })
|
||||
).toBe("Kitchen · filled");
|
||||
});
|
||||
|
||||
it("renders a blank secondary line when all secondary columns are empty", () => {
|
||||
const emptyColumns: DataTableColumnContainer = {
|
||||
name: { title: "Name", main: true },
|
||||
area: { title: "Area" },
|
||||
category: { title: "Category" },
|
||||
empty_template: { title: "Empty", template: () => nothing },
|
||||
};
|
||||
const el = document.createElement("ha-data-table") as HaDataTable;
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
(el as any)._renderRow(emptyColumns, true, { id: "1", name: "Test" }, 0),
|
||||
container
|
||||
);
|
||||
expect(container.querySelector(".secondary")!.textContent!.trim()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -35,102 +35,6 @@ const showToast = async (element: HaToast) => {
|
||||
};
|
||||
|
||||
describe("ha-toast", () => {
|
||||
it("renders its message and bottom offset", async () => {
|
||||
const element = await mountToast({
|
||||
labelText: "Configuration saved",
|
||||
bottomOffset: 24,
|
||||
});
|
||||
|
||||
expect(element.shadowRoot?.querySelector(".message")?.textContent).toBe(
|
||||
"Configuration saved"
|
||||
);
|
||||
expect(
|
||||
(
|
||||
element.shadowRoot?.querySelector(".toast") as HTMLElement
|
||||
).style.getPropertyValue("--ha-toast-bottom-offset")
|
||||
).toBe("24px");
|
||||
});
|
||||
|
||||
it("renders assigned action and dismiss content", async () => {
|
||||
toast = document.createElement("ha-toast");
|
||||
const action = document.createElement("button");
|
||||
action.slot = "action";
|
||||
const dismiss = document.createElement("button");
|
||||
dismiss.slot = "dismiss";
|
||||
toast.append(action, dismiss);
|
||||
document.body.append(toast);
|
||||
await toast.updateComplete;
|
||||
toast.requestUpdate();
|
||||
await toast.updateComplete;
|
||||
|
||||
expect(
|
||||
toast.shadowRoot
|
||||
?.querySelector<HTMLSlotElement>('slot[name="action"]')
|
||||
?.assignedElements()
|
||||
).toEqual([action]);
|
||||
expect(
|
||||
toast.shadowRoot
|
||||
?.querySelector<HTMLSlotElement>('slot[name="dismiss"]')
|
||||
?.assignedElements()
|
||||
).toEqual([dismiss]);
|
||||
expect(
|
||||
toast.shadowRoot
|
||||
?.querySelector(".actions")
|
||||
?.classList.contains("has-action")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps changing visual text separate from live-region text", async () => {
|
||||
const element = await mountToast({
|
||||
labelText: "Updating in 59 seconds",
|
||||
announceText: "Updating in 60 seconds",
|
||||
});
|
||||
|
||||
const visibleMessage = element.shadowRoot!.querySelector(".message")!;
|
||||
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
|
||||
expect(visibleMessage.textContent).toBe("Updating in 59 seconds");
|
||||
expect(visibleMessage.closest('[role="status"]')).toBeNull();
|
||||
expect(liveRegion.textContent?.trim()).toBe("Updating in 60 seconds");
|
||||
expect(liveRegion.getAttribute("role")).toBe("status");
|
||||
expect(liveRegion.getAttribute("aria-atomic")).toBe("true");
|
||||
});
|
||||
|
||||
it("falls back to announcing the visible message", async () => {
|
||||
const element = await mountToast({ labelText: "Configuration saved" });
|
||||
|
||||
expect(
|
||||
element
|
||||
.shadowRoot!.querySelector(".assistive-message")!
|
||||
.textContent?.trim()
|
||||
).toBe("Configuration saved");
|
||||
});
|
||||
|
||||
it("activates its live region while shown", async () => {
|
||||
const element = await mountToast();
|
||||
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
|
||||
expect(liveRegion.getAttribute("aria-live")).toBe("off");
|
||||
|
||||
await showToast(element);
|
||||
|
||||
expect(liveRegion.getAttribute("aria-live")).toBe("polite");
|
||||
expect(
|
||||
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows as part of a stack without becoming a popover", async () => {
|
||||
const element = await mountToast({ stacked: true });
|
||||
|
||||
await showToast(element);
|
||||
|
||||
expect(
|
||||
element.shadowRoot!.querySelector(".toast")!.hasAttribute("popover")
|
||||
).toBe(false);
|
||||
expect(
|
||||
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["action", "dismiss", "programmatic"] as const)(
|
||||
"reports a %s close reason",
|
||||
async (reason) => {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { IntlMessageFormat } from "intl-messageformat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "../../src/translations/en.json";
|
||||
|
||||
// The state condition summary string. Its verb must agree with how the entity
|
||||
// list is joined: "and" (match "all") takes a plural verb, "or" (match "any")
|
||||
// takes a singular verb in English.
|
||||
const message = (en as any).ui.panel.config.automation.editor.conditions.type
|
||||
.state.description.full;
|
||||
|
||||
const format = (values: Record<string, unknown>) =>
|
||||
new IntlMessageFormat(message, "en").format(values) as string;
|
||||
|
||||
describe("state condition summary grammar", () => {
|
||||
it("uses a singular verb for a single entity", () => {
|
||||
expect(
|
||||
format({
|
||||
hasAttribute: "false",
|
||||
numberOfEntities: 1,
|
||||
matchAny: "false",
|
||||
entities: "Light",
|
||||
numberOfStates: 1,
|
||||
states: "on",
|
||||
hasDuration: "false",
|
||||
})
|
||||
).toBe("If Light is on");
|
||||
});
|
||||
|
||||
it("uses a plural verb for multiple entities matched with all (and)", () => {
|
||||
expect(
|
||||
format({
|
||||
hasAttribute: "false",
|
||||
numberOfEntities: 2,
|
||||
matchAny: "false",
|
||||
entities: "A and B",
|
||||
numberOfStates: 1,
|
||||
states: "on",
|
||||
hasDuration: "false",
|
||||
})
|
||||
).toBe("If A and B are on");
|
||||
});
|
||||
|
||||
it("uses a singular verb for multiple entities matched with any (or)", () => {
|
||||
expect(
|
||||
format({
|
||||
hasAttribute: "false",
|
||||
numberOfEntities: 2,
|
||||
matchAny: "true",
|
||||
entities: "A or B",
|
||||
numberOfStates: 1,
|
||||
states: "on",
|
||||
hasDuration: "false",
|
||||
})
|
||||
).toBe("If A or B is on");
|
||||
});
|
||||
});
|
||||
+4
-22
@@ -58,10 +58,10 @@ const hass = {
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const describeRowTrigger = (trigger: Trigger) =>
|
||||
describeTrigger(trigger, hass, [], { hideEntities: true });
|
||||
describeTrigger(trigger, hass, []);
|
||||
|
||||
const describeRowCondition = (condition: Condition) =>
|
||||
describeCondition(condition, hass, [], { hideEntities: true });
|
||||
describeCondition(condition, hass, []);
|
||||
|
||||
describe("describing state triggers and conditions", () => {
|
||||
const trigger: Trigger = {
|
||||
@@ -75,16 +75,7 @@ describe("describing state triggers and conditions", () => {
|
||||
state: "on",
|
||||
};
|
||||
|
||||
it("names the entities by default", () => {
|
||||
expect(describeTrigger(trigger, hass, [])).toBe(
|
||||
"When Kitchen light changes to on"
|
||||
);
|
||||
expect(describeCondition(condition, hass, [])).toBe(
|
||||
"If Kitchen light is on"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the entities out when they are rendered as targets", () => {
|
||||
it("leaves the entities out, they are rendered as targets", () => {
|
||||
expect(describeRowTrigger(trigger)).toBe("State changed to on");
|
||||
expect(describeRowCondition(condition)).toBe("State is on");
|
||||
});
|
||||
@@ -115,16 +106,7 @@ describe("describing numeric state triggers and conditions", () => {
|
||||
above: 20,
|
||||
};
|
||||
|
||||
it("names the entities by default", () => {
|
||||
expect(describeTrigger(trigger, hass, [])).toBe(
|
||||
"When Temperature is above 20"
|
||||
);
|
||||
expect(describeCondition(condition, hass, [])).toBe(
|
||||
"If Temperature is above 20"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the entities out when they are rendered as targets", () => {
|
||||
it("leaves the entities out, they are rendered as targets", () => {
|
||||
expect(describeRowTrigger(trigger)).toBe("Numeric state crossed above 20");
|
||||
expect(describeRowCondition(condition)).toBe("Numeric state is above 20");
|
||||
});
|
||||
@@ -153,20 +153,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("dialog-form mounted nested forms", () => {
|
||||
it("keeps parent forms mounted while nested", async () => {
|
||||
const dialog = await openDialog();
|
||||
const parent = getForms(dialog)[0];
|
||||
|
||||
await showNestedDialog(dialog, parent, nestedParams());
|
||||
|
||||
const forms = getForms(dialog);
|
||||
expect(forms).toHaveLength(2);
|
||||
expect(forms[0].hidden).toBe(true);
|
||||
expect(forms[1].hidden).toBe(false);
|
||||
expect(forms[0].hasAttribute("autofocus")).toBe(false);
|
||||
expect(forms[1].hasAttribute("autofocus")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns to the parent after nested submit", async () => {
|
||||
const dialog = await openDialog();
|
||||
const nested = nestedParams({ value: "nested" });
|
||||
|
||||
@@ -7,19 +7,7 @@ vi.mock("../../src/components/ha-button", () => ({}));
|
||||
customElements.define("ha-button", class extends LitElement {});
|
||||
await import("../../src/layouts/ha-init-page");
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
"ui.init.loading": "Loading translated data",
|
||||
"ui.init.migration": "Database migration translated\n\nPlease wait",
|
||||
"ui.init.error.title": "Connection error translated",
|
||||
"ui.init.error.retry_now": "Retry translated",
|
||||
};
|
||||
|
||||
const localize: LocalizeFunc = (key, values) => {
|
||||
if (key === "ui.init.error.retrying") {
|
||||
return `Retry translated ${values?.seconds}`;
|
||||
}
|
||||
return translations[key] ?? "";
|
||||
};
|
||||
const localize: LocalizeFunc = (key) => key;
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
let element: HaInitPage | undefined;
|
||||
@@ -42,49 +30,15 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ha-init-page", () => {
|
||||
it("renders localized loading and migration states", async () => {
|
||||
const initPage = await mount();
|
||||
expect(initPage.shadowRoot!.textContent).toContain(
|
||||
"Loading translated data"
|
||||
);
|
||||
|
||||
initPage.migration = true;
|
||||
await initPage.updateComplete;
|
||||
expect(
|
||||
initPage.shadowRoot!.querySelector(".migration-text")!.textContent
|
||||
).toBe("Database migration translated\n\nPlease wait");
|
||||
});
|
||||
|
||||
it("preserves migration paragraph breaks without localization", async () => {
|
||||
const initPage = await mount({ localize: undefined, migration: true });
|
||||
|
||||
expect(
|
||||
initPage.shadowRoot!.querySelector(".migration-text")!.textContent
|
||||
).toContain("completed.\n\nThe upgrade");
|
||||
});
|
||||
|
||||
it("renders the localized connection error and countdown", async () => {
|
||||
const initPage = await mount({ error: true });
|
||||
|
||||
expect(initPage.shadowRoot!.textContent).toContain(
|
||||
"Connection error translated"
|
||||
);
|
||||
expect(initPage.shadowRoot!.textContent).toContain("Retry translated 60");
|
||||
expect(
|
||||
initPage.shadowRoot!.querySelector("ha-button")!.textContent
|
||||
).toContain("Retry translated");
|
||||
});
|
||||
|
||||
it("counts down once per second and stops after disconnecting", async () => {
|
||||
vi.useFakeTimers();
|
||||
const initPage = await mount({ error: true });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await initPage.updateComplete;
|
||||
expect(initPage.shadowRoot!.textContent).toContain("Retry translated 59");
|
||||
expect((initPage as any)._retryInSeconds).toBe(59);
|
||||
|
||||
initPage.remove();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(initPage.shadowRoot!.textContent).toContain("Retry translated 59");
|
||||
expect((initPage as any)._retryInSeconds).toBe(59);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,16 +75,6 @@ describe("notification toast lifecycle", () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await firstShow;
|
||||
|
||||
expect(toast.shadowRoot!.querySelector(".toast")!.classList).toContain(
|
||||
"visible"
|
||||
);
|
||||
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
|
||||
'"seconds":60'
|
||||
);
|
||||
expect(
|
||||
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
|
||||
).toContain('"seconds":60');
|
||||
|
||||
await manager.showDialog({
|
||||
id: "frontend-update-available",
|
||||
message: {
|
||||
@@ -99,13 +89,6 @@ describe("notification toast lifecycle", () => {
|
||||
duration: -1,
|
||||
});
|
||||
|
||||
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
|
||||
'"seconds":59'
|
||||
);
|
||||
expect(
|
||||
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
|
||||
).toContain('"seconds":60');
|
||||
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
toast.addEventListener("toast-closed", () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
@@ -67,38 +67,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("notification-manager", () => {
|
||||
it("shows a message with the default duration", async () => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({ message: "Configuration saved" });
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
|
||||
expect(toast.labelText).toBe("Configuration saved");
|
||||
expect(toast.timeoutMs).toBe(4000);
|
||||
expect(
|
||||
stack.style.getPropertyValue("--notification-stack-bottom-offset")
|
||||
).toBe("0px");
|
||||
expect(toast.show).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders a dismiss button and closes with the dismiss reason", async () => {
|
||||
it("closes with the dismiss reason", async () => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({
|
||||
message: "Connection lost",
|
||||
dismissable: true,
|
||||
duration: -1,
|
||||
bottomOffset: 16,
|
||||
});
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
|
||||
expect(toast.timeoutMs).toBe(-1);
|
||||
expect(
|
||||
stack.style.getPropertyValue("--notification-stack-bottom-offset")
|
||||
).toBe("16px");
|
||||
|
||||
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
|
||||
expect(toast.hide).toHaveBeenCalledWith("dismiss");
|
||||
});
|
||||
@@ -141,34 +119,7 @@ describe("notification-manager", () => {
|
||||
}
|
||||
);
|
||||
|
||||
it("localizes visible and assistive messages with numeric arguments", async () => {
|
||||
const manager = await mountManager();
|
||||
|
||||
await manager.showDialog({
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 59 },
|
||||
},
|
||||
announceMessage: {
|
||||
translationKey: "ui.notification_toast.new_version_available",
|
||||
args: { seconds: 60 },
|
||||
},
|
||||
});
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
expect(toast.labelText).toContain('"seconds":59');
|
||||
expect(toast.announceText).toContain('"seconds":60');
|
||||
expect(localize).toHaveBeenCalledWith(
|
||||
"ui.notification_toast.new_version_available",
|
||||
{ seconds: 59 }
|
||||
);
|
||||
expect(localize).toHaveBeenCalledWith(
|
||||
"ui.notification_toast.new_version_available",
|
||||
{ seconds: 60 }
|
||||
);
|
||||
});
|
||||
|
||||
it("renders and invokes primary and secondary actions", async () => {
|
||||
it("invokes primary and secondary actions", async () => {
|
||||
const manager = await mountManager();
|
||||
const primary = vi.fn();
|
||||
const secondary = vi.fn();
|
||||
@@ -182,11 +133,6 @@ describe("notification-manager", () => {
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
const buttons = manager.shadowRoot!.querySelectorAll("ha-button");
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons[0].textContent?.trim()).toBe("Cancel");
|
||||
expect(buttons[0].getAttribute("appearance")).toBe("plain");
|
||||
expect(buttons[1].textContent?.trim()).toBe("Update now");
|
||||
expect(buttons[1].getAttribute("appearance")).toBe("filled");
|
||||
|
||||
buttons[0].click();
|
||||
expect(toast.hide).toHaveBeenLastCalledWith("action");
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import { isDate } from "../../../src/common/string/is_date";
|
||||
|
||||
/**
|
||||
* These tests verify that all-day event dates are correctly identified
|
||||
* and can be distinguished from datetime strings. This is critical for
|
||||
* proper date display in the calendar event detail dialog.
|
||||
*/
|
||||
|
||||
test("isDate correctly identifies date-only strings", () => {
|
||||
// Valid date-only strings (all-day events)
|
||||
expect(isDate("2025-10-10")).toBe(true);
|
||||
expect(isDate("2007-06-28")).toBe(true);
|
||||
expect(isDate("2025-12-31")).toBe(true);
|
||||
|
||||
// DateTime strings should not be identified as dates
|
||||
expect(isDate("2025-10-10T00:00:00")).toBe(false);
|
||||
expect(isDate("2025-10-10T14:30:00")).toBe(false);
|
||||
expect(isDate("2025-10-10T14:30:00Z")).toBe(false);
|
||||
expect(isDate("2025-10-10T14:30:00+00:00")).toBe(false);
|
||||
expect(isDate("2025-10-10T14:30:00-08:00")).toBe(false);
|
||||
});
|
||||
|
||||
test("Date parsing for all-day events", () => {
|
||||
// Verify that date-only strings can be parsed as local dates
|
||||
const dateStr = "2025-10-10";
|
||||
const parsed = new Date(dateStr + "T00:00:00");
|
||||
|
||||
expect(parsed.getFullYear()).toBe(2025);
|
||||
expect(parsed.getMonth()).toBe(9); // October (0-indexed)
|
||||
expect(parsed.getDate()).toBe(10);
|
||||
});
|
||||
|
||||
test("Timed events respect timezone conversion", () => {
|
||||
// Verify that datetime strings with timezone info are properly converted with TZDate
|
||||
const datetimeStr = "2025-10-10T14:30:00-07:00"; // 2:30 PM Pacific time
|
||||
const timeZone = "America/Los_Angeles"; // UTC-7 (PDT) in October
|
||||
|
||||
// This should NOT be identified as a date-only string
|
||||
expect(isDate(datetimeStr)).toBe(false);
|
||||
|
||||
// Timed events should use TZDate which respects timezone
|
||||
const tzDate = new TZDate(datetimeStr, timeZone);
|
||||
|
||||
// The date should be October 10, 2:30 PM in LA timezone
|
||||
expect(tzDate.getFullYear()).toBe(2025);
|
||||
expect(tzDate.getMonth()).toBe(9); // October (0-indexed)
|
||||
expect(tzDate.getDate()).toBe(10);
|
||||
expect(tzDate.getHours()).toBe(14);
|
||||
expect(tzDate.getMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
test("Timed events display different day due to timezone offset", () => {
|
||||
// An event at 1 AM UTC on October 10 should display as October 9 in Pacific time
|
||||
const utcDatetimeStr = "2025-10-10T01:00:00Z";
|
||||
const timeZone = "America/Los_Angeles"; // UTC-7 (PDT) in October
|
||||
|
||||
// This should NOT be identified as a date-only string
|
||||
expect(isDate(utcDatetimeStr)).toBe(false);
|
||||
|
||||
// Parse the UTC datetime in Pacific timezone
|
||||
const tzDate = new TZDate(utcDatetimeStr, timeZone);
|
||||
|
||||
// Due to the -7 hour offset, 1 AM UTC becomes 6 PM on the previous day in Pacific
|
||||
expect(tzDate.getFullYear()).toBe(2025);
|
||||
expect(tzDate.getMonth()).toBe(9); // October (0-indexed)
|
||||
expect(tzDate.getDate()).toBe(9); // Previous day
|
||||
expect(tzDate.getHours()).toBe(18); // 6 PM
|
||||
expect(tzDate.getMinutes()).toBe(0);
|
||||
});
|
||||
@@ -1,12 +1,5 @@
|
||||
import { render } from "lit";
|
||||
import { assert, describe, it } from "vitest";
|
||||
import "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
|
||||
import type { HaEnergyPowerConfig } from "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
|
||||
import type { PowerConfig } from "../../../../src/data/energy";
|
||||
import {
|
||||
getPowerHelperEntityId,
|
||||
type PowerType,
|
||||
} from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
|
||||
describe("getPowerHelperEntityId", () => {
|
||||
it("returns the helper for an inverted config", () => {
|
||||
@@ -76,100 +69,3 @@ describe("getPowerHelperEntityId", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Renders the template directly so the async unit lookup in willUpdate is
|
||||
// skipped. localize echoes the key back.
|
||||
const renderPickers = (powerType: PowerType, powerConfig: PowerConfig) => {
|
||||
const el = document.createElement(
|
||||
"ha-energy-power-config"
|
||||
) as HaEnergyPowerConfig;
|
||||
el.hass = { localize: (key: string) => key } as any;
|
||||
el.powerType = powerType;
|
||||
el.powerConfig = powerConfig;
|
||||
|
||||
const container = document.createElement("div");
|
||||
render((el as any).render(), container);
|
||||
|
||||
return [...container.querySelectorAll("ha-statistic-picker")].map(
|
||||
(picker: any) => ({
|
||||
required: picker.required,
|
||||
invalid: picker.invalid,
|
||||
errorMessage: picker.errorMessage,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
describe("ha-energy-power-config required power statistic", () => {
|
||||
it("renders no picker when no power sensor is configured", () => {
|
||||
assert.lengthOf(renderPickers("none", {}), 0);
|
||||
});
|
||||
|
||||
it("marks an empty standard statistic as required and invalid", () => {
|
||||
assert.deepEqual(renderPickers("standard", {}), [
|
||||
{
|
||||
required: true,
|
||||
invalid: true,
|
||||
errorMessage: "ui.common.error_required",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the statistic required but valid once it is set", () => {
|
||||
const [picker] = renderPickers("standard", { stat_rate: "sensor.power" });
|
||||
assert.isTrue(picker.required);
|
||||
assert.isFalse(picker.invalid);
|
||||
});
|
||||
|
||||
it("marks an empty inverted statistic as required and invalid", () => {
|
||||
const [picker] = renderPickers("inverted", {});
|
||||
assert.isTrue(picker.required);
|
||||
assert.isTrue(picker.invalid);
|
||||
});
|
||||
|
||||
it("does not flag the inverted statistic when it is set", () => {
|
||||
const [picker] = renderPickers("inverted", {
|
||||
stat_rate_inverted: "sensor.power",
|
||||
});
|
||||
assert.isFalse(picker.invalid);
|
||||
});
|
||||
|
||||
it("flags both two sensor statistics while they are empty", () => {
|
||||
const pickers = renderPickers("two_sensors", {});
|
||||
assert.lengthOf(pickers, 2);
|
||||
assert.deepEqual(
|
||||
pickers.map((p) => p.invalid),
|
||||
[true, true]
|
||||
);
|
||||
});
|
||||
|
||||
// The two sensor statistics exclude each other, so they keep their clear
|
||||
// button — and therefore no required marker — to stay swappable.
|
||||
it("does not mark the two sensor statistics as required", () => {
|
||||
const pickers = renderPickers("two_sensors", {});
|
||||
assert.deepEqual(
|
||||
pickers.map((p) => p.required),
|
||||
[false, false]
|
||||
);
|
||||
});
|
||||
|
||||
it("flags only the statistic that is still missing", () => {
|
||||
const pickers = renderPickers("two_sensors", {
|
||||
stat_rate_from: "sensor.power_from",
|
||||
});
|
||||
assert.deepEqual(
|
||||
pickers.map((p) => p.invalid),
|
||||
[false, true]
|
||||
);
|
||||
});
|
||||
|
||||
it("clears both flags once the two sensor pair is complete", () => {
|
||||
const pickers = renderPickers("two_sensors", {
|
||||
stat_rate_from: "sensor.power_from",
|
||||
stat_rate_to: "sensor.power_to",
|
||||
});
|
||||
assert.deepEqual(
|
||||
pickers.map((p) => p.invalid),
|
||||
[false, false]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MatterLockInfo } from "../../../../../src/data/matter-lock";
|
||||
|
||||
/**
|
||||
* These tests verify the display logic for the lock management dialog,
|
||||
* ensuring the correct alert is shown based on lock capabilities.
|
||||
*/
|
||||
|
||||
type AlertState =
|
||||
| "no_user_management"
|
||||
| "no_credential_types_supported"
|
||||
| "pin_not_supported"
|
||||
| "full_support";
|
||||
|
||||
/**
|
||||
* Mirrors the branching logic in dialog-matter-lock-manage.ts render()
|
||||
* and _renderUsers() to determine which alert (if any) to display.
|
||||
*/
|
||||
function getAlertState(lockInfo: MatterLockInfo | undefined): AlertState {
|
||||
if (lockInfo && !lockInfo.supports_user_management) {
|
||||
return "no_user_management";
|
||||
}
|
||||
|
||||
const hasNoManageableCredentials =
|
||||
!lockInfo?.supported_credential_types?.length;
|
||||
|
||||
if (hasNoManageableCredentials) {
|
||||
return "no_credential_types_supported";
|
||||
}
|
||||
|
||||
const supportsPinCredential =
|
||||
lockInfo?.supported_credential_types?.includes("pin") ?? false;
|
||||
|
||||
if (!supportsPinCredential) {
|
||||
return "pin_not_supported";
|
||||
}
|
||||
|
||||
return "full_support";
|
||||
}
|
||||
|
||||
describe("dialog-matter-lock-manage alert logic", () => {
|
||||
it("shows no_user_management when lock does not support user management", () => {
|
||||
const lockInfo: MatterLockInfo = {
|
||||
supports_user_management: false,
|
||||
supported_credential_types: [],
|
||||
max_users: null,
|
||||
max_pin_users: null,
|
||||
max_rfid_users: null,
|
||||
max_credentials_per_user: null,
|
||||
min_pin_length: null,
|
||||
max_pin_length: null,
|
||||
min_rfid_length: null,
|
||||
max_rfid_length: null,
|
||||
};
|
||||
|
||||
expect(getAlertState(lockInfo)).toBe("no_user_management");
|
||||
});
|
||||
|
||||
it("shows no_user_management even if credential types are listed", () => {
|
||||
const lockInfo: MatterLockInfo = {
|
||||
supports_user_management: false,
|
||||
supported_credential_types: ["pin"],
|
||||
max_users: null,
|
||||
max_pin_users: null,
|
||||
max_rfid_users: null,
|
||||
max_credentials_per_user: null,
|
||||
min_pin_length: 4,
|
||||
max_pin_length: 8,
|
||||
min_rfid_length: null,
|
||||
max_rfid_length: null,
|
||||
};
|
||||
|
||||
expect(getAlertState(lockInfo)).toBe("no_user_management");
|
||||
});
|
||||
|
||||
it("shows no_credential_types_supported when user management is supported but no credential types", () => {
|
||||
const lockInfo: MatterLockInfo = {
|
||||
supports_user_management: true,
|
||||
supported_credential_types: [],
|
||||
max_users: 10,
|
||||
max_pin_users: null,
|
||||
max_rfid_users: null,
|
||||
max_credentials_per_user: null,
|
||||
min_pin_length: null,
|
||||
max_pin_length: null,
|
||||
min_rfid_length: null,
|
||||
max_rfid_length: null,
|
||||
};
|
||||
|
||||
expect(getAlertState(lockInfo)).toBe("no_credential_types_supported");
|
||||
});
|
||||
|
||||
it("shows pin_not_supported when user management is supported with non-pin credentials only", () => {
|
||||
const lockInfo: MatterLockInfo = {
|
||||
supports_user_management: true,
|
||||
supported_credential_types: ["rfid"],
|
||||
max_users: 10,
|
||||
max_pin_users: null,
|
||||
max_rfid_users: 5,
|
||||
max_credentials_per_user: 3,
|
||||
min_pin_length: null,
|
||||
max_pin_length: null,
|
||||
min_rfid_length: 4,
|
||||
max_rfid_length: 8,
|
||||
};
|
||||
|
||||
expect(getAlertState(lockInfo)).toBe("pin_not_supported");
|
||||
});
|
||||
|
||||
it("shows full_support when user management and pin are both supported", () => {
|
||||
const lockInfo: MatterLockInfo = {
|
||||
supports_user_management: true,
|
||||
supported_credential_types: ["pin"],
|
||||
max_users: 10,
|
||||
max_pin_users: 10,
|
||||
max_rfid_users: null,
|
||||
max_credentials_per_user: 5,
|
||||
min_pin_length: 4,
|
||||
max_pin_length: 8,
|
||||
min_rfid_length: null,
|
||||
max_rfid_length: null,
|
||||
};
|
||||
|
||||
expect(getAlertState(lockInfo)).toBe("full_support");
|
||||
});
|
||||
|
||||
it("shows full_support when both pin and rfid are supported", () => {
|
||||
const lockInfo: MatterLockInfo = {
|
||||
supports_user_management: true,
|
||||
supported_credential_types: ["pin", "rfid"],
|
||||
max_users: 10,
|
||||
max_pin_users: 10,
|
||||
max_rfid_users: 5,
|
||||
max_credentials_per_user: 5,
|
||||
min_pin_length: 4,
|
||||
max_pin_length: 8,
|
||||
min_rfid_length: 4,
|
||||
max_rfid_length: 8,
|
||||
};
|
||||
|
||||
expect(getAlertState(lockInfo)).toBe("full_support");
|
||||
});
|
||||
|
||||
it("handles undefined lockInfo as no_credential_types_supported", () => {
|
||||
expect(getAlertState(undefined)).toBe("no_credential_types_supported");
|
||||
});
|
||||
});
|
||||
@@ -125,12 +125,6 @@ const nextTask = () =>
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const rows = (el: HTMLElement) =>
|
||||
Array.from(el.shadowRoot!.querySelectorAll("ha-list-item"));
|
||||
|
||||
const rowFor = (el: HTMLElement, name: string) =>
|
||||
rows(el).find((row) => row.textContent?.includes(name));
|
||||
|
||||
describe("ha-config-section-storage per-mount usage", () => {
|
||||
let usageCalls: Record<string, Deferred<any>>;
|
||||
|
||||
@@ -185,57 +179,4 @@ describe("ha-config-section-storage per-mount usage", () => {
|
||||
expect(asked).toEqual(["alpha", "beta"]);
|
||||
expect(asked).not.toContain("broken");
|
||||
});
|
||||
|
||||
it("renders the rows before any usage arrives", async () => {
|
||||
const el = await render();
|
||||
expect(rows(el)).toHaveLength(3);
|
||||
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("fills in each row as its own request settles", async () => {
|
||||
const el = await render();
|
||||
|
||||
usageCalls.alpha.resolve({
|
||||
id: "alpha",
|
||||
label: "alpha",
|
||||
total_bytes: 1000,
|
||||
used_bytes: 500,
|
||||
});
|
||||
await nextTask();
|
||||
await (el as any).updateComplete;
|
||||
|
||||
expect(rowFor(el, "alpha")!.textContent).toContain(
|
||||
"500 Bytes of 1000 Bytes used"
|
||||
);
|
||||
// beta has not answered yet, so it must still be pending, not blanked.
|
||||
expect(rowFor(el, "beta")!.querySelector("ha-spinner")).not.toBeNull();
|
||||
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a row without usage when its request fails, and keeps the others", async () => {
|
||||
const el = await render();
|
||||
|
||||
usageCalls.alpha.resolve({
|
||||
id: "alpha",
|
||||
label: "alpha",
|
||||
total_bytes: 1000,
|
||||
used_bytes: 500,
|
||||
});
|
||||
usageCalls.beta.reject(new Error("dead server"));
|
||||
await nextTask();
|
||||
await (el as any).updateComplete;
|
||||
|
||||
const beta = rowFor(el, "beta")!;
|
||||
expect(beta.querySelector("ha-spinner")).toBeNull();
|
||||
expect(beta.querySelector("ha-bar")).toBeNull();
|
||||
expect(beta.textContent).not.toContain("used");
|
||||
expect(rowFor(el, "alpha")!.querySelector("ha-bar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("never shows usage for a mount that is not active", async () => {
|
||||
const el = await render();
|
||||
const broken = rowFor(el, "broken")!;
|
||||
expect(broken.querySelector("ha-spinner")).toBeNull();
|
||||
expect(broken.querySelector("ha-bar")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,15 +75,6 @@ const isEnabled = (
|
||||
) => !control(state, action, attributes).disabled;
|
||||
|
||||
describe("media player playback default controls", () => {
|
||||
it("lead with a power toggle, then previous, play/pause and next", () => {
|
||||
expect(MEDIA_PLAYER_DEFAULT_CONTROLS).toEqual([
|
||||
"power",
|
||||
"media_previous_track",
|
||||
"media_play_pause",
|
||||
"media_next_track",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders play when idle, pause while playing", () => {
|
||||
expect(
|
||||
controlsFor("idle", MEDIA_PLAYER_DEFAULT_CONTROLS).map((b) => b.action)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Node } from "../../../../../../src/components/chart/ha-sankey-chart";
|
||||
|
||||
describe("hui-power-sankey-card", () => {
|
||||
describe("node click handling", () => {
|
||||
it("should identify device nodes as clickable via entityId", () => {
|
||||
const nodes: Node[] = [
|
||||
{ id: "solar", value: 1000, index: 0, label: "Solar" },
|
||||
{ id: "home", value: 1500, index: 1, label: "Home" },
|
||||
{
|
||||
id: "sensor.device1",
|
||||
value: 200,
|
||||
index: 4,
|
||||
label: "Device 1",
|
||||
entityId: "sensor.device1",
|
||||
},
|
||||
];
|
||||
|
||||
const clickableNodes = nodes.filter((n) => n.entityId);
|
||||
expect(clickableNodes).toHaveLength(1);
|
||||
expect(clickableNodes[0].entityId).toBe("sensor.device1");
|
||||
});
|
||||
|
||||
it("should not make source/area/floor nodes clickable", () => {
|
||||
const nodes: Node[] = [
|
||||
{ id: "solar", value: 1000, index: 0 },
|
||||
{ id: "grid", value: 500, index: 0 },
|
||||
{ id: "home", value: 1500, index: 1 },
|
||||
{ id: "floor_1", value: 800, index: 2 },
|
||||
{ id: "area_kitchen", value: 400, index: 3 },
|
||||
];
|
||||
|
||||
const clickableNodes = nodes.filter((n) => n.entityId);
|
||||
expect(clickableNodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,13 +22,9 @@ describe("error badge factories", () => {
|
||||
type: "error",
|
||||
error: "test error",
|
||||
}) as HuiErrorBadge;
|
||||
document.body.append(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.localName).toBe("hui-error-badge");
|
||||
expect(element.shadowRoot?.textContent).toContain("test error");
|
||||
element.remove();
|
||||
expect((element as any)._config.error).toBe("test error");
|
||||
});
|
||||
|
||||
it("creates a configured error heading badge on the first call", async () => {
|
||||
@@ -36,12 +32,8 @@ describe("error badge factories", () => {
|
||||
type: "error",
|
||||
error: "test heading error",
|
||||
}) as HuiErrorHeadingBadge;
|
||||
document.body.append(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.localName).toBe("hui-error-heading-badge");
|
||||
expect(element.shadowRoot?.textContent).toContain("test heading error");
|
||||
element.remove();
|
||||
expect((element as any)._config.error).toBe("test heading error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SecurityFrontendSystemData } from "../../../../src/data/frontend";
|
||||
import type { EditSecurityDialogParams } from "../../../../src/panels/security/dialogs/show-dialog-edit-security";
|
||||
import type { HomeAssistantInternationalization } from "../../../../src/types";
|
||||
import "../../../../src/panels/security/dialogs/dialog-edit-security";
|
||||
import { createMockHass } from "../../../fixtures/hass";
|
||||
|
||||
interface TestEditSecurityDialog extends HTMLElement {
|
||||
params: EditSecurityDialogParams;
|
||||
_i18n: HomeAssistantInternationalization;
|
||||
isDirtyState: boolean;
|
||||
connectedCallback(): void;
|
||||
disconnectedCallback(): void;
|
||||
performUpdate(): void;
|
||||
}
|
||||
|
||||
const alertEntitiesChanged = (
|
||||
dialog: TestEditSecurityDialog,
|
||||
ev: CustomEvent
|
||||
) =>
|
||||
(
|
||||
dialog as unknown as Record<
|
||||
"_alertEntitiesChanged",
|
||||
(event: CustomEvent) => void
|
||||
>
|
||||
)["_alertEntitiesChanged"](ev);
|
||||
|
||||
describe("dialog-edit-security", () => {
|
||||
const createDialog = (config: SecurityFrontendSystemData = {}) => {
|
||||
const hass = createMockHass();
|
||||
const dialog = document.createElement(
|
||||
"dialog-edit-security"
|
||||
) as unknown as TestEditSecurityDialog;
|
||||
dialog._i18n = hass;
|
||||
dialog.params = {
|
||||
config,
|
||||
saveConfig: vi.fn(),
|
||||
};
|
||||
dialog.performUpdate = vi.fn();
|
||||
dialog.connectedCallback();
|
||||
return dialog;
|
||||
};
|
||||
|
||||
it("becomes clean after nested configuration is restored", () => {
|
||||
const alertEntities = [
|
||||
{ entity: "binary_sensor.window", severity: "warning" as const },
|
||||
];
|
||||
const dialog = createDialog({ alert_entities: alertEntities });
|
||||
|
||||
alertEntitiesChanged(
|
||||
dialog,
|
||||
new CustomEvent("value-changed", {
|
||||
detail: {
|
||||
value: [{ ...alertEntities[0], severity: "alert" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(dialog.isDirtyState).toBe(true);
|
||||
|
||||
alertEntitiesChanged(
|
||||
dialog,
|
||||
new CustomEvent("value-changed", {
|
||||
detail: { value: [{ ...alertEntities[0] }] },
|
||||
})
|
||||
);
|
||||
expect(dialog.isDirtyState).toBe(false);
|
||||
|
||||
dialog.disconnectedCallback();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeDefaultSecurityAlertVisibility,
|
||||
computeSecurityAlertCardConfig,
|
||||
} from "../../../../src/panels/security/strategies/security-alerts";
|
||||
import { createMockEntityState } from "../../../fixtures/hass";
|
||||
|
||||
describe("computeDefaultSecurityAlertVisibility", () => {
|
||||
it.each([
|
||||
["alarm_control_panel.house", { state: "triggered" }],
|
||||
["binary_sensor.leak", { state: "on" }],
|
||||
["cover.garage_door", { state: "open" }],
|
||||
[
|
||||
"lock.front_door",
|
||||
{
|
||||
state: ["jammed", "unlocked", "open"],
|
||||
},
|
||||
],
|
||||
])("uses the active state for %s", (entityId, stateCondition) => {
|
||||
expect(computeDefaultSecurityAlertVisibility(entityId)).toEqual([
|
||||
{
|
||||
condition: "state",
|
||||
entity: entityId,
|
||||
...stateCondition,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSecurityAlertCardConfig", () => {
|
||||
it("maps alert severity to a red alert card", () => {
|
||||
expect(
|
||||
computeSecurityAlertCardConfig(undefined, {
|
||||
entity: "binary_sensor.smoke",
|
||||
severity: "alert",
|
||||
})
|
||||
).toEqual({
|
||||
type: "alert",
|
||||
entity: "binary_sensor.smoke",
|
||||
color: "red",
|
||||
visibility: [
|
||||
{
|
||||
condition: "state",
|
||||
entity: "binary_sensor.smoke",
|
||||
state: "on",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the entity device class for the default severity", () => {
|
||||
const stateObj = createMockEntityState("binary_sensor.smoke", "off", {
|
||||
device_class: "smoke",
|
||||
});
|
||||
|
||||
expect(
|
||||
computeSecurityAlertCardConfig(stateObj, {
|
||||
entity: "binary_sensor.smoke",
|
||||
}).color
|
||||
).toBe("red");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SecurityViewStrategy } from "../../../../src/panels/security/strategies/security-view-strategy";
|
||||
import { createMockHass } from "../../../fixtures/hass";
|
||||
|
||||
describe("security-view-strategy", () => {
|
||||
it("renders active alerts as individual cards in a visible section", async () => {
|
||||
const hass = createMockHass();
|
||||
hass.config = { ...hass.config, components: [] };
|
||||
|
||||
const view = await SecurityViewStrategy.generate(
|
||||
{
|
||||
type: "security",
|
||||
alert_entities: [{ entity: "binary_sensor.window" }],
|
||||
},
|
||||
hass
|
||||
);
|
||||
|
||||
const alertSection = view.sidebar?.sections?.[0];
|
||||
|
||||
expect(alertSection?.visibility).toEqual([
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{
|
||||
condition: "state",
|
||||
entity: "binary_sensor.window",
|
||||
state: "on",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(alertSection?.cards).toEqual([
|
||||
{
|
||||
type: "heading",
|
||||
heading: "ui.panel.lovelace.strategy.security.active_alerts",
|
||||
heading_style: "title",
|
||||
},
|
||||
{
|
||||
type: "alert",
|
||||
entity: "binary_sensor.window",
|
||||
color: "amber",
|
||||
visibility: [
|
||||
{
|
||||
condition: "state",
|
||||
entity: "binary_sensor.window",
|
||||
state: "on",
|
||||
},
|
||||
],
|
||||
grid_options: { columns: 12 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -51,12 +51,6 @@ describe("fileDownload", () => {
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(createdElement);
|
||||
});
|
||||
|
||||
it("defaults filename to empty string", async () => {
|
||||
await loadFileDownload();
|
||||
fileDownload("https://example.com/file.json");
|
||||
expect(createdElement.download).toBe("");
|
||||
});
|
||||
|
||||
it("does not revoke non-blob URLs", async () => {
|
||||
await loadFileDownload();
|
||||
fileDownload("https://example.com/file.json", "file.json");
|
||||
|
||||
@@ -2283,14 +2283,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@bundle-stats/plugin-webpack-filter@npm:4.22.2":
|
||||
version: 4.22.2
|
||||
resolution: "@bundle-stats/plugin-webpack-filter@npm:4.22.2"
|
||||
"@bundle-stats/plugin-webpack-filter@npm:4.22.3":
|
||||
version: 4.22.3
|
||||
resolution: "@bundle-stats/plugin-webpack-filter@npm:4.22.3"
|
||||
dependencies:
|
||||
tslib: "npm:2.8.1"
|
||||
peerDependencies:
|
||||
core-js: ^3.0.0
|
||||
checksum: 10/29f837793a9b95e265cf4a98ef48b23808097c9d007448f5ea81732981f8f0c11b5aa443d6c21219e8f4c516ade12419198f8ea12a2ced04f1090a3e99bb41f0
|
||||
checksum: 10/bbc2c12a7b98de0a8548d3ec5de2739e08743a13edcebb3700413cb418faa11e5991fffbff22807e68739399aef633a41745d4b5889a87938fab89e4a4459292
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9975,7 +9975,7 @@ __metadata:
|
||||
"@babel/preset-env": "npm:8.0.2"
|
||||
"@babel/runtime": "npm:8.0.0"
|
||||
"@braintree/sanitize-url": "npm:7.1.2"
|
||||
"@bundle-stats/plugin-webpack-filter": "npm:4.22.2"
|
||||
"@bundle-stats/plugin-webpack-filter": "npm:4.22.3"
|
||||
"@codemirror/autocomplete": "npm:6.20.3"
|
||||
"@codemirror/commands": "npm:6.11.0"
|
||||
"@codemirror/lang-jinja": "npm:6.0.1"
|
||||
|
||||
Reference in New Issue
Block a user