From f732de574bc906f81987d5516da78fb505a48e97 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 13 May 2026 11:41:31 +0100 Subject: [PATCH 1/5] Disable update button when state is clean --- .../settings/entity-settings-helper-tab.ts | 22 ++++++++- .../entity-registry-settings-editor.ts | 45 +++++++++++++++++++ .../entities/entity-registry-settings.ts | 9 +++- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts index ed050f7e2447..e1aed55dbf6b 100644 --- a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts +++ b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts @@ -39,11 +39,15 @@ export class EntitySettingsHelperTab extends LitElement { @state() private _submitting = false; + @state() private _dirty = false; + @state() private _componentLoaded?: boolean; @query("entity-registry-settings-editor") private _registryEditor?: EntityRegistrySettingsEditor; + private _originalItemJson?: string; + protected firstUpdated(changedProperties: PropertyValues) { super.firstUpdated(changedProperties); this._componentLoaded = isComponentLoaded( @@ -120,7 +124,9 @@ export class EntitySettingsHelperTab extends LitElement { ${this.hass.localize("ui.dialogs.entity_registry.editor.update")} @@ -128,8 +134,18 @@ export class EntitySettingsHelperTab extends LitElement { `; } + private get _isHelperDirty(): boolean { + if (!this._item || !this._originalItemJson) return false; + 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 { @@ -138,11 +154,15 @@ export class EntitySettingsHelperTab extends LitElement { } this._error = undefined; this._item = ev.detail.value; + this._updateDirty(); } private async _getItem() { const items = await HELPERS_CRUD[this.entry.platform].fetch(this.hass!); this._item = items.find((item) => item.id === this.entry.unique_id) || null; + this._originalItemJson = this._item + ? JSON.stringify(this._item) + : undefined; } private async _updateItem(): Promise { diff --git a/src/panels/config/entities/entity-registry-settings-editor.ts b/src/panels/config/entities/entity-registry-settings-editor.ts index 0cb96fba2ca5..d2613f6595e8 100644 --- a/src/panels/config/entities/entity-registry-settings-editor.ts +++ b/src/panels/config/entities/entity-registry-settings-editor.ts @@ -208,6 +208,34 @@ export class EntityRegistrySettingsEditor extends LitElement { private _deviceClassOptions?: string[][]; + private _initialStateJson!: string; + + private _lastDirty = false; + + private _currentState() { + return { + name: this._name.trim() || null, + icon: this._icon.trim() || null, + entityId: this._entityId.trim(), + areaId: this._areaId ?? null, + labels: this._labels ?? [], + deviceClass: this._deviceClass, + disabledBy: this._disabledBy, + hiddenBy: this._hiddenBy, + unitOfMeasurement: this._unit_of_measurement, + precision: this._precision, + defaultCode: this._defaultCode, + calendarColor: this._calendarColor ?? null, + precipitationUnit: this._precipitation_unit, + pressureUnit: this._pressure_unit, + temperatureUnit: this._temperature_unit, + visibilityUnit: this._visibility_unit, + windSpeedUnit: this._wind_speed_unit, + switchAsDomain: this._switchAsDomain, + switchAsInvert: this._switchAsInvert, + }; + } + protected willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); if ( @@ -274,6 +302,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)) { @@ -372,6 +403,16 @@ export class EntityRegistrySettingsEditor extends LitElement { this._switchAsDomain = "switch"; this._switchAsInvert = false; } + this._initialStateJson = JSON.stringify(this._currentState()); + this._lastDirty = false; + } + + if (this._initialStateJson) { + const dirty = this.dirty; + if (dirty !== this._lastDirty) { + this._lastDirty = dirty; + fireEvent(this, "change"); + } } } @@ -1060,6 +1101,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; diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index d9d98e205c99..41e9571db6ae 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -44,6 +44,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @state() private _submitting?: boolean; + @state() private _dirty = false; + @query("entity-registry-settings-editor") private _registryEditor?: EntityRegistrySettingsEditor; @@ -144,7 +146,11 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { > ${this.hass.localize("ui.dialogs.entity_registry.editor.delete")} - + ${this.hass.localize("ui.dialogs.entity_registry.editor.update")} @@ -153,6 +159,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { private _entityRegistryChanged() { this._error = undefined; + this._dirty = this._registryEditor?.dirty ?? false; } private _openDeviceSettings() { From ad36772c7480d0e29ec4fcf5ac722600bd67f94e Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 13 May 2026 12:15:33 +0100 Subject: [PATCH 2/5] Show device editor tip on name change when device exists --- src/components/ha-tip.ts | 34 +++++++++- .../entity-registry-settings-editor.ts | 67 ++++++++++++++++--- src/translations/en.json | 1 + 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/components/ha-tip.ts b/src/components/ha-tip.ts index 9f117db3e091..71f43ae1706a 100644 --- a/src/components/ha-tip.ts +++ b/src/components/ha-tip.ts @@ -1,3 +1,4 @@ +import "@home-assistant/webawesome/dist/components/popup/popup"; import { mdiLightbulbOutline } from "@mdi/js"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; @@ -9,18 +10,41 @@ import "./ha-svg-icon"; class HaTip extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; + /** + * When set, renders the tip inside a popup anchored to the given element + * instead of inline. Does not steal focus. + */ + @property({ attribute: false }) public popoverAnchor?: Element; + public render() { if (!this.hass) { return nothing; } - return html` + const content = html` ${this.hass.localize("ui.panel.config.tips.tip")} `; + + if (this.popoverAnchor) { + return html` + + + + `; + } + + return content; } static styles = css` @@ -40,6 +64,14 @@ class HaTip extends LitElement { .prefix { font-weight: var(--ha-font-weight-medium); } + + .popup-content { + padding: var(--ha-space-2) var(--ha-space-3); + background: var(--card-background-color); + border-radius: var(--ha-border-radius-xl); + box-shadow: var(--wa-shadow-m); + color: var(--primary-text-color); + } `; } diff --git a/src/panels/config/entities/entity-registry-settings-editor.ts b/src/panels/config/entities/entity-registry-settings-editor.ts index d2613f6595e8..09e587797696 100644 --- a/src/panels/config/entities/entity-registry-settings-editor.ts +++ b/src/panels/config/entities/entity-registry-settings-editor.ts @@ -3,7 +3,7 @@ import { mdiContentCopy, mdiRestore } from "@mdi/js"; import type { HassEntity } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import { until } from "lit/directives/until"; import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; @@ -34,6 +34,7 @@ import type { HaSelectSelectEvent } from "../../../components/ha-select"; import "../../../components/ha-state-icon"; import "../../../components/ha-switch"; import type { HaSwitch } from "../../../components/ha-switch"; +import "../../../components/ha-tip"; import "../../../components/input/ha-input"; import { CAMERA_ORIENTATIONS, @@ -204,6 +205,8 @@ export class EntityRegistrySettingsEditor extends LitElement { @state() private _noDeviceArea?: boolean; + @query("ha-input.name") private _nameInput?: HTMLElement; + private _origEntityId!: string; private _deviceClassOptions?: string[][]; @@ -439,16 +442,21 @@ export class EntityRegistrySettingsEditor extends LitElement { ${this.hideName ? nothing : html` - `} + inset-label + class="name" + .value=${this._name} + .label=${this.hass.localize( + "ui.dialogs.entity_registry.editor.name" + )} + .disabled=${this.disabled} + @input=${this._nameChanged} + > + + ${this._renderDeviceNameTip( + this._device, + this._name, + this.entry.name || "" + )}`} ${this.hideIcon ? nothing : html` @@ -1563,6 +1571,13 @@ export class EntityRegistrySettingsEditor extends LitElement { } } + private _resetNameAndOpenDeviceSettings() { + this._name = this.entry.name || ""; + fireEvent(this, "change"); + + this._openDeviceSettings(); + } + private _openDeviceSettings() { showDeviceRegistryDetailDialog(this, { device: this._device!, @@ -1596,6 +1611,36 @@ export class EntityRegistrySettingsEditor extends LitElement { }); } + private _renderDeviceNameTip = memoizeOne( + ( + device: DeviceRegistryEntry | undefined, + name: string, + originalName: string + ) => { + if (!device || name === originalName) { + return nothing; + } + return html` + ${this.hass.localize( + "ui.dialogs.entity_registry.editor.device_name_tip", + { + link: html``, + } + )} + `; + } + ); + private _switchAsDomainsSorted = memoizeOne( (domains: string[], localize: LocalizeFunc) => domains diff --git a/src/translations/en.json b/src/translations/en.json index 26d4ff7add42..597f7f6c1c15 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1958,6 +1958,7 @@ "entity_disabled": "This entity is disabled.", "enable_entity": "Enable", "open_device_settings": "Open device settings", + "device_name_tip": "Rename the device to update all its entities at once. {link}", "switch_as_x_confirm": "This switch will be hidden and a new {domain} will be added. Your existing configurations using the switch will continue to work.", "switch_as_x_remove_confirm": "This {domain} will be removed and the original switch will be visible again. Your existing configurations using the {domain} will no longer work!", "switch_as_x_change_confirm": "This {domain_1} will be removed and will be replaced by a new {domain_2}. Your existing configurations using the {domain_1} will no longer work!", From 6172c9db235184f819cc4c1be38d5bc6c602dd4a Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 13 May 2026 12:24:38 +0100 Subject: [PATCH 3/5] Use helper instead --- .../entity-registry-settings-editor.ts | 77 +++++++------------ 1 file changed, 28 insertions(+), 49 deletions(-) diff --git a/src/panels/config/entities/entity-registry-settings-editor.ts b/src/panels/config/entities/entity-registry-settings-editor.ts index 09e587797696..5673e35899dc 100644 --- a/src/panels/config/entities/entity-registry-settings-editor.ts +++ b/src/panels/config/entities/entity-registry-settings-editor.ts @@ -3,7 +3,7 @@ import { mdiContentCopy, mdiRestore } from "@mdi/js"; import type { HassEntity } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import { until } from "lit/directives/until"; import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; @@ -34,7 +34,6 @@ import type { HaSelectSelectEvent } from "../../../components/ha-select"; import "../../../components/ha-state-icon"; import "../../../components/ha-switch"; import type { HaSwitch } from "../../../components/ha-switch"; -import "../../../components/ha-tip"; import "../../../components/input/ha-input"; import { CAMERA_ORIENTATIONS, @@ -205,8 +204,6 @@ export class EntityRegistrySettingsEditor extends LitElement { @state() private _noDeviceArea?: boolean; - @query("ha-input.name") private _nameInput?: HTMLElement; - private _origEntityId!: string; private _deviceClassOptions?: string[][]; @@ -442,21 +439,33 @@ export class EntityRegistrySettingsEditor extends LitElement { ${this.hideName ? nothing : html` - - ${this._renderDeviceNameTip( - this._device, - this._name, - this.entry.name || "" - )}`} + inset-label + class="name" + .value=${this._name} + .label=${this.hass.localize( + "ui.dialogs.entity_registry.editor.name" + )} + .disabled=${this.disabled} + @input=${this._nameChanged} + > + ${this._device + ? html`${this.hass.localize( + "ui.dialogs.entity_registry.editor.device_name_tip", + { + link: html``, + } + )}` + : nothing} + `} ${this.hideIcon ? nothing : html` @@ -1611,36 +1620,6 @@ export class EntityRegistrySettingsEditor extends LitElement { }); } - private _renderDeviceNameTip = memoizeOne( - ( - device: DeviceRegistryEntry | undefined, - name: string, - originalName: string - ) => { - if (!device || name === originalName) { - return nothing; - } - return html` - ${this.hass.localize( - "ui.dialogs.entity_registry.editor.device_name_tip", - { - link: html``, - } - )} - `; - } - ); - private _switchAsDomainsSorted = memoizeOne( (domains: string[], localize: LocalizeFunc) => domains From 8e1474d7171eb358b5e5d4aeeae1f9aabd1a535c Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 13 May 2026 12:25:09 +0100 Subject: [PATCH 4/5] Slightly better message --- src/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/translations/en.json b/src/translations/en.json index 597f7f6c1c15..638df514d888 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1958,7 +1958,7 @@ "entity_disabled": "This entity is disabled.", "enable_entity": "Enable", "open_device_settings": "Open device settings", - "device_name_tip": "Rename the device to update all its entities at once. {link}", + "device_name_tip": "Consider renaming the device instead to update all its entities at once. {link}", "switch_as_x_confirm": "This switch will be hidden and a new {domain} will be added. Your existing configurations using the switch will continue to work.", "switch_as_x_remove_confirm": "This {domain} will be removed and the original switch will be visible again. Your existing configurations using the {domain} will no longer work!", "switch_as_x_change_confirm": "This {domain_1} will be removed and will be replaced by a new {domain_2}. Your existing configurations using the {domain_1} will no longer work!", From a2071db978e37d637eb1c61c54db868eb5c22cca Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 18 May 2026 10:32:45 +0100 Subject: [PATCH 5/5] Restore --- src/components/ha-tip.ts | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/src/components/ha-tip.ts b/src/components/ha-tip.ts index 71f43ae1706a..9f117db3e091 100644 --- a/src/components/ha-tip.ts +++ b/src/components/ha-tip.ts @@ -1,4 +1,3 @@ -import "@home-assistant/webawesome/dist/components/popup/popup"; import { mdiLightbulbOutline } from "@mdi/js"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; @@ -10,41 +9,18 @@ import "./ha-svg-icon"; class HaTip extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - /** - * When set, renders the tip inside a popup anchored to the given element - * instead of inline. Does not steal focus. - */ - @property({ attribute: false }) public popoverAnchor?: Element; - public render() { if (!this.hass) { return nothing; } - const content = html` + return html` ${this.hass.localize("ui.panel.config.tips.tip")} `; - - if (this.popoverAnchor) { - return html` - - - - `; - } - - return content; } static styles = css` @@ -64,14 +40,6 @@ class HaTip extends LitElement { .prefix { font-weight: var(--ha-font-weight-medium); } - - .popup-content { - padding: var(--ha-space-2) var(--ha-space-3); - background: var(--card-background-color); - border-radius: var(--ha-border-radius-xl); - box-shadow: var(--wa-shadow-m); - color: var(--primary-text-color); - } `; }