Compare commits

..

7 Commits

Author SHA1 Message Date
Jan-Philipp Benecke 14617aaf3c Fix hass-tabs-subpage narrow toggle (#52375) 2026-06-02 19:53:31 +02:00
Bram Kragten 42e1051d9c Add tags in app store too, plus show if addon is installed already (#52373) 2026-06-02 18:48:09 +02:00
Marcin Bauer cfe30114f0 Move live-test indicator to badge on condition icon (#52352)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Wendelin <w@pe8.at>
2026-06-02 14:19:35 +00:00
Petar Petrov 288c03c248 Fix raw div tag showing in Sankey chart tooltips (#52365)
Fix raw div tag showing in sankey chart tooltips
2026-06-02 16:12:38 +02:00
renovate[bot] 8cd9a5adf6 Update dependency lint-staged to v17.0.6 (#52363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 13:17:09 +00:00
Bram Kragten 4d3437b491 Matter add device: change how main entity is found (#52361)
Don't search for a entity based on main entity but use entity_category
2026-06-02 15:13:07 +02:00
Bram Kragten ceb51714be Migrate trigger behavior (#52360)
* Migrate trigger behavior

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-06-02 15:00:34 +02:00
23 changed files with 291 additions and 422 deletions
+1 -1
View File
@@ -180,7 +180,7 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.0.5",
"lint-staged": "17.0.6",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -1,6 +1,5 @@
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -13,7 +12,6 @@ export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@@ -21,8 +19,6 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
@@ -31,39 +27,38 @@ export class HaAutomationRowLiveTest extends LitElement {
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
static styles = css`
:host {
position: absolute;
top: -5px;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 12px;
height: 12px;
width: 10px;
height: 10px;
border-radius: var(--ha-border-radius-circle);
border: 3px solid;
border: var(--ha-border-width-md) solid;
box-sizing: border-box;
background-color: var(--card-background-color);
box-shadow: 0 0 0 2px var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
:host([state="pass"]) #indicator {
background-color: var(--ha-color-fill-success-loud-resting);
border-color: var(--ha-color-fill-success-loud-resting);
background-color: var(--ha-color-green-60);
border-color: var(--ha-color-green-60);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-fill-warning-loud-resting);
border-color: var(--ha-color-orange-60);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-fill-danger-loud-resting);
border-color: var(--ha-color-red-60);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-fill-neutral-loud-resting);
border-color: var(--ha-color-neutral-60);
}
`;
}
@@ -165,7 +165,7 @@ export class HaAutomationRow extends LitElement {
::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet);
}
:host([building-block]) ::slotted([slot="leading-icon"]) {
:host([building-block]) ::slotted(#condition-icon) {
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
transform: rotate(-45deg);
+6 -2
View File
@@ -101,18 +101,22 @@ export class HaSankeyChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
// Keep numbers and units left-to-right, even in RTL locales.
const formattedValue = html`<div style="direction:ltr; display: inline;">
${value}
</div>`;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${value}`;
${node?.label ?? data.id}<br />${formattedValue}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${value}`;
${target?.label ?? data.target}<br />${formattedValue}`;
}
return null;
};
+11
View File
@@ -485,6 +485,17 @@ export const migrateAutomationTrigger = (
}
delete trigger.platform;
}
if ("options" in trigger) {
if (trigger.options && "behavior" in trigger.options) {
if (trigger.options.behavior === "any") {
trigger.options.behavior = "each";
} else if (trigger.options.behavior === "last") {
trigger.options.behavior = "all";
}
}
}
return trigger;
};
-21
View File
@@ -1,21 +0,0 @@
import { createContext } from "@lit/context";
export interface DirtyStateContext<State = unknown> {
/** Whether current state differs from the initial snapshot */
isDirty: boolean;
/** Current tracked state */
state: State;
/** Update the tracked state — triggers dirty comparison */
setState: (state: State) => void;
/** Reset initial snapshot to current state (marks clean) */
markClean: () => void;
}
/**
* Singleton context key for dirty-state tracking.
*
* Because Lit context keys are singletons, the value type is
* `DirtyStateContext<unknown>`. The provider mixin and consumer controller
* supply type-safe APIs on top of this boundary.
*/
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
+3 -11
View File
@@ -63,7 +63,6 @@ import { subscribeLabFeature } from "../../data/labs";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { ScrollableFadeMixin } from "../../mixins/scrollable-fade-mixin";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import {
@@ -122,8 +121,8 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends DirtyStateProviderMixin()(
SubscribeMixin(ScrollableFadeMixin(LitElement))
export class MoreInfoDialog extends SubscribeMixin(
ScrollableFadeMixin(LitElement)
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -641,8 +640,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin()(
@closed=${this._dialogClosed}
@opened=${this._handleOpened}
@show-child-view=${this._showChildView}
.preventScrimClose=${(this._currView === "settings" &&
this.isDirtyState) ||
.preventScrimClose=${this._currView === "settings" ||
!this._isEscapeEnabled}
flexcontent
>
@@ -959,12 +957,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin()(
}
}
if (changedProps.has("_currView") || changedProps.has("_entry")) {
if (this._currView === "settings" && this._entry) {
this._initDirtyTracking({ type: "deep" });
}
}
if (changedProps.has("_currView")) {
this._infoEditMode = false;
this._detailsYamlMode = false;
+2
View File
@@ -139,6 +139,8 @@ export class HassTabsSubpage extends LitElement {
);
public willUpdate(changedProperties: PropertyValues<this>) {
this.toggleAttribute("narrow", this._narrow);
if (changedProperties.has("route")) {
const currentPath = `${this.route.prefix}${this.route.path}`;
this._activeTab = this.tabs.find((tab) =>
-193
View File
@@ -1,193 +0,0 @@
import { provide } from "@lit/context";
import type { LitElement } from "lit";
import { state } from "lit/decorators";
import { deepEqual } from "../common/util/deep-equal";
import { shallowEqual } from "../common/util/shallow-equal";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../data/context/dirty-state";
import type { Constructor } from "../types";
export type CompareStrategy<State> =
| { type: "deep" }
| { type: "shallow" }
| { type: "custom"; compare: (a: State, b: State) => boolean };
function resolveCompare<State>(
strategy: CompareStrategy<State>
): (a: State, b: State) => boolean {
switch (strategy.type) {
case "deep":
return (a, b) => deepEqual(a, b);
case "shallow":
return (a, b) => shallowEqual(a, b);
default:
return strategy.compare;
}
}
/**
* Mixin that provides dirty-state tracking via Lit context.
*
* Uses the `@provide` decorator so any descendant component can consume
* dirty-state with `@consume({ context: dirtyStateContext, subscribe: true })`.
*
* Curried generic pattern: `State` is explicitly provided while `Base` is
* inferred from the superclass argument.
*
* @example Eager init (state known upfront, e.g. dialog open):
* ```ts
* interface MyDialogState { name: string; icon: string }
*
* class MyDialog extends DirtyStateProviderMixin<MyDialogState>()(LitElement) {
* open() {
* this._initDirtyTracking({ type: "shallow" }, { name: "", icon: "" });
* }
* }
* ```
*
* @example Deferred init (child consumer reports initial state):
* ```ts
* class MyPage extends DirtyStateProviderMixin<FormState>()(LitElement) {
* connectedCallback() {
* super.connectedCallback();
* this._initDirtyTracking({ type: "deep" });
* // First setState from a child consumer sets the baseline
* }
* }
* ```
*
* Child consumers:
* ```ts
* @consume({ context: dirtyStateContext, subscribe: true })
* @state()
* private _dirtyState?: DirtyStateContext;
*
* // Read: this._dirtyState?.isDirty
* // Write: this._dirtyState?.setState(newState)
* ```
*/
export const DirtyStateProviderMixin =
<State = unknown>() =>
<Base extends Constructor<LitElement>>(superClass: Base) => {
class DirtyStateProviderMixinClass extends superClass {
private _dirtyInitialState: State | undefined;
private _dirtyCurrentState: State | undefined;
private _dirtyCompareFn: (a: State, b: State) => boolean = deepEqual;
@provide({ context: dirtyStateContext })
@state()
private _dirtyStateContext: DirtyStateContext = this._buildContextValue(
undefined,
false
);
/**
* Build the context value object for the provider.
*
* The returned type is `DirtyStateContext` (i.e. `DirtyStateContext<unknown>`)
* because the singleton context key is typed at `unknown`. The single
* `unknown → State` narrowing cast in `setState` is the only unsafe boundary
* and is confined here.
*/
private _buildContextValue(
currentState: State | undefined,
isDirty: boolean
): DirtyStateContext {
return {
isDirty,
state: currentState,
setState: (incoming: unknown) => {
this._updateDirtyState(incoming as State);
},
markClean: () => {
this._markDirtyStateClean();
},
};
}
/**
* Initialize dirty state tracking.
*
* When `initialState` is provided, tracking starts immediately.
* When omitted (deferred mode), the first `_updateDirtyState` /
* `setState` call from a consumer becomes the baseline snapshot.
*
* Call again to reset (e.g. when the underlying entity changes).
*/
protected _initDirtyTracking(
strategy: CompareStrategy<State>,
initialState?: State
): void {
this._dirtyCompareFn = resolveCompare(strategy);
if (initialState !== undefined) {
this._dirtyInitialState = initialState;
this._dirtyCurrentState = initialState;
this._dirtyStateContext = this._buildContextValue(
initialState,
false
);
} else {
this._dirtyInitialState = undefined;
this._dirtyCurrentState = undefined;
this._dirtyStateContext = this._buildContextValue(undefined, false);
}
}
/**
* Update the tracked state. Triggers dirty comparison against initial snapshot.
*
* If called before `_initDirtyTracking` provided an initial state (deferred
* mode), the first call sets the baseline and reports clean.
*
* Guarded: no-ops if the computed dirty status and state reference are
* unchanged, preventing render loops when called from `updated()`.
*/
protected _updateDirtyState(newState: State): void {
// Deferred init: first state becomes the baseline
if (this._dirtyInitialState === undefined) {
this._dirtyInitialState = newState;
this._dirtyCurrentState = newState;
this._dirtyStateContext = this._buildContextValue(newState, false);
return;
}
const isDirty = !this._dirtyCompareFn(
this._dirtyInitialState,
newState
);
if (
this._dirtyCurrentState !== undefined &&
this._dirtyCompareFn(this._dirtyCurrentState, newState) &&
this._dirtyStateContext.isDirty === isDirty
) {
return;
}
this._dirtyCurrentState = newState;
this._dirtyStateContext = this._buildContextValue(newState, isDirty);
}
/**
* Reset the initial snapshot to the current state, marking the state as clean.
* Call this after a successful save.
*/
protected _markDirtyStateClean(): void {
this._dirtyInitialState = this._dirtyCurrentState;
this._dirtyStateContext = this._buildContextValue(
this._dirtyCurrentState,
false
);
}
/**
* Whether the current state differs from the initial snapshot.
*/
public get isDirtyState(): boolean {
return this._dirtyStateContext.isDirty;
}
}
return DirtyStateProviderMixinClass;
};
@@ -1,5 +1,5 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { mdiHelpCircleOutline } from "@mdi/js";
import { mdiCheckCircle, mdiHelpCircleOutline } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@@ -25,7 +25,9 @@ class SupervisorAppsCardContent extends LitElement {
@property() public stage: AddonStage = "stable";
@property() public state: AddonState = null;
@property() public state?: AddonState;
@property({ type: Boolean }) public installed = false;
@property() public description?: string;
@@ -77,13 +79,23 @@ class SupervisorAppsCardContent extends LitElement {
</div>
</div>
</div>
${this.tags?.length || this.state
${this.tags?.length || this.state !== undefined || this.installed
? html`
<div class="footer">
<supervisor-apps-state
.state=${this.state || "unknown"}
></supervisor-apps-state>
${this.state !== undefined
? html`<supervisor-apps-state
.state=${this.state || "unknown"}
></supervisor-apps-state>`
: this.installed
? html`<div class="installed">
<ha-svg-icon .path=${mdiCheckCircle}></ha-svg-icon>
<span
>${this.hass.localize(
"ui.panel.config.apps.state.installed"
)}</span
>
</div>`
: html`<span></span>`}
${this.tags?.length
? html`<div class="tags">
${this.tags.map(
@@ -159,6 +171,17 @@ class SupervisorAppsCardContent extends LitElement {
display: flex;
gap: var(--ha-space-2);
}
.installed {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-m);
}
.installed ha-svg-icon {
--mdc-icon-size: 16px;
color: var(--ha-color-on-success-normal);
}
`;
}
@@ -1,7 +1,14 @@
import { mdiArrowUpBoldCircle, mdiPuzzle } from "@mdi/js";
import {
mdiAlertDecagramOutline,
mdiArrowUpBoldCircle,
mdiArrowUpBoldCircleOutline,
mdiFlask,
mdiPuzzle,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
@@ -10,6 +17,7 @@ import type { HassioAddonRepository } from "../../../data/hassio/addon";
import type { StoreAddon } from "../../../data/supervisor/store";
import type { HomeAssistant } from "../../../types";
import "./components/supervisor-apps-card-content";
import type { AppTag } from "./components/supervisor-apps-card-content";
import { filterAndSort } from "./components/supervisor-apps-filter";
import { supervisorAppsStyle } from "./resources/supervisor-apps-style";
@@ -54,21 +62,29 @@ export class SupervisorAppsRepositoryEl extends LitElement {
<div class="content">
<h1>${repo.name}</h1>
<div class="card-group">
${addons.map(
(addon) => html`
${addons.map((addon) => {
const tags = this._getAppTags(addon);
return html`
<ha-card
outlined
.addon=${addon}
class=${addon.available ? "" : "not_available"}
@click=${this._addonTapped}
>
<div class="card-content">
<div
class=${classMap({
"card-content": true,
"has-footer": tags.length > 0 || addon.installed,
})}
>
<supervisor-apps-card-content
.hass=${this.hass}
.title=${addon.name}
.stage=${addon.stage}
.description=${addon.description}
.available=${addon.available}
.installed=${addon.installed}
.tags=${tags}
.icon=${addon.installed && addon.update_available
? mdiArrowUpBoldCircle
: mdiPuzzle}
@@ -108,8 +124,8 @@ export class SupervisorAppsRepositoryEl extends LitElement {
></supervisor-apps-card-content>
</div>
</ha-card>
`
)}
`;
})}
</div>
</div>
`;
@@ -119,6 +135,32 @@ export class SupervisorAppsRepositoryEl extends LitElement {
navigate(`/config/app/${ev.currentTarget.addon.slug}/info?store=true`);
}
private _getAppTags(addon: StoreAddon): AppTag[] {
const labels: AppTag[] = [];
if (addon.installed && addon.update_available) {
labels.push({
label: this.hass.localize(
`ui.panel.config.apps.state.update_available`
),
variant: "brand",
iconPath: mdiArrowUpBoldCircleOutline,
});
}
if (addon.stage !== "stable") {
labels.push({
label: this.hass.localize(
`ui.panel.config.apps.dashboard.capability.stages.${addon.stage}`
),
variant: addon.stage === "experimental" ? "warning" : "danger",
iconPath:
addon.stage === "experimental" ? mdiFlask : mdiAlertDecagramOutline,
});
}
return labels;
}
static get styles(): CSSResultGroup {
return [
supervisorAppsStyle,
@@ -127,6 +169,9 @@ export class SupervisorAppsRepositoryEl extends LitElement {
cursor: pointer;
overflow: hidden;
}
.card-content.has-footer {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
}
.not_available {
opacity: 0.6;
}
@@ -52,6 +52,7 @@ import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tooltip";
import type {
AutomationClipboard,
Condition,
@@ -211,11 +212,27 @@ export default class HaAutomationConditionRow extends LitElement {
);
return html`
<ha-condition-icon
slot="leading-icon"
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
<div id="condition-icon" class="icon-badge-wrapper" slot="leading-icon">
<ha-condition-icon
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
${this.optionsInSidebar && this.condition.condition !== "trigger"
? html`<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`
: nothing}
</div>
${this.optionsInSidebar &&
this.condition.condition !== "trigger" &&
this._liveTestResult.message
? html`<ha-tooltip for="condition-icon" slot="leading-icon"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
@@ -531,17 +548,7 @@ export default class HaAutomationConditionRow extends LitElement {
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
>${this._renderRow()}
<ha-automation-row-live-test
slot="icons"
.state=${this.condition.condition !== "trigger"
? this._liveTestResult.state
: "unknown"}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this.condition.condition !== "trigger" ? this._liveTestResult.state : "unknown"}`
)}
.message=${this._liveTestResult.message}
></ha-automation-row-live-test
></ha-automation-row>`
</ha-automation-row>`
: html`
<ha-expansion-panel
left-chevron
+5
View File
@@ -53,6 +53,11 @@ export const rowStyles = css`
position: absolute;
}
.icon-badge-wrapper {
position: relative;
display: inline-flex;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
@@ -15,19 +15,13 @@ import type {
} from "../../../data/category_registry";
import { internationalizationContext } 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 type { CategoryRegistryDetailDialogParams } from "./show-dialog-category-registry-detail";
interface CategoryFormState {
name: string;
icon: string | null;
}
@customElement("dialog-category-registry-detail")
class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
DialogMixin<CategoryRegistryDetailDialogParams>(LitElement)
class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParams>(
LitElement
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -50,10 +44,6 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
this._name = this.params?.suggestedName || "";
this._icon = null;
}
this._initDirtyTracking(
{ type: "shallow" },
{ name: this._name, icon: this._icon }
);
}
protected render() {
@@ -62,14 +52,13 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
}
const entry = this.params.entry;
const nameInvalid = !this._isNameValid();
const isCreate = !entry;
return html`
<ha-dialog
open
header-title=${entry
? this._i18n.localize("ui.panel.config.category.editor.edit")
: this._i18n.localize("ui.panel.config.category.editor.create")}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -107,9 +96,7 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid ||
!!this._submitting ||
(!isCreate && !this.isDirtyState)}
.disabled=${nameInvalid || !!this._submitting}
>
${entry
? this._i18n.localize("ui.common.save")
@@ -127,17 +114,15 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState({ name: this._name, icon: this._icon });
}
private _iconChanged(ev: ValueChangedEvent<string>) {
this._error = undefined;
this._icon = ev.detail.value;
this._updateDirtyState({ name: this._name, icon: this._icon });
}
private async _updateEntry() {
const create = !this.params?.entry;
const create = !this.params!.entry;
this._submitting = true;
let newValue: CategoryRegistryEntry | undefined;
try {
@@ -146,11 +131,10 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
icon: this._icon || (create ? undefined : null),
};
if (create) {
newValue = await this.params?.createEntry?.(values);
newValue = await this.params!.createEntry!(values);
} else {
newValue = await this.params?.updateEntry?.(values);
newValue = await this.params!.updateEntry!(values);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
@@ -1,13 +1,11 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { consume, type ContextType } from "@lit/context";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeEntityEntryName } from "../../../../../common/entity/compute_entity_name";
import "../../../../../components/ha-button";
import { dirtyStateContext } from "../../../../../data/context/dirty-state";
import type { ExtEntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import { removeEntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import { HELPERS_CRUD } from "../../../../../data/helpers_crud";
@@ -35,16 +33,14 @@ export class EntitySettingsHelperTab extends LitElement {
@property({ attribute: false }) public entry!: ExtEntityRegistryEntry;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: ContextType<typeof dirtyStateContext>;
@state() private _error?: string;
@state() private _item?: Helper | null;
@state() private _submitting = false;
@state() private _dirty = false;
@state() private _componentLoaded?: boolean;
@query("entity-registry-settings-editor")
@@ -64,9 +60,13 @@ export class EntitySettingsHelperTab extends LitElement {
super.updated(changedProperties);
if (changedProperties.has("entry")) {
this._error = undefined;
if (this.entry.unique_id !== changedProperties.get("entry")?.unique_id) {
if (
this.entry.unique_id !==
(changedProperties.get("entry") as ExtEntityRegistryEntry)?.unique_id
) {
this._item = undefined;
}
this._getItem();
}
}
@@ -107,6 +107,7 @@ export class EntitySettingsHelperTab extends LitElement {
.hass=${this.hass}
.entry=${this.entry}
.disabled=${!!this._submitting}
@change=${this._entityRegistryChanged}
hide-name
hide-icon
></entity-registry-settings-editor>
@@ -123,7 +124,7 @@ export class EntitySettingsHelperTab extends LitElement {
</ha-button>
<ha-button
@click=${this._updateItem}
.disabled=${!(this._dirtyState?.isDirty || this._isHelperDirty) ||
.disabled=${!this._dirty ||
!!this._submitting ||
!!(this._item && !this._item.name)}
>
@@ -138,12 +139,22 @@ export class EntitySettingsHelperTab extends LitElement {
return JSON.stringify(this._item) !== this._originalItemJson;
}
private _updateDirty() {
this._dirty = (this._registryEditor?.dirty ?? false) || this._isHelperDirty;
}
private _entityRegistryChanged() {
this._error = undefined;
this._updateDirty();
}
private _valueChanged(ev: CustomEvent): void {
if (this._item === null) {
return;
}
this._error = undefined;
this._item = ev.detail.value;
this._updateDirty();
}
private async _getItem() {
@@ -156,20 +167,15 @@ export class EntitySettingsHelperTab extends LitElement {
private async _updateItem(): Promise<void> {
this._submitting = true;
this._error = undefined;
try {
if (this._componentLoaded && this._item) {
await HELPERS_CRUD[this.entry.platform].update(
this.hass,
this.hass!,
this._item.id,
this._item
);
}
const result = await this._registryEditor!.updateEntry();
this._dirtyState?.markClean();
this._originalItemJson = this._item
? JSON.stringify(this._item)
: undefined;
if (result.close) {
fireEvent(this, "close-dialog");
}
@@ -6,8 +6,8 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consume, type ContextType } from "@lit/context";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import { supportsFeature } from "../../../common/entity/supports-feature";
@@ -45,7 +45,6 @@ import {
STREAM_TYPE_HLS,
updateCameraPrefs,
} from "../../../data/camera";
import { dirtyStateContext } from "../../../data/context/dirty-state";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteConfigEntry } from "../../../data/config_entries";
import {
@@ -145,28 +144,6 @@ const SCANNER_SOURCE_TYPES = ["router", "bluetooth", "bluetooth_le"];
const ZONE_DOMAINS = ["zone"];
interface EntitySettingsState {
name: string | null;
icon: string | null;
entityId: string;
areaId: string | null;
labels: string[];
deviceClass: string | undefined;
disabledBy: EntityRegistryEntry["disabled_by"];
hiddenBy: EntityRegistryEntry["hidden_by"];
unitOfMeasurement: string | null | undefined;
precision: number | null | undefined;
defaultCode: string | null | undefined;
calendarColor: string | null;
precipitationUnit: string | null | undefined;
pressureUnit: string | null | undefined;
temperatureUnit: string | null | undefined;
visibilityUnit: string | null | undefined;
windSpeedUnit: string | null | undefined;
switchAsDomain: string;
switchAsInvert: boolean;
}
@customElement("entity-registry-settings-editor")
export class EntityRegistrySettingsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -181,50 +158,41 @@ export class EntityRegistrySettingsEditor extends LitElement {
@property({ attribute: false }) public helperConfigEntry?: ConfigEntry;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: ContextType<typeof dirtyStateContext>;
@state() private _name!: string;
@state() private _icon!: string;
@state() private _entityId!: EntitySettingsState["entityId"];
@state() private _entityId!: string;
@state() private _deviceClass?: EntitySettingsState["deviceClass"];
@state() private _deviceClass?: string;
@state() private _switchAsDomain: EntitySettingsState["switchAsDomain"] =
"switch";
@state() private _switchAsDomain = "switch";
@state() private _switchAsInvert: EntitySettingsState["switchAsInvert"] =
false;
@state() private _switchAsInvert = false;
@state() private _areaId?: string | null;
@state() private _labels?: string[] | null;
@state() private _disabledBy!: EntitySettingsState["disabledBy"];
@state() private _disabledBy!: EntityRegistryEntry["disabled_by"];
@state() private _hiddenBy!: EntitySettingsState["hiddenBy"];
@state() private _hiddenBy!: EntityRegistryEntry["hidden_by"];
@state() private _device?: DeviceRegistryEntry;
@state()
private _unit_of_measurement?: EntitySettingsState["unitOfMeasurement"];
@state() private _unit_of_measurement?: string | null;
@state() private _precision?: EntitySettingsState["precision"];
@state() private _precision?: number | null;
@state()
private _precipitation_unit?: EntitySettingsState["precipitationUnit"];
@state() private _precipitation_unit?: string | null;
@state() private _pressure_unit?: EntitySettingsState["pressureUnit"];
@state() private _pressure_unit?: string | null;
@state()
private _temperature_unit?: EntitySettingsState["temperatureUnit"];
@state() private _temperature_unit?: string | null;
@state() private _visibility_unit?: EntitySettingsState["visibilityUnit"];
@state() private _visibility_unit?: string | null;
@state() private _wind_speed_unit?: EntitySettingsState["windSpeedUnit"];
@state() private _wind_speed_unit?: string | null;
@state() private _cameraPrefs?: CameraPreferences;
@@ -236,9 +204,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _weatherConvertibleUnits?: WeatherUnits;
@state() private _defaultCode?: EntitySettingsState["defaultCode"];
@state() private _defaultCode?: string | null;
@state() private _calendarColor?: EntitySettingsState["calendarColor"];
@state() private _calendarColor?: string | null;
@state() private _associatedZone?: string;
@@ -248,7 +216,11 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _deviceClassOptions?: string[][];
private _currentState(): EntitySettingsState {
private _initialStateJson!: string;
private _lastDirty = false;
private _currentState() {
return {
name: this._name.trim() || null,
icon: this._icon.trim() || null,
@@ -343,6 +315,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._wind_speed_unit = stateObj?.attributes?.wind_speed_unit;
}
this._initialStateJson = JSON.stringify(this._currentState());
this._lastDirty = false;
const deviceClasses: string[][] = OVERRIDE_DEVICE_CLASSES[domain];
if (!deviceClasses || this._hideDeviceClassOverride(domain)) {
@@ -441,9 +416,17 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._switchAsDomain = "switch";
this._switchAsInvert = false;
}
this._initialStateJson = JSON.stringify(this._currentState());
this._lastDirty = false;
}
this._dirtyState?.setState(this._currentState());
if (this._initialStateJson) {
const dirty = this.dirty;
if (dirty !== this._lastDirty) {
this._lastDirty = dirty;
fireEvent(this, "change");
}
}
}
protected render() {
@@ -1163,6 +1146,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
`;
}
public get dirty(): boolean {
return JSON.stringify(this._currentState()) !== this._initialStateJson;
}
public async updateEntry(): Promise<{
close: boolean;
entry: ExtEntityRegistryEntry;
@@ -1448,10 +1435,12 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _nameChanged(ev: InputEvent): void {
fireEvent(this, "change");
this._name = (ev.target as HTMLInputElement).value;
}
private _iconChanged(ev: CustomEvent): void {
fireEvent(this, "change");
this._icon = ev.detail.value;
}
@@ -1470,18 +1459,22 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _entityIdChanged(ev: InputEvent): void {
fireEvent(this, "change");
this._entityId = `${computeDomain(this._origEntityId)}.${(ev.target as HTMLInputElement).value}`;
}
private _deviceClassChanged(ev: HaSelectSelectEvent<string, true>): void {
fireEvent(this, "change");
this._deviceClass = ev.detail.value;
}
private _unitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._unit_of_measurement = ev.detail.value;
}
private _defaultcodeChanged(ev: InputEvent): void {
fireEvent(this, "change");
this._defaultCode =
(ev.target as HTMLInputElement).value === ""
? null
@@ -1489,35 +1482,43 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _calendarColorChanged(ev: CustomEvent): void {
fireEvent(this, "change");
this._calendarColor = ev.detail.value || null;
}
private _associatedZoneChanged(ev: CustomEvent): void {
fireEvent(this, "change");
this._associatedZone = ev.detail.value || "zone.home";
}
private _precipitationUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._precipitation_unit = ev.detail.value;
}
private _precisionChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._precision =
ev.detail.value === "default" ? null : Number(ev.detail.value);
}
private _pressureUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._pressure_unit = ev.detail.value;
}
private _temperatureUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._temperature_unit = ev.detail.value;
}
private _visibilityUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._visibility_unit = ev.detail.value;
}
private _windSpeedUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._wind_speed_unit = ev.detail.value;
}
@@ -1552,6 +1553,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _areaPicked(ev: CustomEvent) {
fireEvent(this, "change");
this._areaId = ev.detail.value;
}
@@ -1624,6 +1626,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _resetNameAndOpenDeviceSettings() {
this._name = this.entry.name || "";
fireEvent(this, "change");
this._openDeviceSettings();
}
@@ -2,7 +2,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { consume, type ContextType } from "@lit/context";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDeviceName } from "../../../common/entity/compute_device_name";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
@@ -14,7 +13,6 @@ import {
deleteConfigEntry,
getConfigEntry,
} from "../../../data/config_entries";
import { dirtyStateContext } from "../../../data/context/dirty-state";
import { updateDeviceRegistryEntry } from "../../../data/device/device_registry";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
@@ -40,16 +38,14 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@property({ type: Object }) public entry!: ExtEntityRegistryEntry;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: ContextType<typeof dirtyStateContext>;
@state() private _helperConfigEntry?: ConfigEntry;
@state() private _error?: string;
@state() private _submitting?: boolean;
@state() private _dirty = false;
@query("entity-registry-settings-editor")
private _registryEditor?: EntityRegistrySettingsEditor;
@@ -137,6 +133,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
.entry=${this.entry}
.helperConfigEntry=${this._helperConfigEntry}
.disabled=${!!this._submitting}
@change=${this._entityRegistryChanged}
></entity-registry-settings-editor>
</div>
<div class="buttons">
@@ -151,7 +148,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
</ha-button>
<ha-button
@click=${this._updateEntry}
.disabled=${!this._dirtyState?.isDirty || !!this._submitting}
.disabled=${!this._dirty || !!this._submitting}
.loading=${!!this._submitting}
>
${this.hass.localize("ui.dialogs.entity_registry.editor.update")}
@@ -160,6 +157,11 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
`;
}
private _entityRegistryChanged() {
this._error = undefined;
this._dirty = this._registryEditor?.dirty ?? false;
}
private _openDeviceSettings() {
const device = this.hass.devices[this.entry.device_id!];
@@ -205,10 +207,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
private async _updateEntry(): Promise<void> {
this._submitting = true;
this._error = undefined;
try {
const result = await this._registryEditor!.updateEntry();
this._dirtyState?.markClean();
if (result.close) {
fireEvent(this, "close-dialog");
}
@@ -25,6 +25,7 @@ import {
type ExtEntityRegistryEntry,
} from "../../../../../data/entity/entity_registry";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { OVERRIDE_DEVICE_CLASSES } from "../../../entities/entity-registry-settings-editor";
import "./matter-add-device/matter-add-device-apple-home";
import "./matter-add-device/matter-add-device-existing";
import "./matter-add-device/matter-add-device-generic";
@@ -139,15 +140,17 @@ class DialogMatterAddDevice extends LitElement {
entityIds
);
const mainEntry = Object.values(entries).find(
(e) => e.original_name === null
);
if (!mainEntry) return;
const domain = computeDomain(mainEntry.entity_id);
if (domain === "cover" || domain === "binary_sensor") {
this._mainEntity = mainEntry;
}
this._mainEntity = Object.values(entries).find((entry) => {
if (entry.entity_category) return false;
const domain = computeDomain(entry.entity_id);
const deviceClasses = OVERRIDE_DEVICE_CLASSES[domain];
if (!deviceClasses) return false;
const deviceClass = entry.device_class ?? entry.original_device_class;
if (!deviceClass) return false;
return deviceClasses.some(
(classes) => classes.length > 1 && classes.includes(deviceClass)
);
});
}
private _dialogClosed(): void {
@@ -438,9 +438,7 @@ class HuiEnergySankeyCard
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
kWh</div>`;
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
@@ -580,9 +580,7 @@ class HuiPowerSankeyCard
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatPowerShort(this.hass, value)}
</div>`;
formatPowerShort(this.hass, value);
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
@@ -511,9 +511,11 @@ class HuiWaterFlowSankeyCard
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatFlowRateShort(this.hass.locale, this.hass.config.unit_system.length, value)}
</div>`;
formatFlowRateShort(
this.hass.locale,
this.hass.config.unit_system.length,
value
);
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
@@ -30,6 +30,7 @@ import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-yaml-editor";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
@@ -230,11 +231,28 @@ export class HaCardConditionEditor extends LitElement {
return html`
<div class="container">
<ha-expansion-panel left-chevron>
<ha-svg-icon
<div
id="condition-icon"
class="icon-badge-wrapper"
slot="leading-icon"
class="condition-icon"
.path=${ICON_CONDITION[condition.condition]}
></ha-svg-icon>
>
<ha-svg-icon
.path=${ICON_CONDITION[condition.condition]}
></ha-svg-icon>
${hideLiveTest
? nothing
: html`<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`}
</div>
${!hideLiveTest && this._liveTestResult.message
? html`<ha-tooltip for="condition-icon" slot="leading-icon"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
<h3 slot="header">
${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition.condition}.label`
@@ -255,18 +273,6 @@ export class HaCardConditionEditor extends LitElement {
"ui.panel.lovelace.editor.condition-editor.testing_error"
)}
</ha-automation-row-event-chip>
${hideLiveTest
? nothing
: html`
<ha-automation-row-live-test
slot="icons"
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${this._liveTestResult.state}`
)}
.message=${this._liveTestResult.message}
></ha-automation-row-live-test>
`}
<ha-dropdown
slot="icons"
@wa-select=${this._handleAction}
@@ -479,17 +485,15 @@ export class HaCardConditionEditor extends LitElement {
--expansion-panel-summary-padding: 0 0 0 8px;
--expansion-panel-content-padding: 0;
}
.condition-icon {
.icon-badge-wrapper {
display: none;
}
@media (min-width: 870px) {
.condition-icon {
display: inline-block;
.icon-badge-wrapper {
display: inline-flex;
position: relative;
color: var(--secondary-text-color);
opacity: 0.9;
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
}
}
h3 {
+12 -12
View File
@@ -8557,7 +8557,7 @@ __metadata:
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
leaflet.markercluster: "npm:1.5.3"
license-checker-rseidelsohn: "npm:5.0.1"
lint-staged: "npm:17.0.5"
lint-staged: "npm:17.0.6"
lit: "npm:3.3.3"
lit-analyzer: "npm:2.0.3"
lit-html: "npm:3.3.3"
@@ -9936,21 +9936,21 @@ __metadata:
languageName: node
linkType: hard
"lint-staged@npm:17.0.5":
version: 17.0.5
resolution: "lint-staged@npm:17.0.5"
"lint-staged@npm:17.0.6":
version: 17.0.6
resolution: "lint-staged@npm:17.0.6"
dependencies:
listr2: "npm:^10.2.1"
picomatch: "npm:^4.0.4"
string-argv: "npm:^0.3.2"
tinyexec: "npm:^1.1.2"
yaml: "npm:^2.8.4"
tinyexec: "npm:1.2.2"
yaml: "npm:^2.9.0"
dependenciesMeta:
yaml:
optional: true
bin:
lint-staged: bin/lint-staged.js
checksum: 10/a0bea43689d68ec0bf6a56943884dbdb96b6b49e2677bf80654d802678b2edf9fc65338ca8ef3fc310f245933ea2a809db1ac94431dc445c57a4d49620d9d4da
checksum: 10/371918cfb293ed0ca5d16fc2a1de304b5a95d21b87dc1ea7f3751567c8f8a07971a40349fac8edb5fce3c6ea6713f70922ea90184b142fd432a5bb4db6c316b0
languageName: node
linkType: hard
@@ -13172,10 +13172,10 @@ __metadata:
languageName: node
linkType: hard
"tinyexec@npm:^1.0.2, tinyexec@npm:^1.1.2":
version: 1.1.2
resolution: "tinyexec@npm:1.1.2"
checksum: 10/2bbe37f9001c6f5723ab39eb8dc1e88f77e830d7cf2e8f34bb75019eb505fcfe3b061b4799c502ff31fa63aa1a9adc649add5ff1e17b7fbd8c16e1afb75d0b9e
"tinyexec@npm:1.2.2, tinyexec@npm:^1.0.2":
version: 1.2.2
resolution: "tinyexec@npm:1.2.2"
checksum: 10/e6f3cabafc33a46063868b4e9c0ab76722e21cb46f0177f7bef5a9656e09ea6fa37115d3e47f776aff11aab9ab696b0c840c8e0099fab574b1e37767c4371aec
languageName: node
linkType: hard
@@ -14755,7 +14755,7 @@ __metadata:
languageName: node
linkType: hard
"yaml@npm:^2.8.4":
"yaml@npm:^2.9.0":
version: 2.9.0
resolution: "yaml@npm:2.9.0"
bin: