Compare commits

...

7 Commits

Author SHA1 Message Date
Aidan Timson 5ce62ca5ec Prevent scrim closure on category dirty state 2026-06-02 13:50:39 +01:00
Aidan Timson 8a8b4bf138 Move dirty state provider to dialog build level using deferred state 2026-06-02 13:40:22 +01:00
Aidan Timson 6f6c860338 remove cast 2026-06-02 12:32:51 +01:00
Aidan Timson a79051caf1 Fix loop 2026-06-02 11:36:31 +01:00
Aidan Timson 2e459e8029 Deep state (existing) 2026-06-02 11:29:51 +01:00
Aidan Timson 373855ddb2 Shallow state (new) 2026-06-02 11:22:40 +01:00
Aidan Timson af8f250b60 Dirty state context provider 2026-06-02 11:19:33 +01:00
7 changed files with 321 additions and 93 deletions
+21
View File
@@ -0,0 +1,21 @@
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");
+11 -3
View File
@@ -63,6 +63,7 @@ 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 {
@@ -121,8 +122,8 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends SubscribeMixin(
ScrollableFadeMixin(LitElement)
export class MoreInfoDialog extends DirtyStateProviderMixin()(
SubscribeMixin(ScrollableFadeMixin(LitElement))
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -640,7 +641,8 @@ export class MoreInfoDialog extends SubscribeMixin(
@closed=${this._dialogClosed}
@opened=${this._handleOpened}
@show-child-view=${this._showChildView}
.preventScrimClose=${this._currView === "settings" ||
.preventScrimClose=${(this._currView === "settings" &&
this.isDirtyState) ||
!this._isEscapeEnabled}
flexcontent
>
@@ -957,6 +959,12 @@ export class MoreInfoDialog extends SubscribeMixin(
}
}
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;
+193
View File
@@ -0,0 +1,193 @@
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;
};
@@ -15,13 +15,19 @@ 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 DialogMixin<CategoryRegistryDetailDialogParams>(
LitElement
class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
DialogMixin<CategoryRegistryDetailDialogParams>(LitElement)
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -44,6 +50,10 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
this._name = this.params?.suggestedName || "";
this._icon = null;
}
this._initDirtyTracking(
{ type: "shallow" },
{ name: this._name, icon: this._icon }
);
}
protected render() {
@@ -52,13 +62,14 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
}
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")}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -96,7 +107,9 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || !!this._submitting}
.disabled=${nameInvalid ||
!!this._submitting ||
(!isCreate && !this.isDirtyState)}
>
${entry
? this._i18n.localize("ui.common.save")
@@ -114,15 +127,17 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
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 {
@@ -131,10 +146,11 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
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,11 +1,13 @@
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";
@@ -33,14 +35,16 @@ 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")
@@ -60,13 +64,9 @@ export class EntitySettingsHelperTab extends LitElement {
super.updated(changedProperties);
if (changedProperties.has("entry")) {
this._error = undefined;
if (
this.entry.unique_id !==
(changedProperties.get("entry") as ExtEntityRegistryEntry)?.unique_id
) {
if (this.entry.unique_id !== changedProperties.get("entry")?.unique_id) {
this._item = undefined;
}
this._getItem();
}
}
@@ -107,7 +107,6 @@ 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>
@@ -124,7 +123,7 @@ export class EntitySettingsHelperTab extends LitElement {
</ha-button>
<ha-button
@click=${this._updateItem}
.disabled=${!this._dirty ||
.disabled=${!(this._dirtyState?.isDirty || this._isHelperDirty) ||
!!this._submitting ||
!!(this._item && !this._item.name)}
>
@@ -139,22 +138,12 @@ 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() {
@@ -167,15 +156,20 @@ 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,6 +45,7 @@ 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 {
@@ -144,6 +145,28 @@ 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;
@@ -158,41 +181,50 @@ 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!: string;
@state() private _entityId!: EntitySettingsState["entityId"];
@state() private _deviceClass?: string;
@state() private _deviceClass?: EntitySettingsState["deviceClass"];
@state() private _switchAsDomain = "switch";
@state() private _switchAsDomain: EntitySettingsState["switchAsDomain"] =
"switch";
@state() private _switchAsInvert = false;
@state() private _switchAsInvert: EntitySettingsState["switchAsInvert"] =
false;
@state() private _areaId?: string | null;
@state() private _labels?: string[] | null;
@state() private _disabledBy!: EntityRegistryEntry["disabled_by"];
@state() private _disabledBy!: EntitySettingsState["disabledBy"];
@state() private _hiddenBy!: EntityRegistryEntry["hidden_by"];
@state() private _hiddenBy!: EntitySettingsState["hiddenBy"];
@state() private _device?: DeviceRegistryEntry;
@state() private _unit_of_measurement?: string | null;
@state()
private _unit_of_measurement?: EntitySettingsState["unitOfMeasurement"];
@state() private _precision?: number | null;
@state() private _precision?: EntitySettingsState["precision"];
@state() private _precipitation_unit?: string | null;
@state()
private _precipitation_unit?: EntitySettingsState["precipitationUnit"];
@state() private _pressure_unit?: string | null;
@state() private _pressure_unit?: EntitySettingsState["pressureUnit"];
@state() private _temperature_unit?: string | null;
@state()
private _temperature_unit?: EntitySettingsState["temperatureUnit"];
@state() private _visibility_unit?: string | null;
@state() private _visibility_unit?: EntitySettingsState["visibilityUnit"];
@state() private _wind_speed_unit?: string | null;
@state() private _wind_speed_unit?: EntitySettingsState["windSpeedUnit"];
@state() private _cameraPrefs?: CameraPreferences;
@@ -204,9 +236,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _weatherConvertibleUnits?: WeatherUnits;
@state() private _defaultCode?: string | null;
@state() private _defaultCode?: EntitySettingsState["defaultCode"];
@state() private _calendarColor?: string | null;
@state() private _calendarColor?: EntitySettingsState["calendarColor"];
@state() private _associatedZone?: string;
@@ -216,11 +248,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _deviceClassOptions?: string[][];
private _initialStateJson!: string;
private _lastDirty = false;
private _currentState() {
private _currentState(): EntitySettingsState {
return {
name: this._name.trim() || null,
icon: this._icon.trim() || null,
@@ -315,9 +343,6 @@ 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)) {
@@ -416,17 +441,9 @@ 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");
}
}
this._dirtyState?.setState(this._currentState());
}
protected render() {
@@ -1146,10 +1163,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
`;
}
public get dirty(): boolean {
return JSON.stringify(this._currentState()) !== this._initialStateJson;
}
public async updateEntry(): Promise<{
close: boolean;
entry: ExtEntityRegistryEntry;
@@ -1435,12 +1448,10 @@ 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;
}
@@ -1459,22 +1470,18 @@ 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
@@ -1482,43 +1489,35 @@ 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;
}
@@ -1553,7 +1552,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _areaPicked(ev: CustomEvent) {
fireEvent(this, "change");
this._areaId = ev.detail.value;
}
@@ -1626,8 +1624,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _resetNameAndOpenDeviceSettings() {
this._name = this.entry.name || "";
fireEvent(this, "change");
this._openDeviceSettings();
}
@@ -2,6 +2,7 @@ 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";
@@ -13,6 +14,7 @@ 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 {
@@ -38,14 +40,16 @@ 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;
@@ -133,7 +137,6 @@ 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">
@@ -148,7 +151,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
</ha-button>
<ha-button
@click=${this._updateEntry}
.disabled=${!this._dirty || !!this._submitting}
.disabled=${!this._dirtyState?.isDirty || !!this._submitting}
.loading=${!!this._submitting}
>
${this.hass.localize("ui.dialogs.entity_registry.editor.update")}
@@ -157,11 +160,6 @@ 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!];
@@ -207,8 +205,10 @@ 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");
}