Compare commits

...

1 Commits

Author SHA1 Message Date
Aidan Timson 8dedee6e70 Migrate 7th set to dirty state provider 2026-06-09 15:25:52 +01:00
6 changed files with 222 additions and 109 deletions
+4 -6
View File
@@ -6,6 +6,9 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
superClass: T
) =>
class extends superClass {
/** Provided by `DirtyStateProviderMixin` or overridden by the consuming class. */
declare isDirtyState: boolean;
private _handleClick = async (e: MouseEvent) => {
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
const target = e.composedPath()[0];
@@ -33,7 +36,7 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirty) {
if (this.isDirtyState) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
@@ -47,11 +50,6 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
this._removeListeners();
}
// eslint-disable-next-line @typescript-eslint/class-literal-property-style
protected get isDirty(): boolean {
return false;
}
protected async promptDiscardChanges(): Promise<boolean> {
return true;
}
+59 -12
View File
@@ -1,22 +1,55 @@
import { consume } from "@lit/context";
import type { LitElement } from "lit";
import { state } from "lit/decorators";
import { closestWithProperty } from "../../../common/dom/ancestors-with-property";
import type { DirtyStateContext } from "../../../data/context/dirty-state";
import { dirtyStateContext } from "../../../data/context/dirty-state";
import type { ShowToastParams } from "../../../managers/notification-manager";
import { showToast } from "../../../util/toast";
export const EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET = 60;
function editorSaveFabVisibleFrom(el: HTMLElement): boolean {
if (
el.localName === "ha-automation-editor" ||
el.localName === "ha-script-editor"
) {
return Boolean((el as { dirty?: boolean }).dirty);
}
const holder = closestWithProperty(el, "dirty", false) as
| (HTMLElement & { dirty?: boolean })
| null;
return Boolean(holder?.dirty);
}
/**
* Mixin that consumes `dirtyStateContext` and exposes `editorDirty` for use
* in determining toast offset positioning.
*/
export const EditorToastDirtyConsumerMixin = <
Base extends abstract new (...args: any[]) => LitElement,
>(
superClass: Base
) => {
abstract class EditorToastDirtyConsumer extends superClass {
@state()
@consume({ context: dirtyStateContext, subscribe: true })
private _dirtyCtx?: DirtyStateContext;
protected get editorDirty(): boolean {
return Boolean(this._dirtyCtx?.isDirty);
}
protected showEditorToast(params: ShowToastParams): void {
const offset = this.editorDirty
? EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET
: undefined;
showToast(this, {
...params,
...(offset !== undefined ? { bottomOffset: offset } : {}),
dismissable: true,
});
}
}
return EditorToastDirtyConsumer as unknown as Base &
(abstract new (...args: any[]) => {
editorDirty: boolean;
showEditorToast(params: ShowToastParams): void;
});
};
/**
* Standalone function for callers that haven't adopted `EditorToastDirtyConsumerMixin`.
* Falls back to DOM traversal for dirty detection.
* @deprecated Use `EditorToastDirtyConsumerMixin` instead.
*/
export function showEditorToast(
el: HTMLElement,
params: ShowToastParams
@@ -30,3 +63,17 @@ export function showEditorToast(
dismissable: true,
});
}
/** @deprecated Use `EditorToastDirtyConsumerMixin` to consume dirty state from context. */
function editorSaveFabVisibleFrom(el: HTMLElement): boolean {
if (
el.localName === "ha-automation-editor" ||
el.localName === "ha-script-editor"
) {
return Boolean((el as { dirty?: boolean }).dirty);
}
const holder = closestWithProperty(el, "dirty", false) as
| (HTMLElement & { dirty?: boolean })
| null;
return Boolean(holder?.dirty);
}
@@ -20,6 +20,7 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type { Constructor, HomeAssistant, Route } from "../../../types";
import type { EntityRegistryUpdate } from "./automation-save-dialog/show-dialog-automation-save";
@@ -87,7 +88,9 @@ export interface EditorDomainHooks<TConfig> {
export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
superClass: Constructor<LitElement>
) => {
class AutomationScriptEditorClass extends superClass {
class AutomationScriptEditorClass extends DirtyStateProviderMixin<boolean>()(
superClass
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@@ -144,8 +147,16 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
private _relatedContextAreaId?: string;
connectedCallback(): void {
super.connectedCallback();
this._initDirtyTracking({ type: "shallow" }, false);
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("dirty")) {
this._updateDirtyState(this.dirty);
}
if (
changedProps.has("currentEntityId") ||
changedProps.has("entityRegistry")
@@ -237,10 +248,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
};
protected get isDirty() {
return this.dirty;
}
protected async promptDiscardChanges() {
return this.confirmUnsavedChanged();
}
+118 -69
View File
@@ -35,6 +35,7 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant } from "../../../types";
const IP_VERSIONS = ["ipv4", "ipv6"];
@@ -57,15 +58,15 @@ const PREDEFINED_DNS = {
};
@customElement("supervisor-network")
export class HassioNetwork extends LitElement {
export class HassioNetwork extends DirtyStateProviderMixin<NetworkInterface>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _accessPoints: AccessPoint[] = [];
@state() private _curTabIndex = 0;
@state() private _dirty = false;
@state() private _interface?: NetworkInterface;
@state() private _interfaces!: NetworkInterface[];
@@ -88,6 +89,7 @@ export class HassioNetwork extends LitElement {
a.primary > b.primary ? -1 : 1
);
this._interface = { ...this._interfaces[this._curTabIndex] };
this._initDirtyTracking({ type: "deep" }, this._interface);
}
protected render() {
@@ -230,7 +232,7 @@ export class HassioNetwork extends LitElement {
? this._renderIPConfiguration(version)
: nothing
)}
${this._dirty
${this.isDirtyState
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.supervisor.warning"
@@ -242,7 +244,7 @@ export class HassioNetwork extends LitElement {
<ha-button
.loading=${this._processing}
@click=${this._updateNetwork}
.disabled=${!this._dirty}
.disabled=${!this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -254,12 +256,17 @@ export class HassioNetwork extends LitElement {
private _selectAP(event) {
this._wifiConfiguration = event.currentTarget.ap;
let iface = this._interface!;
IP_VERSIONS.forEach((version) => {
if (this._interface![version]!.method === "disabled") {
this._interface![version]!.method = "auto";
if (iface[version]!.method === "disabled") {
iface = {
...iface,
[version]: { ...iface[version], method: "auto" },
};
}
});
this._dirty = true;
this._interface = iface;
this._updateDirtyState(this._interface);
}
private async _scanForAP() {
@@ -555,7 +562,7 @@ export class HassioNetwork extends LitElement {
this._interface!.interface,
interfaceOptions
);
this._dirty = false;
this._markDirtyStateClean();
await this._fetchNetworkInfo();
} catch (err: any) {
showAlertDialog(this, {
@@ -571,32 +578,38 @@ export class HassioNetwork extends LitElement {
private async _clear() {
await this._fetchNetworkInfo();
this._interface!.ipv4!.method = "auto";
this._interface!.ipv4!.nameservers = [];
this._interface!.ipv6!.method = "auto";
this._interface!.ipv6!.nameservers = [];
// removing the connection will disable the interface
// this is the only way to forget the wifi network right now
this._interface!.wifi = null;
this._interface = {
...this._interface!,
ipv4: {
...this._interface!.ipv4!,
method: "auto",
nameservers: [],
},
ipv6: {
...this._interface!.ipv6!,
method: "auto",
nameservers: [],
},
wifi: null,
};
this._wifiConfiguration = undefined;
this._dirty = true;
this.requestUpdate("_interface");
this._updateDirtyState(this._interface);
}
private async _handleTabActivated(ev: CustomEvent): Promise<void> {
if (this._dirty) {
if (this.isDirtyState) {
const confirm = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.network.supervisor.unsaved"),
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
});
if (!confirm) {
this.requestUpdate("_interface");
return;
}
}
this._curTabIndex = Number(ev.detail.name);
this._interface = { ...this._interfaces[this._curTabIndex] };
this._initDirtyTracking({ type: "deep" }, this._interface);
}
private _handleRadioValueChanged(ev: Event): void {
@@ -611,18 +624,19 @@ export class HassioNetwork extends LitElement {
) {
return;
}
this._dirty = true;
this._interface[version]!.method = value;
this.requestUpdate("_interface");
this._interface = {
...this._interface,
[version]: { ...this._interface[version], method: value },
};
this._updateDirtyState(this._interface);
}
private _handleRadioValueChangedAp(ev: Event): void {
const source = ev.currentTarget as HaRadioGroup;
const value = source.value as "open" | "wep" | "wpa-psk";
this._wifiConfiguration!.auth = value;
this._dirty = true;
this.requestUpdate("_wifiConfiguration");
this._wifiConfiguration = { ...this._wifiConfiguration!, auth: value };
this._updateDirtyState(this._interface!);
}
private _handleInputValueChanged(ev: Event): void {
@@ -636,35 +650,50 @@ export class HassioNetwork extends LitElement {
return;
}
this._dirty = true;
const versionData = this._interface[version];
if (id === "address") {
const index = (ev.target as any).index as number;
const { mask: oldMask } = parseAddress(
this._interface[version].address![index]
);
const { mask: oldMask } = parseAddress(versionData.address![index]);
const { mask } = parseAddress(value);
this._interface[version].address![index] = formatAddress(
value,
mask || oldMask || ""
);
this.requestUpdate("_interface");
const newAddress = [...versionData.address!];
newAddress[index] = formatAddress(value, mask || oldMask || "");
this._interface = {
...this._interface,
[version]: { ...versionData, address: newAddress },
};
} else if (id === "netmask") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = formatAddress(ip, value);
this.requestUpdate("_interface");
const { ip } = parseAddress(versionData.address![index]);
const newAddress = [...versionData.address!];
newAddress[index] = formatAddress(ip, value);
this._interface = {
...this._interface,
[version]: { ...versionData, address: newAddress },
};
} else if (id === "prefix") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = `${ip}/${value}`;
this.requestUpdate("_interface");
const { ip } = parseAddress(versionData.address![index]);
const newAddress = [...versionData.address!];
newAddress[index] = `${ip}/${value}`;
this._interface = {
...this._interface,
[version]: { ...versionData, address: newAddress },
};
} else if (id === "nameserver") {
const index = (ev.target as any).index as number;
this._interface[version].nameservers![index] = value;
this.requestUpdate("_interface");
const newNameservers = [...versionData.nameservers!];
newNameservers[index] = value;
this._interface = {
...this._interface,
[version]: { ...versionData, nameservers: newNameservers },
};
} else {
this._interface[version][id] = value;
this._interface = {
...this._interface,
[version]: { ...versionData, [id]: value },
};
}
this._updateDirtyState(this._interface);
}
private _handleInputValueChangedWifi(ev: Event): void {
@@ -680,26 +709,35 @@ export class HassioNetwork extends LitElement {
source.reportValidity();
return;
}
this._dirty = true;
this._wifiConfiguration![id] = value;
this._wifiConfiguration = { ...this._wifiConfiguration, [id]: value };
this._updateDirtyState(this._interface!);
}
private _addAddress(ev: Event): void {
const version = (ev.target as any).version as "ipv4" | "ipv6";
this._interface![version]!.address!.push(
version === "ipv4" ? "0.0.0.0/24" : "::/64"
);
this._dirty = true;
this.requestUpdate("_interface");
const newAddr = version === "ipv4" ? "0.0.0.0/24" : "::/64";
this._interface = {
...this._interface!,
[version]: {
...this._interface![version],
address: [...this._interface![version]!.address!, newAddr],
},
};
this._updateDirtyState(this._interface);
}
private _removeAddress(ev: Event): void {
const source = ev.target as any;
const index = source.index as number;
const version = source.version as "ipv4" | "ipv6";
this._interface![version]!.address!.splice(index, 1);
this._dirty = true;
this.requestUpdate("_interface");
const newAddress = this._interface![version]!.address!.filter(
(_, i) => i !== index
);
this._interface = {
...this._interface!,
[version]: { ...this._interface![version], address: newAddress },
};
this._updateDirtyState(this._interface);
}
private _handleDNSMenuOpened() {
@@ -711,30 +749,41 @@ export class HassioNetwork extends LitElement {
}
private _addPredefinedDNS(version: "ipv4" | "ipv6", addresses: string[]) {
if (!this._interface![version]!.nameservers) {
this._interface![version]!.nameservers = [];
}
this._interface![version]!.nameservers!.push(...addresses);
this._dirty = true;
this.requestUpdate("_interface");
const existing = this._interface![version]!.nameservers || [];
this._interface = {
...this._interface!,
[version]: {
...this._interface![version],
nameservers: [...existing, ...addresses],
},
};
this._updateDirtyState(this._interface);
}
private _addCustomDNS(version: "ipv4" | "ipv6") {
if (!this._interface![version]!.nameservers) {
this._interface![version]!.nameservers = [];
}
this._interface![version]!.nameservers!.push("");
this._dirty = true;
this.requestUpdate("_interface");
const existing = this._interface![version]!.nameservers || [];
this._interface = {
...this._interface!,
[version]: {
...this._interface![version],
nameservers: [...existing, ""],
},
};
this._updateDirtyState(this._interface);
}
private _removeNameserver(ev: Event): void {
const source = ev.target as any;
const index = source.index as number;
const version = source.version as "ipv4" | "ipv6";
this._interface![version]!.nameservers!.splice(index, 1);
this._dirty = true;
this.requestUpdate("_interface");
const newNameservers = this._interface![version]!.nameservers!.filter(
(_, i) => i !== index
);
this._interface = {
...this._interface!,
[version]: { ...this._interface![version], nameservers: newNameservers },
};
this._updateDirtyState(this._interface);
}
private _handleDropdownSelect(ev: HaDropdownSelectEvent) {
+8 -6
View File
@@ -76,6 +76,7 @@ import {
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
@@ -97,8 +98,8 @@ interface DeviceEntities {
type DeviceEntitiesLookup = Record<string, string[]>;
@customElement("ha-scene-editor")
export class HaSceneEditor extends PreventUnsavedMixin(
KeyboardShortcutMixin(LitElement)
export class HaSceneEditor extends DirtyStateProviderMixin<boolean>()(
PreventUnsavedMixin(KeyboardShortcutMixin(LitElement))
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -211,6 +212,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
public connectedCallback() {
super.connectedCallback();
this._initDirtyTracking({ type: "shallow" }, false);
if (!this.sceneId) {
this._mode = "live";
this._subscribeEvents();
@@ -603,6 +605,10 @@ export class HaSceneEditor extends PreventUnsavedMixin(
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_dirty")) {
this._updateDirtyState(this._dirty);
}
if (
this._entityRegCreated &&
this._newSceneId &&
@@ -1322,10 +1328,6 @@ export class HaSceneEditor extends PreventUnsavedMixin(
});
}
protected get isDirty() {
return this._dirty;
}
protected async promptDiscardChanges() {
return this._confirmUnsavedChanged();
}
+21 -11
View File
@@ -17,6 +17,7 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { Lovelace } from "./types";
@@ -33,7 +34,9 @@ const strategyStruct = type({
});
@customElement("hui-editor")
class LovelaceFullConfigEditor extends LitElement {
class LovelaceFullConfigEditor extends DirtyStateProviderMixin<boolean>()(
LitElement
) {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -44,8 +47,6 @@ class LovelaceFullConfigEditor extends LitElement {
@state() private _saving?: boolean;
@state() private _changed?: boolean;
private _config?: LovelaceRawConfig;
private _yamlError?: string;
@@ -66,10 +67,10 @@ class LovelaceFullConfigEditor extends LitElement {
slot="actionItems"
class="save-button
${classMap({
saved: this._saving === false || this._changed === true,
saved: this._saving === false || this.isDirtyState,
})}"
>
${this._changed
${this.isDirtyState
? this.hass!.localize(
"ui.panel.lovelace.editor.raw_editor.unsaved_changes"
)
@@ -78,7 +79,7 @@ class LovelaceFullConfigEditor extends LitElement {
<ha-button
slot="actionItems"
@click=${this._handleSave}
.disabled=${!this._changed}
.disabled=${!this.isDirtyState}
>${this.hass!.localize(
"ui.panel.lovelace.editor.raw_editor.save"
)}</ha-button
@@ -96,6 +97,14 @@ class LovelaceFullConfigEditor extends LitElement {
`;
}
connectedCallback(): void {
super.connectedCallback();
this._initDirtyTracking(
{ type: "custom", compare: (a, b) => a === b },
false
);
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.yamlEditor.setValue(this.lovelace!.rawConfig);
@@ -158,17 +167,18 @@ class LovelaceFullConfigEditor extends LitElement {
private _yamlChanged(ev: CustomEvent) {
this._config = ev.detail.isValid ? ev.detail.value : undefined;
this._yamlError = ev.detail.errorMsg;
this._changed = undoDepth(this.yamlEditor.codemirror!.state) > 0;
if (this._changed && !window.onbeforeunload) {
const changed = undoDepth(this.yamlEditor.codemirror!.state) > 0;
this._updateDirtyState(changed);
if (changed && !window.onbeforeunload) {
window.onbeforeunload = () => true;
} else if (!this._changed && window.onbeforeunload) {
} else if (!changed && window.onbeforeunload) {
window.onbeforeunload = null;
}
}
private async _closeEditor() {
if (
this._changed &&
this.isDirtyState &&
!(await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.lovelace.editor.raw_editor.confirm_unsaved_changes"
@@ -279,7 +289,7 @@ class LovelaceFullConfigEditor extends LitElement {
});
}
window.onbeforeunload = null;
this._changed = false;
this._markDirtyStateClean();
this._saving = false;
}