Compare commits

...
8 Commits
Author SHA1 Message Date
58de8df46a Prevent closing the backup restore dialog by clicking outside (#54095)
Prevent scrim close on backup restore dialog

Clicking outside the restore dialog dismissed it unless the encryption
key field was dirty. Restoring a backup is destructive, so guard the
dialog at every step.


Claude-Session: https://claude.ai/code/session_017eZQ6BTz6MervuG3Zb8RLp

Co-authored-by: Claude <[email protected]>
2026-09-10 08:15:47 +03:00
karwostsandGitHub 3447e1d862 An empty placeholder for entity-filter-badge in edit mode (#54096) 2026-09-10 08:15:02 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
105a2a2423 Update Node.js to v24.21.0 (#54097)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-10 07:00:24 +02:00
LaraiandGitHub 27efa82a89 Fix accessible names for analytics consent switches (#54083) 2026-09-09 18:23:13 +02:00
Josef ZweckandGitHub 19cf7f2613 Allow shared translations for subentry abort (#53964)
* Allow shared translations for subentry abort

* load translation
2026-09-09 18:18:41 +02:00
Josef ZweckandGitHub fcd25e41a0 Make sure we load shared translations in config flow (#54084)
* Make sure we load shared translations in config flow

* load translation for specified domain
2026-09-09 18:18:00 +02:00
f942a5afe5 Add visibility condition reordering (#53252)
Co-authored-by: Petar Petrov <[email protected]>
2026-09-09 14:49:01 +02:00
89b745b851 Add contextual descriptions to rows in card condition editor (#52817)
* Add row targets UI to entity state and numeric state in card condition editor

* Use automation descriptions instead of target ui

* Type check

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Format condition editor

* Fix contextual condition descriptions

* Resolve state references in condition descriptions

* Share automation condition headers with visibility conditions

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-09 15:29:42 +03:00
17 changed files with 888 additions and 219 deletions
+1 -1
View File
@@ -1 +1 @@
24.20.0
24.21.0
+1 -1
View File
@@ -234,6 +234,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.20.0"
"node": "24.21.0"
}
}
@@ -0,0 +1,154 @@
import { consume, type ContextType } from "@lit/context";
import { mdiCommentTextOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { truncateWithEllipsis } from "../../common/string/truncate-with-ellipsis";
import type { Condition } from "../../data/automation";
import type { ConditionDescription } from "../../data/condition";
import { internationalizationContext } from "../../data/context";
import "../../panels/config/automation/ha-automation-row-behavior";
import "../../panels/config/automation/ha-automation-row-options";
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 "../ha-svg-icon";
import "../ha-tooltip";
@customElement("ha-automation-condition-summary")
export class HaAutomationConditionSummary extends LitElement {
@property() public label = "";
@property({ attribute: false }) public condition?: Condition;
@property({ attribute: false }) public description?: ConditionDescription;
@property({ attribute: false }) public isNew = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
protected render() {
const descriptionHasTarget = "target" in (this.description || {});
const hasEntityTarget =
this.condition?.condition === "state" ||
this.condition?.condition === "numeric_state";
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this.isNew;
const note = this.condition?.note?.trim();
let target: HassServiceTarget | undefined;
if (this.condition) {
if (descriptionHasTarget && "target" in this.condition) {
target = this.condition.target;
} else if (
hasEntityTarget &&
"entity_id" in this.condition &&
this.condition.entity_id
) {
target = this._getEntityTarget(this.condition.entity_id);
} else if ("device_id" in this.condition && this.condition.device_id) {
target = this._getDeviceTarget(this.condition.device_id);
}
}
return html`
<h3>
${this.label}
${
this.description && this.condition
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${
this.description?.target
? { target: this.description.target }
: undefined
}
.interactive=${this.condition?.condition !== "device"}
></ha-automation-row-targets>`
: nothing
}
${
this.description && this.condition
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
${
note
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
role="img"
.path=${mdiCommentTextOutline}
aria-label=${this._i18n.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${truncateWithEllipsis(note, 250)}</p></ha-tooltip
>
`
: nothing
}
</h3>
`;
}
static styles = css`
:host {
display: block;
min-width: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
padding: var(--ha-space-2) 0;
min-height: 32px;
max-width: 100%;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
ha-tooltip {
cursor: default;
}
ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-summary": HaAutomationConditionSummary;
}
}
+28 -3
View File
@@ -50,7 +50,11 @@ export class HaAnalytics extends LitElement {
.preference=${"base"}
.disabled=${loading}
name="base"
></ha-switch>
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}
</ha-switch>
</ha-row-item>
${ADDITIONAL_PREFERENCES.map(
(preference) => html`
@@ -72,7 +76,11 @@ export class HaAnalytics extends LitElement {
.checked=${!!this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
></ha-switch>
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}
</ha-switch>
${
baseEnabled
? nothing
@@ -106,7 +114,11 @@ export class HaAnalytics extends LitElement {
.preference=${"diagnostics"}
.disabled=${loading}
name="diagnostics"
></ha-switch>
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}
</ha-switch>
</ha-row-item>
`;
}
@@ -143,6 +155,19 @@ export class HaAnalytics extends LitElement {
color: var(--error-color);
}
/* The visible headline already names the row. Keep the switch's
slotted label available to assistive technology without repeating it. */
ha-switch::part(label) {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
ha-row-item {
--ha-row-item-padding-inline: 0;
}
@@ -9,6 +9,7 @@ import { domainToName } from "../../data/integration";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import {
loadDataEntryFlowDialog,
loadFlowStepTranslations,
showFlowDialog,
} from "./show-dialog-data-entry-flow";
@@ -32,6 +33,7 @@ export const showConfigFlowDialog = (
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", handler),
]);
await loadFlowStepTranslations(hass, step);
return step;
},
fetchFlow: async (hass, flowId) => {
@@ -45,9 +47,14 @@ export const showConfigFlowDialog = (
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", step.handler),
]);
await loadFlowStepTranslations(hass, step);
return step;
},
handleFlowStep: async (hass, flowId, data) => {
const step = await handleConfigFlowStep(hass, flowId, data);
await loadFlowStepTranslations(hass, step);
return step;
},
handleFlowStep: handleConfigFlowStep,
deleteFlow: deleteConfigFlow,
renderAbortDescription(hass, step) {
@@ -154,6 +154,19 @@ export interface FlowConfig {
export type LoadingReason =
"loading_handlers" | "loading_flow" | "loading_step";
/**
* Load the translations a step resolves against when it points at another
* integration, which owns strings shared between integrations.
*/
export const loadFlowStepTranslations = async (
hass: HomeAssistant,
step: DataEntryFlowStep
): Promise<void> => {
if ("translation_domain" in step && step.translation_domain) {
await hass.loadBackendTranslation("config", step.translation_domain);
}
};
export interface DataEntryFlowDialogParams {
startFlowHandler?: string;
searchQuery?: string;
@@ -34,6 +34,8 @@ export const showSubConfigFlowDialog = (
hass.loadBackendTranslation("selector", configEntry.domain),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", configEntry.domain),
// Shared abort reasons live in the homeassistant integration
hass.loadBackendTranslation("config", "homeassistant"),
]);
return step;
},
@@ -46,16 +48,24 @@ export const showSubConfigFlowDialog = (
configEntry.domain
);
await hass.loadBackendTranslation("selector", configEntry.domain);
await hass.loadBackendTranslation("config", "homeassistant");
return step;
},
handleFlowStep: handleSubConfigFlowStep,
deleteFlow: deleteSubConfigFlow,
renderAbortDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
// A translation domain means the reason is shared by several integrations
// and is defined once under `config`, not per subentry type
const description = step.translation_domain
? hass.localize(
`component.${step.translation_domain}.config.abort.${step.reason}`,
step.description_placeholders
)
: hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
return description
? html`
@@ -5,7 +5,6 @@ import {
mdiArrowDown,
mdiArrowUp,
mdiCommentEditOutline,
mdiCommentTextOutline,
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
@@ -19,7 +18,6 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
@@ -32,10 +30,10 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { truncateWithEllipsis } from "../../../../common/string/truncate-with-ellipsis";
import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-condition-summary";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-row-event-chip";
@@ -64,7 +62,6 @@ import {
} from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import {
showAlertDialog,
showPromptDialog,
@@ -73,12 +70,7 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-automation-row-behavior";
import "../ha-automation-row-options";
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-condition-editor";
import type HaAutomationConditionEditor from "./ha-automation-condition-editor";
import "./types/ha-automation-condition-and";
@@ -181,26 +173,6 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderRow() {
const descriptionHasTarget =
"target" in (this.conditionDescriptions[this.condition.condition] || {});
const hasEntityTarget =
this.condition.condition === "state" ||
this.condition.condition === "numeric_state";
const target = this._getTarget(descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
const noteTooltipText = truncateWithEllipsis(
this.condition.note?.trim() || "",
250
);
return html`
${
this.optionsInSidebar && this.condition.condition !== "trigger"
@@ -226,56 +198,15 @@ export default class HaAutomationConditionRow extends LitElement {
></ha-condition-icon>
</div>`
}
<h3 slot="header">
${capitalizeFirstLetter(
<ha-automation-condition-summary
slot="header"
.label=${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? this._renderTargets(
target,
targetRequired,
conditionTargetSpec,
this.condition.condition !== "device"
)
: nothing
}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
${
this.condition.note?.trim()
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${noteTooltipText}</p></ha-tooltip
>
`
: nothing
}
</h3>
.condition=${this.condition}
.description=${this.conditionDescriptions[this.condition.condition]}
.isNew=${this._isNew}
></ha-automation-condition-summary>
<ha-automation-row-event-chip
.show=${this._testing}
.variant=${this._testingResult ? "success" : "warning"}
@@ -621,45 +552,6 @@ export default class HaAutomationConditionRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.condition) {
return this.condition.target;
}
if (
"entity_id" in this.condition &&
this.condition.entity_id &&
hasEntityTarget
) {
return this._getEntityTarget(this.condition.entity_id);
}
if ("device_id" in this.condition && this.condition.device_id) {
return this._getDeviceTarget(this.condition.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
@@ -148,7 +148,7 @@ class DialogRestoreBackup
<ha-dialog
.open=${this._open}
header-title=${dialogTitle}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div class="content">
+3 -1
View File
@@ -67,6 +67,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
if (this.hass) {
this._element.hass = this.hass;
}
this._element.preview = this.preview;
// Update element when the visibility of the badge changes, e.g. custom badge
this._element.addEventListener("badge-visibility-changed", (ev: Event) => {
ev.stopPropagation();
@@ -128,11 +129,12 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
}
}
}
if (changedProps.has("hass")) {
if (changedProps.has("hass") || changedProps.has("preview")) {
try {
if (this.hass) {
this._element.hass = this.hass;
}
this._element.preview = this.preview;
} catch (e: any) {
this._loadElement(createErrorBadgeConfig(e.message, null));
}
@@ -28,6 +28,8 @@ export class HuiEntityFilterBadge
private _elements?: HuiBadge[];
private _placeholderBadge?: HTMLElement;
private _configEntities?: EntityFilterEntityConfig[];
private _oldEntities?: EntityFilterEntityConfig[];
@@ -56,6 +58,7 @@ export class HuiEntityFilterBadge
this.removeChild(this.lastChild);
}
this._elements = undefined;
this._placeholderBadge = undefined;
this._configEntities = processConfigEntities(config.entities);
this._oldEntities = undefined;
@@ -69,6 +72,7 @@ export class HuiEntityFilterBadge
protected shouldUpdate(changedProperties: PropertyValues): boolean {
if (
changedProperties.has("_config") ||
changedProperties.has("preview") ||
(changedProperties.has("hass") &&
this._haveEntitiesChanged(
changedProperties.get("hass") as HomeAssistant | undefined
@@ -112,33 +116,42 @@ export class HuiEntityFilterBadge
});
if (entitiesList.length === 0) {
this.style.display = "none";
this._oldEntities = entitiesList;
return;
}
const isSame =
this._oldEntities &&
entitiesList.length === this._oldEntities.length &&
entitiesList.every((entity, idx) => entity === this._oldEntities![idx]);
if (!this.preview) {
this._placeholderBadge = undefined;
this.style.display = "none";
return;
}
if (!isSame) {
this._elements = [];
for (const badgeConfig of entitiesList) {
const element = document.createElement("hui-badge");
element.hass = this.hass;
element.preview = this.preview;
element.config = {
type: "entity",
...badgeConfig,
};
element.load();
this._elements.push(element);
this._placeholderBadge ??= document.createElement("ha-badge");
} else {
this._placeholderBadge = undefined;
const isSame =
!changedProperties.has("preview") &&
this._oldEntities &&
entitiesList.length === this._oldEntities.length &&
entitiesList.every((entity, idx) => entity === this._oldEntities![idx]);
if (!isSame) {
this._elements = [];
for (const badgeConfig of entitiesList) {
const element = document.createElement("hui-badge");
element.hass = this.hass;
element.preview = this.preview;
element.config = {
type: "entity",
...badgeConfig,
};
element.load();
this._elements.push(element);
}
this._oldEntities = entitiesList;
}
this._oldEntities = entitiesList;
}
if (!this._elements) {
if (!this._elements && !this._placeholderBadge) {
return;
}
@@ -146,8 +159,12 @@ export class HuiEntityFilterBadge
this.removeChild(this.lastChild);
}
for (const element of this._elements) {
this.appendChild(element);
if (this._placeholderBadge) {
this.appendChild(this._placeholderBadge);
} else {
for (const element of this._elements!) {
this.appendChild(element);
}
}
this.style.display = "flex";
@@ -492,8 +492,8 @@ export class HuiDialogEditCard
max-width: var(--ha-view-sections-column-max-width, 500px);
}
.content .element-editor {
padding-inline-start: var(--ha-space-1);
padding-inline-end: var(--ha-space-2);
margin-inline-start: var(--ha-space-1);
margin-bottom: 0;
}
@@ -7,6 +7,8 @@ import {
mdiDelete,
mdiDotsVertical,
mdiFlask,
mdiArrowDown,
mdiArrowUp,
mdiPlaylistEdit,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
@@ -24,7 +26,9 @@ import { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/automation/ha-automation-condition-summary";
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/automation/ha-automation-row-live-test";
import type { LiveTestState } from "../../../../components/automation/ha-automation-row-live-test";
@@ -55,6 +59,13 @@ import {
CONDITION_ROW_CONFIG_KEYS,
pickRowConfig,
} from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import type { ConditionDescriptions } from "../../../../data/condition";
import {
conditionDescriptionsContext,
fullEntitiesContext,
} from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import { ICON_CONDITION } from "../../common/icon-condition";
import type {
AndCondition,
@@ -219,6 +230,22 @@ export class HaCardConditionEditor extends LitElement {
@property({ attribute: false }) condition!: VisibilityCondition;
@property({ type: Number }) public index = 0;
@property({ type: Boolean }) public first = false;
@property({ type: Boolean }) public last = false;
@property({ attribute: false }) public sortableData?: VisibilityCondition;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
private _entityReg: EntityRegistryEntry[] = [];
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: conditionsEntityContext, subscribe: true })
private _entityContext?: ConditionsEntityContext;
@@ -433,6 +460,16 @@ export class HaCardConditionEditor extends LitElement {
const hideLiveTest = this._hideLiveTest(condition);
const summaryCondition =
condition.condition === "time"
? { ...condition, weekday: condition.weekdays }
: this._usesAutomationEditor ||
CONTAINER_CONDITIONS.includes(condition.condition) ||
(!isNoEntityCondition(condition.condition, this._noEntity) &&
condition.condition in this._conditionDescriptions)
? condition
: undefined;
return html`
<div class="container">
<ha-expansion-panel left-chevron>
@@ -462,13 +499,28 @@ export class HaCardConditionEditor extends LitElement {
>`
: nothing
}
<h3 slot="header">
${
this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition.condition}.label`
) || condition.condition
<ha-automation-condition-summary
slot="header"
.condition=${summaryCondition}
.description=${
summaryCondition
? this._conditionDescriptions[condition.condition]
: undefined
}
</h3>
.label=${
summaryCondition
? capitalizeFirstLetter(
describeCondition(
summaryCondition,
this.hass,
this._entityReg
)
)
: this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition.condition}.label`
) || condition.condition
}
></ha-automation-condition-summary>
<ha-automation-row-event-chip
.show=${this._testingResult !== undefined}
.variant=${this._testingResult ? "success" : "warning"}
@@ -486,6 +538,7 @@ export class HaCardConditionEditor extends LitElement {
)
}
</ha-automation-row-event-chip>
<slot name="drag-handle" slot="icons"></slot>
<ha-dropdown
slot="icons"
@wa-select=${this._handleAction}
@@ -530,6 +583,18 @@ export class HaCardConditionEditor extends LitElement {
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="move_up" .disabled=${this.first}>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="icon" .path=${mdiArrowUp}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="move_down" .disabled=${this.last}>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="icon" .path=${mdiArrowDown}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item
value="toggle_yaml"
.disabled=${!this._uiAvailable}
@@ -632,6 +697,12 @@ export class HaCardConditionEditor extends LitElement {
case "cut":
this._cutCondition();
return;
case "move_up":
fireEvent(this, "move-up");
return;
case "move_down":
fireEvent(this, "move-down");
return;
case "toggle_yaml":
this._yamlMode = !this._yamlMode;
return;
@@ -714,11 +785,6 @@ export class HaCardConditionEditor extends LitElement {
color: var(--secondary-text-color);
opacity: 0.9;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
.content {
padding: 12px;
}
@@ -1,17 +1,24 @@
import { consume } from "@lit/context";
import { mdiContentPaste, mdiPlus } from "@mdi/js";
import { mdiContentPaste, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { storage } from "../../../../common/decorators/storage";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { nextRender } from "../../../../common/util/render-status";
import "../../../../components/ha-button";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { ICON_CONDITION } from "../../common/icon-condition";
import type {
Condition,
@@ -78,6 +85,20 @@ export class HaCardConditionsEditor extends LitElement {
private _focusLastConditionOnChange = false;
@state() private _rowSortSelected?: number;
private _conditionKeys = new WeakMap<VisibilityCondition, number>();
private _nextConditionKey = 0;
private _getKey(condition: VisibilityCondition): number {
if (!this._conditionKeys.has(condition)) {
this._conditionKeys.set(condition, this._nextConditionKey++);
}
return this._conditionKeys.get(condition)!;
}
protected firstUpdated() {
// Automation condition editors read labels from the config fragment.
this.hass.loadFragmentTranslation("config");
@@ -128,62 +149,233 @@ export class HaCardConditionsEditor extends LitElement {
protected render() {
return html`
<div class="conditions">
${this.conditions.map(
(cond, idx) => html`
<ha-card-condition-editor
.index=${idx}
@duplicate-condition=${this._duplicateCondition}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.condition=${cond}
></ha-card-condition-editor>
`
)}
<div>
<ha-dropdown @wa-select=${this._addCondition}>
<ha-button slot="trigger" appearance="filled">
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.add"
<ha-sortable
handle-selector=".handle"
draggable-selector="ha-card-condition-editor"
group="lovelace-conditions"
invert-swap
@item-moved=${this._conditionMoved}
@item-added=${this._conditionAdded}
@item-removed=${this._conditionRemoved}
>
<div class="conditions">
${repeat(
this.conditions,
(condition) => this._getKey(condition),
(cond, idx) => html`
<ha-card-condition-editor
.sortableData=${cond}
.index=${idx}
.first=${idx === 0}
.last=${idx === this.conditions.length - 1}
@duplicate-condition=${this._duplicateCondition}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.condition=${cond}
>
<div
slot="drag-handle"
class="handle ${
this._rowSortSelected === idx ? "active" : ""
}"
role="button"
tabindex="0"
aria-label=${this.hass.localize("ui.common.move")}
aria-pressed=${this._rowSortSelected === idx}
.index=${idx}
@click=${this._handleDragClick}
@keydown=${this._handleDragKeydown}
>
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
</ha-card-condition-editor>
`
)}
<div>
<ha-dropdown @wa-select=${this._addCondition}>
<ha-button slot="trigger" appearance="filled">
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.add"
)}
</ha-button>
${
this._canPaste
? html`
<ha-dropdown-item value="paste">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.paste_condition"
)}
<ha-svg-icon
slot="icon"
.path=${mdiContentPaste}
></ha-svg-icon>
</ha-dropdown-item>
`
: nothing
}
${this._availableConditions.map(
(condition) => html`
<ha-dropdown-item .value=${condition}>
${
this.hass!.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition}.label`
) || condition
}
<ha-svg-icon
slot="icon"
.path=${ICON_CONDITION[condition]}
></ha-svg-icon>
</ha-dropdown-item>
`
)}
</ha-button>
${
this._canPaste
? html`
<ha-dropdown-item value="paste">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.paste_condition"
)}
<ha-svg-icon
slot="icon"
.path=${mdiContentPaste}
></ha-svg-icon>
</ha-dropdown-item>
`
: nothing
}
${this._availableConditions.map(
(condition) => html`
<ha-dropdown-item .value=${condition}>
${
this.hass!.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition}.label`
) || condition
}
<ha-svg-icon
slot="icon"
.path=${ICON_CONDITION[condition]}
></ha-svg-icon>
</ha-dropdown-item>
`
)}
</ha-dropdown>
</ha-dropdown>
</div>
</div>
</div>
</ha-sortable>
`;
}
private _move(oldIndex: number, newIndex: number) {
const conditions = [...this.conditions];
const condition = conditions.splice(oldIndex, 1)[0];
conditions.splice(newIndex, 0, condition);
this.conditions = conditions;
if (this._rowSortSelected === oldIndex) {
this._rowSortSelected = newIndex;
} else if (
this._rowSortSelected !== undefined &&
oldIndex < this._rowSortSelected &&
newIndex >= this._rowSortSelected
) {
this._rowSortSelected--;
} else if (
this._rowSortSelected !== undefined &&
oldIndex > this._rowSortSelected &&
newIndex <= this._rowSortSelected
) {
this._rowSortSelected++;
}
fireEvent(this, "value-changed", { value: conditions });
}
private _conditionMoved(ev: CustomEvent) {
ev.stopPropagation();
this._move(ev.detail.oldIndex, ev.detail.newIndex);
}
private async _conditionAdded(ev: CustomEvent) {
ev.stopPropagation();
const { index, data } = ev.detail;
if (this._rowSortSelected !== undefined && index <= this._rowSortSelected) {
this._rowSortSelected++;
}
let conditions = [...this.conditions];
conditions.splice(index, 0, data);
this.conditions = conditions;
await nextRender();
if (this.conditions !== conditions && !this.conditions.includes(data)) {
conditions = [...this.conditions];
conditions.splice(index, 0, data);
} else {
conditions = this.conditions;
}
fireEvent(this, "value-changed", { value: conditions });
}
private async _conditionRemoved(ev: CustomEvent) {
ev.stopPropagation();
const { index: removedIndex } = ev.detail;
const removed = this.conditions[removedIndex];
if (this._rowSortSelected === removedIndex) {
this._rowSortSelected = undefined;
} else if (
this._rowSortSelected !== undefined &&
removedIndex < this._rowSortSelected
) {
this._rowSortSelected--;
}
let conditions = [...this.conditions];
conditions.splice(removedIndex, 1);
this.conditions = conditions;
await nextRender();
if (this.conditions !== conditions) {
conditions = [...this.conditions];
const index = conditions.indexOf(removed);
if (index !== -1) {
conditions.splice(index, 1);
}
}
fireEvent(this, "value-changed", { value: conditions });
}
private _moveUp(ev: CustomEvent) {
ev.stopPropagation();
const row = ev.currentTarget as HaCardConditionEditor;
if (!row.first) {
this._move(row.index, row.index - 1);
}
}
private _moveDown(ev: CustomEvent) {
ev.stopPropagation();
const row = ev.currentTarget as HaCardConditionEditor;
if (!row.last) {
this._move(row.index, row.index + 1);
}
}
private _handleDragClick(
ev: HASSDomCurrentTargetEvent<HTMLElement & { index: number }>
) {
ev.stopPropagation();
this._rowSortSelected =
this._rowSortSelected === ev.currentTarget.index
? undefined
: ev.currentTarget.index;
}
private _handleDragKeydown(
ev: KeyboardEvent &
HASSDomCurrentTargetEvent<HTMLElement & { index: number }>
) {
const handle = ev.currentTarget;
const selected = this._rowSortSelected === handle.index;
if (ev.key === "Escape" && selected) {
ev.preventDefault();
ev.stopPropagation();
this._rowSortSelected = undefined;
return;
}
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleDragClick(ev);
return;
}
if (
(selected || ev.altKey) &&
!ev.ctrlKey &&
!ev.metaKey &&
!ev.shiftKey &&
(ev.key === "ArrowUp" || ev.key === "ArrowDown")
) {
ev.preventDefault();
ev.stopPropagation();
const newIndex =
ev.key === "ArrowUp" ? handle.index - 1 : handle.index + 1;
if (newIndex < 0 || newIndex >= this.conditions.length) {
return;
}
this._move(handle.index, newIndex);
handle.focus();
}
}
private _addCondition(ev: HaDropdownSelectEvent) {
const value = ev.detail.item.value as string;
const conditions = [...this.conditions];
@@ -225,15 +417,34 @@ export class HaCardConditionsEditor extends LitElement {
fireEvent(this, "value-changed", { value: conditions });
}
private _conditionChanged(ev: CustomEvent) {
private _conditionChanged(
ev: ValueChangedEvent<VisibilityCondition | null> &
HASSDomTargetEvent<HaCardConditionEditor>
) {
ev.stopPropagation();
const conditions = [...this.conditions];
const newValue = ev.detail.value;
const index = (ev.target as any).index;
if (
newValue !== null &&
(typeof newValue !== "object" || Array.isArray(newValue))
) {
return;
}
const conditions = [...this.conditions];
const index = ev.target.index;
if (newValue === null) {
conditions.splice(index, 1);
if (this._rowSortSelected === index) {
this._rowSortSelected = undefined;
} else if (
this._rowSortSelected !== undefined &&
index < this._rowSortSelected
) {
this._rowSortSelected--;
}
} else {
this._conditionKeys.set(newValue, this._getKey(conditions[index]));
conditions[index] = newValue;
}
@@ -252,6 +463,24 @@ export class HaCardConditionsEditor extends LitElement {
margin-top: 12px;
scroll-margin-top: 48px;
}
.handle {
padding: var(--ha-space-1);
cursor: move;
cursor: grab;
border-radius: var(--ha-border-radius-pill);
}
.handle:focus {
outline: var(--wa-focus-ring);
background: var(--ha-color-fill-neutral-quiet-resting);
}
.handle.active {
outline: var(--wa-focus-ring);
background: var(--ha-color-fill-neutral-normal-active);
}
.handle ha-svg-icon {
display: block;
pointer-events: none;
}
ha-dropdown {
display: inline-block;
margin-top: var(--ha-space-3);
+1
View File
@@ -43,6 +43,7 @@ export interface Lovelace {
export interface LovelaceBadge extends HTMLElement {
hass?: HomeAssistant;
preview?: boolean;
connectedWhileHidden?: boolean;
setConfig(config: LovelaceBadgeConfig): void;
}
+56
View File
@@ -61,3 +61,59 @@ test("completes onboarding and opens the default dashboard", async ({
expect(calls.tokenRequests[1]).toContain("dashboard-auth-code");
expectNoPageErrors(errors);
});
test("chooses analytics consent using named switches", async ({
page,
baseURL,
}) => {
const errors = trackPageErrors(page);
const calls = await setupOnboardingMocks(page);
await openOnboarding(page, baseURL!);
await createOwner(page);
await completeCoreConfig(page);
const analytics = page.locator("onboarding-analytics");
const basic = analytics.getByRole("switch", {
name: "Basic analytics",
exact: true,
});
const usage = analytics.getByRole("switch", { name: "Usage", exact: true });
const statistics = analytics.getByRole("switch", {
name: "Statistical data",
exact: true,
});
const diagnostics = analytics.getByRole("switch", {
name: "Diagnostics",
exact: true,
});
await expect(basic).toBeVisible();
await basic.press("Space");
await usage.press("Space");
await statistics.press("Space");
await diagnostics.press("Space");
await expect(usage).toBeChecked();
await expect(statistics).toBeChecked();
await expect(diagnostics).toBeChecked();
// Withdrawing basic consent also withdraws its dependent categories,
// while independently selected crash reporting stays enabled.
await basic.press("Space");
await expect(usage).not.toBeChecked();
await expect(statistics).not.toBeChecked();
await expect(diagnostics).toBeChecked();
await completeAnalytics(page);
await expect.poll(() => calls.analyticsCompleted).toBe(true);
expect(calls.analyticsPreferences).toMatchObject({
type: "analytics/preferences",
preferences: {
base: false,
usage: false,
statistics: false,
diagnostics: true,
},
});
expectNoPageErrors(errors);
});
@@ -0,0 +1,197 @@
import { describe, expect, it, vi } from "vitest";
import "../../../../../src/panels/lovelace/editor/conditions/ha-card-conditions-editor";
import type { HaCardConditionsEditor } from "../../../../../src/panels/lovelace/editor/conditions/ha-card-conditions-editor";
import type { Condition } from "../../../../../src/panels/lovelace/common/validate-condition";
const CONDITIONS: Condition[] = [
{ condition: "state", entity: "light.first", state: "on" },
{ condition: "state", entity: "light.second", state: "off" },
{ condition: "state", entity: "light.third", state: "on" },
];
const createEditor = () => {
const editor = document.createElement(
"ha-card-conditions-editor"
) as HaCardConditionsEditor;
editor.conditions = CONDITIONS;
return editor;
};
const waitForValueChanged = (editor: HaCardConditionsEditor) =>
new Promise<Condition[]>((resolve) => {
editor.addEventListener(
"value-changed",
(ev) => resolve(ev.detail.value as Condition[]),
{ once: true }
);
});
describe("ha-card-conditions-editor sorting", () => {
it("moves conditions without mutating the input array", async () => {
const editor = createEditor();
const input = editor.conditions;
const changed = waitForValueChanged(editor);
const stopPropagation = vi.fn();
(editor as any)._conditionMoved({
detail: { oldIndex: 0, newIndex: 2 },
stopPropagation,
});
const value = await changed;
expect(stopPropagation).toHaveBeenCalledOnce();
expect(value).toEqual([CONDITIONS[1], CONDITIONS[2], CONDITIONS[0]]);
expect(value).not.toBe(input);
expect(input).toEqual(CONDITIONS);
});
it("inserts a condition received from another list", async () => {
const editor = createEditor();
const added: Condition = {
condition: "user",
users: ["test-user"],
};
const changed = waitForValueChanged(editor);
await (editor as any)._conditionAdded({
detail: { index: 1, data: added },
stopPropagation: vi.fn(),
});
await expect(changed).resolves.toEqual([
CONDITIONS[0],
added,
CONDITIONS[1],
CONDITIONS[2],
]);
});
it("does not insert a cross-level condition twice after a parent rerender", async () => {
const editor = createEditor();
const added: Condition = {
condition: "user",
users: ["test-user"],
};
const changed = waitForValueChanged(editor);
const addition = (editor as any)._conditionAdded({
detail: { index: 1, data: added },
stopPropagation: vi.fn(),
});
editor.conditions = [CONDITIONS[0], added, CONDITIONS[1], CONDITIONS[2]];
await addition;
await expect(changed).resolves.toEqual([
CONDITIONS[0],
added,
CONDITIONS[1],
CONDITIONS[2],
]);
});
it("removes a condition sent to another list", async () => {
const editor = createEditor();
const changed = waitForValueChanged(editor);
await (editor as any)._conditionRemoved({
detail: { index: 1 },
stopPropagation: vi.fn(),
});
await expect(changed).resolves.toEqual([CONDITIONS[0], CONDITIONS[2]]);
});
it("keeps keyboard selection aligned across cross-list changes", async () => {
const editor = createEditor();
(editor as any)._rowSortSelected = 1;
await (editor as any)._conditionAdded({
detail: { index: 0, data: { condition: "screen" } },
stopPropagation: vi.fn(),
});
expect((editor as any)._rowSortSelected).toBe(2);
await (editor as any)._conditionRemoved({
detail: { index: 2 },
stopPropagation: vi.fn(),
});
expect((editor as any)._rowSortSelected).toBeUndefined();
});
it("moves a row using its non-pointer actions", async () => {
const editor = createEditor();
const changed = waitForValueChanged(editor);
(editor as any)._moveUp({
currentTarget: { first: false, index: 1 },
stopPropagation: vi.fn(),
});
await expect(changed).resolves.toEqual([
CONDITIONS[1],
CONDITIONS[0],
CONDITIONS[2],
]);
});
it("keeps keyboard sorting attached to the moved condition", () => {
const editor = createEditor();
(editor as any)._rowSortSelected = 1;
(editor as any)._moveUp({
currentTarget: { first: false, index: 1 },
stopPropagation: vi.fn(),
});
expect((editor as any)._rowSortSelected).toBe(0);
});
it("preserves a row key when a condition is replaced", () => {
const editor = createEditor();
const original = CONDITIONS[1];
const replacement: Condition = {
condition: "state",
entity: "light.second",
state: "unavailable",
};
const key = (editor as any)._getKey(original);
(editor as any)._conditionChanged({
detail: { value: replacement },
target: { index: 1 },
stopPropagation: vi.fn(),
});
expect((editor as any)._getKey(replacement)).toBe(key);
});
it("keeps keyboard selection aligned after deletion", () => {
const editor = createEditor();
(editor as any)._rowSortSelected = 2;
(editor as any)._conditionChanged({
detail: { value: null },
target: { index: 0 },
stopPropagation: vi.fn(),
});
expect((editor as any)._rowSortSelected).toBe(1);
});
it("does not move a row beyond a list boundary", () => {
const editor = createEditor();
const changed = vi.fn();
editor.addEventListener("value-changed", changed);
(editor as any)._moveUp({
currentTarget: { first: true, index: 0 },
stopPropagation: vi.fn(),
});
(editor as any)._moveDown({
currentTarget: { last: true, index: CONDITIONS.length - 1 },
stopPropagation: vi.fn(),
});
expect(changed).not.toHaveBeenCalled();
});
});