Compare commits

...

15 Commits

Author SHA1 Message Date
Franck Nijhof 45b43de020 Prevent error logger from crashing on unparseable stack traces
When createLogMessage received an error whose stack stacktrace-js could
not parse (for example a DOMException such as "AbortError: Transition was
skipped"), fromError threw "Cannot parse given Error object". That throw
escaped createLogMessage, so the global unhandledrejection handler logged
"Failure writing unhandled promise rejection to system log" and the
original error was never recorded.

Wrap the stacktrace extraction in a try/catch and fall back to the raw
error stack (or the provided stack fallback) so the logger stays robust
and still records the original error.
2026-06-12 20:28:35 +00:00
renovate[bot] 273967fe70 Update dependency prettier to v3.8.4 (#52569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 19:20:28 +03:00
renovate[bot] 382e07379b Update CodeMirror (#52567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 13:24:05 +02:00
Andreas Schneider 01a8b8d3ef Pass network_only: false when commissioning Matter devices (#52456)
The backend defaults network_only to True, which causes BLE
commissioning to be skipped even when BLE proxies are connected. Passing
false lets the Matter server use BLE when proxies are available while
falling back to network commissioning when they are not.

It is only set to false for the following path:

Settings -> Matter -> Options -> Add manually
2026-06-12 09:33:42 +03:00
renovate[bot] 3bbce5607e Update typescript-eslint monorepo to v8.61.0 (#52562)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 21:58:53 +02:00
Petar Petrov 7ce052e2a8 Reduce allocations in downSampleLineData (#52551)
Replace the per-point grouping (a Map of arrays of {point, x, y} wrapper
objects, plus a second pass that re-walks each frame and, in mean mode,
two reduce() passes) with a single fixed accumulator per frame:

- mean mode keeps running sumX/sumY/count in arrival order, so the
  floating-point summation order is unchanged
- min/max mode tracks the min and max point incrementally with the same
  strict comparisons (first occurrence wins on ties) and the same
  min-before-max emit ordering

Each point is touched once; ~100k transient objects per large payload are
eliminated. Output is bit-identical. Speedups range from ~1.2x (mid-size)
to ~1.5-1.8x (100k-point) payloads, with substantially lower run-to-run
variance from reduced GC pressure. Payloads below maxDetails still
early-return unchanged.
2026-06-11 19:57:31 +02:00
Petar Petrov e929558a9a Fix clipped descenders in chart legend labels (#52554) 2026-06-11 19:47:16 +02:00
Aidan Timson 9cd4a6937f Migrate backup dialogs to dirty state provider and dialog behavior (#52549) 2026-06-11 17:12:57 +03:00
karwosts af617695b8 Make 'Add Card' more robust to bad yaml (#52556) 2026-06-11 17:08:05 +03:00
Aidan Timson 740ad9eb6b Migrate to dirty state provider for 5 dialogs (#52546)
* Add DirtyStateProviderMixin to dialogs (areas, backup, energy, helpers, person, voice assistant)

Migrate dialogs to DirtyStateProviderMixin for dirty state tracking via
Lit context. These dialogs do not use PreventUnsavedMixin and are fully
independent of the PreventUnsavedMixin contract change.

* Add disabled state

* Fix disable state

* Add check
2026-06-11 17:03:37 +03:00
Aidan Timson caeedc41e3 Migrate second set of dashboard dialogs to dirty state provider (#52538)
* Migrate second set of dashboard dialogs to dirty state provider

* Fix create dialog

* Add DirtyStateProviderMixin to Lovelace raw config editor

* Fix yaml mode check from review

* Fix
2026-06-11 16:46:24 +03:00
Bram Kragten fbb76a8ba0 Filter expired camera/image proxy requests in service worker (#52534)
Pre-validate the credential on camera_proxy, camera_proxy_stream and
image_proxy URLs before letting them hit core. Requests with a missing
or "undefined" token, or with an authSig JWT whose exp has passed, are
short-circuited to a synthetic 401 and never reach the server.

This silences spurious "Login attempt or request with invalid
authentication" warnings from homeassistant.components.http.ban that
fire when the browser replays a stale <img src> after BFCache restore,
tab resume, or a network change. The signed-path TTL is short (30s by
default) and image elements happily hold onto the URL long after that.

Limitations: service workers only run on secure contexts, so this does
not help users on plain http LAN access. A core-side fix to ban.py
that distinguishes expired-but-validly-signed paths from real login
attempts remains the principled fix and covers all clients.
2026-06-11 16:35:32 +03:00
Aidan Timson 3340637ff3 Migrate to dirty state provider for 4 areas (#52545)
* Add DirtyStateProviderMixin to dialogs (config entry, credentials, supervisor apps, voice assistant, zones)

Migrate dialogs to DirtyStateProviderMixin for dirty state tracking via
Lit context. These dialogs do not use PreventUnsavedMixin and are fully
independent of the PreventUnsavedMixin contract change.

* Fix disabled state

* Fix disabled state

* Drop voice assistant pipeline dialog changes (handled in dialogs-b)
2026-06-11 16:21:01 +03:00
Petar Petrov 534bea231c Open more-info from energy pie chart legend, enlarge legend toggle on touch (#52506) 2026-06-11 15:18:46 +02:00
Aidan Timson 8635951394 Migrate user dialogs to use dirty state provider (#52537)
* Migrate user dialogs to use dirty state provider

* Restore original dialog setups
2026-06-11 15:14:26 +03:00
38 changed files with 982 additions and 427 deletions
+4 -4
View File
@@ -34,10 +34,10 @@
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.6",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.8",
@@ -186,7 +186,7 @@
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.3",
"prettier": "3.8.4",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
@@ -194,7 +194,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.60.1",
"typescript-eslint": "8.61.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"webpack-stats-plugin": "1.1.3",
+86 -46
View File
@@ -1,5 +1,23 @@
import type { LineSeriesOption } from "echarts";
type Point = NonNullable<LineSeriesOption["data"]>[number];
interface MeanFrame {
sumX: number;
sumY: number;
count: number;
isArray: boolean;
}
interface MinMaxFrame {
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -19,11 +37,47 @@ export function downSampleLineData<
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
// Group points into frames
const frames = new Map<
number,
{ point: (typeof data)[number]; x: number; y: number }[]
>();
if (useMean) {
// Group points into frames, accumulating sums in insertion order.
const frames = new Map<number, MeanFrame>();
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
sumX: x,
sumY: y,
count: 1,
isArray: Array.isArray(pointData),
});
} else {
frame.sumX += x;
frame.sumY += y;
frame.count += 1;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
const meanX = frame.sumX / frame.count;
const meanY = frame.sumY / frame.count;
const meanPoint = (
frame.isArray ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
return result;
}
// Min/max mode: track the min and max point per frame in insertion order.
const frames = new Map<number, MinMaxFrame>();
for (const point of data) {
const pointData = getPointData(point);
@@ -35,53 +89,39 @@ export function downSampleLineData<
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, [{ point, x, y }]);
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
frame.push({ point, x, y });
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
}
if (y > frame.maxY) {
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
}
// Convert frames back to points
const result: T[] = [];
if (useMean) {
// Use mean values for each frame
for (const [_i, framePoints] of frames) {
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
const meanY = sumY / framePoints.length;
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
const meanX = sumX / framePoints.length;
const firstPoint = framePoints[0].point;
const pointData = getPointData(firstPoint);
const meanPoint = (
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
} else {
// Use min/max values for each frame
for (const [_i, framePoints] of frames) {
let minPoint = framePoints[0];
let maxPoint = framePoints[0];
for (const p of framePoints) {
if (p.y < minPoint.y) {
minPoint = p;
}
if (p.y > maxPoint.y) {
maxPoint = p;
}
}
// The order of the data must be preserved so max may be before min
if (minPoint.x > maxPoint.x) {
result.push(maxPoint.point);
}
result.push(minPoint.point);
if (minPoint.x < maxPoint.x) {
result.push(maxPoint.point);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
+22 -1
View File
@@ -1520,7 +1520,9 @@ export class HaChartBase extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
line-height: 1;
/* overflow: hidden clips descenders (e.g. "g", parentheses) with a tight
line-height, so give the line box room to contain them */
line-height: var(--ha-line-height-condensed);
}
@media (hover: hover) {
.chart-legend .label.clickable:hover {
@@ -1558,6 +1560,25 @@ export class HaChartBase extends LitElement {
.chart-legend .legend-toggle ha-svg-icon {
--mdc-icon-size: 18px;
}
/* On touch devices, enlarge the toggle tap target via taller rows and
leading padding (which also separates it from the previous item), while
keeping the icon tight to its own label so the pairing stays clear.
Drop the now-pointless row gap and li padding. */
@media (pointer: coarse) {
.chart-legend ul {
row-gap: 0;
}
/* Only grow the toggle rows, not the expand/collapse chip's row. */
.chart-legend li:has(.legend-toggle) {
height: 40px;
padding: 0;
}
.chart-legend .legend-toggle {
padding: 11px;
padding-inline-end: 4px;
margin: 0;
}
}
ha-assist-chip {
height: 100%;
--_label-text-weight: 500;
+3 -1
View File
@@ -128,11 +128,13 @@ export const addMatterDevice = (hass: HomeAssistant) => {
export const commissionMatterDevice = (
hass: HomeAssistant,
code: string
code: string,
networkOnly: boolean
): Promise<void> =>
hass.callWS({
type: "matter/commission",
code,
network_only: networkOnly,
});
export const acceptSharedMatterDevice = (
@@ -10,13 +10,21 @@ import "../../components/ha-button";
import type { HaSwitch } from "../../components/ha-switch";
import type { ConfigEntryMutableParams } from "../../data/config_entries";
import { updateConfigEntry } from "../../data/config_entries";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { ConfigEntrySystemOptionsDialogParams } from "./show-dialog-config-entry-system-options";
interface SystemOptionsState {
disableNewEntities: boolean;
disablePolling: boolean;
}
@customElement("dialog-config-entry-system-options")
class DialogConfigEntrySystemOptions extends LitElement {
class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptionsState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _disableNewEntities!: boolean;
@@ -38,6 +46,13 @@ class DialogConfigEntrySystemOptions extends LitElement {
this._error = undefined;
this._disableNewEntities = params.entry.pref_disable_new_entities;
this._disablePolling = params.entry.pref_disable_polling;
this._initDirtyTracking(
{ type: "shallow" },
{
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
}
);
this._open = true;
}
@@ -68,7 +83,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
) || this._params.entry.domain,
}
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
@@ -135,7 +150,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize(
"ui.dialogs.config_entry_system_options.update"
@@ -149,11 +164,19 @@ class DialogConfigEntrySystemOptions extends LitElement {
private _disableNewEntitiesChanged(ev: Event): void {
this._error = undefined;
this._disableNewEntities = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private _disablePollingChanged(ev: Event): void {
this._error = undefined;
this._disablePolling = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private async _updateEntry(): Promise<void> {
+67
View File
@@ -19,6 +19,64 @@ declare const __WB_MANIFEST__: Parameters<typeof precacheAndRoute>[0];
const noFallBackRegEx =
/\/(api|static|auth|frontend_latest|frontend_es5|local)\/.*/;
// Camera / image proxy endpoints that carry credentials in the URL.
// We pre-validate the credential in the service worker so obviously invalid
// requests (signature expired, token missing) never reach the server and
// don't trigger spurious "Login attempt" warnings from http.ban after BFCache
// restore, tab resume, network change, or any other browser-initiated replay
// of a stale `<img>` URL.
const proxyPathRegEx =
/^\/api\/(camera_proxy_stream|camera_proxy|image_proxy)\//;
// Reject signatures this many ms before their nominal expiry to absorb small
// client/server clock differences. Erring this direction only ever turns a
// would-be valid request into a local 401; we cannot err the other way without
// re-introducing the warnings this filter exists to prevent.
const JWT_EXPIRY_SKEW_MS = 5000;
const base64UrlDecode = (input: string): string => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return atob(padded);
};
const isJwtExpired = (jwt: string): boolean => {
try {
const parts = jwt.split(".");
if (parts.length !== 3) {
return false;
}
const payload = JSON.parse(base64UrlDecode(parts[1]));
if (typeof payload.exp !== "number") {
return false;
}
return payload.exp * 1000 < Date.now() + JWT_EXPIRY_SKEW_MS;
} catch (_err) {
// If we can't parse the JWT for any reason, defer to the server.
return false;
}
};
const handleProxyRequest: RouteHandler = async ({ request }) => {
const req = request as Request;
const url = new URL(req.url);
const token = url.searchParams.get("token");
if (token === "undefined" || token === "null" || token === "") {
return new Response(null, { status: 401, statusText: "Invalid token" });
}
const authSig = url.searchParams.get("authSig");
if (authSig && isJwtExpired(authSig)) {
return new Response(null, {
status: 401,
statusText: "Signature expired",
});
}
return fetch(req);
};
const initRouting = () => {
precacheAndRoute(__WB_MANIFEST__, {
// Ignore all URL parameters.
@@ -59,6 +117,15 @@ const initRouting = () => {
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
registerRoute(
({ url, request }) =>
proxyPathRegEx.test(url.pathname) && request.method === "GET",
handleProxyRequest
);
// Get api from network.
registerRoute(/\/(api|auth)\/.*/, new NetworkOnly());
@@ -23,18 +23,28 @@ import {
} from "../../../data/application_credential";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential";
interface CredentialFormState {
domain: string;
name: string;
clientId: string;
clientSecret: string;
}
interface Domain {
id: string;
name: string;
}
@customElement("dialog-add-application-credential")
export class DialogAddApplicationCredential extends LitElement {
export class DialogAddApplicationCredential extends DirtyStateProviderMixin<CredentialFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
@@ -76,6 +86,7 @@ export class DialogAddApplicationCredential extends LitElement {
this._error = undefined;
this._loading = false;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, this._currentState());
this._fetchConfig();
}
@@ -100,10 +111,7 @@ export class DialogAddApplicationCredential extends LitElement {
<ha-dialog
.open=${this._open}
@closed=${this._abortDialog}
.preventScrimClose=${!!this._domain ||
!!this._name ||
!!this._clientId ||
!!this._clientSecret}
.preventScrimClose=${this.isDirtyState}
.headerTitle=${this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)}
@@ -284,6 +292,7 @@ export class DialogAddApplicationCredential extends LitElement {
ev.stopPropagation();
this._domain = ev.detail.value;
this._updateDescription();
this._updateDirtyState(this._currentState());
}
private async _updateDescription() {
@@ -307,6 +316,16 @@ export class DialogAddApplicationCredential extends LitElement {
const name = (ev.target as any).name;
const value = (ev.target as any).value;
this[`_${name}`] = value;
this._updateDirtyState(this._currentState());
}
private _currentState(): CredentialFormState {
return {
domain: this._domain || "",
name: this._name || "",
clientId: this._clientId || "",
clientSecret: this._clientSecret || "",
};
}
private _abortDialog() {
@@ -32,6 +32,7 @@ import {
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import type { ObjectSelector, Selector } from "../../../../../data/selector";
import { 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 { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
@@ -56,15 +57,15 @@ const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
const MASKED_FIELDS = ["password", "secret", "token"];
@customElement("supervisor-app-config")
class SupervisorAppConfig extends LitElement {
class SupervisorAppConfig extends DirtyStateProviderMixin<
Record<string, unknown>
>()(LitElement) {
@property({ attribute: false }) public addon!: HassioAddonDetails;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@state() private _configHasChanged = false;
@state() private _valid = true;
@state() private _canShowSchema = false;
@@ -351,9 +352,7 @@ class SupervisorAppConfig extends LitElement {
<div class="card-actions right">
<ha-progress-button
@click=${this._saveTapped}
.disabled=${this.disabled ||
!this._configHasChanged ||
!this._valid}
.disabled=${this.disabled || !this.isDirtyState || !this._valid}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -377,6 +376,7 @@ class SupervisorAppConfig extends LitElement {
protected updated(changedProperties: PropertyValues): void {
if (changedProperties.has("addon")) {
this._options = { ...this.addon.options };
this._initDirtyTracking({ type: "deep" }, this.addon.options);
}
super.updated(changedProperties);
if (
@@ -415,11 +415,13 @@ class SupervisorAppConfig extends LitElement {
private _configChanged(ev): void {
if (this.addon.schema && this._canShowSchema && !this._yamlMode) {
this._valid = true;
this._configHasChanged = true;
this._options = ev.detail.value;
this._updateDirtyState(ev.detail.value);
} else {
this._configHasChanged = true;
this._valid = ev.detail.isValid;
if (ev.detail.isValid) {
this._updateDirtyState(ev.detail.value);
}
}
}
@@ -450,7 +452,7 @@ class SupervisorAppConfig extends LitElement {
};
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._configHasChanged = false;
this._markDirtyStateClean();
const eventdata = {
success: true,
response: undefined,
@@ -469,7 +471,7 @@ class SupervisorAppConfig extends LitElement {
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (this.disabled || !this._configHasChanged || !this._valid) {
if (this.disabled || !this.isDirtyState || !this._valid) {
return;
}
@@ -499,7 +501,7 @@ class SupervisorAppConfig extends LitElement {
options,
});
this._configHasChanged = false;
this._markDirtyStateClean();
if (this.addon?.state === "started") {
await suggestSupervisorAppRestart(this, this.hass, this.addon);
}
@@ -15,13 +15,16 @@ import type {
} from "../../../../../data/hassio/addon";
import { setHassioAddonOption } from "../../../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
@customElement("supervisor-app-network")
class SupervisorAppNetwork extends LitElement {
class SupervisorAppNetwork extends DirtyStateProviderMixin<
Record<string, number | null>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@@ -30,19 +33,19 @@ class SupervisorAppNetwork extends LitElement {
@state() private _showOptional = false;
@state() private _configHasChanged = false;
@state() private _error?: string;
@state() private _config?: Record<string, any>;
@state() private _config?: Record<string, number | null>;
protected render() {
if (!this._config) {
return nothing;
}
const hasHiddenOptions = Object.keys(this._config).find(
(entry) => this._config![entry] === null
const config = this._config;
const hasHiddenOptions = Object.keys(config).find(
(entry) => config[entry] === null
);
return html`
@@ -98,7 +101,7 @@ class SupervisorAppNetwork extends LitElement {
</ha-progress-button>
<ha-progress-button
@click=${this._saveTapped}
.disabled=${!this._configHasChanged || this.disabled}
.disabled=${!this.isDirtyState || this.disabled}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -115,7 +118,10 @@ class SupervisorAppNetwork extends LitElement {
}
private _createSchema = memoizeOne(
(config: Record<string, number>, showOptional: boolean): HaFormSchema[] =>
(
config: Record<string, number | null>,
showOptional: boolean
): HaFormSchema[] =>
(showOptional
? Object.keys(config)
: Object.keys(config).filter((entry) => config[entry] !== null)
@@ -141,12 +147,14 @@ class SupervisorAppNetwork extends LitElement {
item.name;
private _setNetworkConfig(): void {
this._config = this.addon.network || {};
const config = this.addon.network || {};
this._config = config;
this._initDirtyTracking({ type: "shallow" }, config);
}
private async _configChanged(ev: CustomEvent): Promise<void> {
this._configHasChanged = true;
private _configChanged(ev: CustomEvent): void {
this._config = ev.detail.value;
this._updateDirtyState(ev.detail.value);
}
private async _resetTapped(ev: CustomEvent): Promise<void> {
@@ -161,7 +169,7 @@ class SupervisorAppNetwork extends LitElement {
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._configHasChanged = false;
this._markDirtyStateClean();
const eventdata = {
success: true,
response: undefined,
@@ -188,14 +196,14 @@ class SupervisorAppNetwork extends LitElement {
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (!this._configHasChanged || this.disabled) {
if (!this.isDirtyState || this.disabled) {
return;
}
const button = ev.currentTarget as any;
this._error = undefined;
const networkconfiguration = {};
const networkconfiguration: Record<string, number | null> = {};
Object.entries(this._config!).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
@@ -206,7 +214,7 @@ class SupervisorAppNetwork extends LitElement {
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._configHasChanged = false;
this._markDirtyStateClean();
const eventdata = {
success: true,
response: undefined,
@@ -3,6 +3,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
@@ -54,9 +55,20 @@ const SENSOR_DOMAINS = ["sensor"];
const TEMPERATURE_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_TEMPERATURE];
const HUMIDITY_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_HUMIDITY];
interface AreaFormState {
name: string;
aliases: string[];
labels: string[];
picture: string | null;
icon: string | null;
floor: string | null;
temperatureEntity: string | null;
humidityEntity: string | null;
}
@customElement("dialog-area-registry-detail")
class DialogAreaDetail
extends LitElement
extends DirtyStateProviderMixin<AreaFormState>()(LitElement)
implements HassDialog<AreaRegistryDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -116,9 +128,23 @@ class DialogAreaDetail
this._humidityEntity = null;
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
}
private _currentState(): AreaFormState {
return {
name: this._name,
aliases: this._aliases,
labels: this._labels,
picture: this._picture,
icon: this._icon,
floor: this._floor,
temperatureEntity: this._temperatureEntity,
humidityEntity: this._humidityEntity,
};
}
public closeDialog(): boolean {
this._open = false;
return true;
@@ -326,6 +352,8 @@ class DialogAreaDetail
if (processed.floor) {
this._floor = processed.floor;
}
this._updateDirtyState(this._currentState());
}
protected render() {
@@ -343,7 +371,7 @@ class DialogAreaDetail
header-title=${entry
? this.hass.localize("ui.panel.config.areas.editor.update_area")
: this.hass.localize("ui.panel.config.areas.editor.create_area")}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-suggest-with-ai-button
@@ -384,7 +412,9 @@ class DialogAreaDetail
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || this._submitting}
.disabled=${nameInvalid ||
this._submitting ||
(!!this._params?.entry && !this.isDirtyState)}
>
${entry
? this.hass.localize("ui.common.save")
@@ -418,36 +448,43 @@ class DialogAreaDetail
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _floorChanged(ev) {
this._error = undefined;
this._floor = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _labelsChanged(ev) {
this._error = undefined;
this._labels = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
}
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _sensorChanged(ev: CustomEvent): void {
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
const key = `_${deviceClass}Entity`;
this[key] = ev.detail.value || null;
this._updateDirtyState(this._currentState());
}
private async _updateEntry() {
@@ -469,6 +506,7 @@ class DialogAreaDetail
} else {
await this._params!.updateEntry!(values);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
@@ -15,6 +15,7 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/item/ha-row-item";
import "../../../../components/list/ha-list-base";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import type {
BackupConfig,
BackupMutableConfig,
@@ -82,7 +83,10 @@ const RECOMMENDED_CONFIG: BackupConfig = {
};
@customElement("ha-dialog-backup-onboarding")
class DialogBackupOnboarding extends LitElement implements HassDialog {
class DialogBackupOnboarding
extends DirtyStateProviderMixin<BackupConfig>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -115,6 +119,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._config!);
}
public closeDialog() {
@@ -169,6 +174,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
try {
await this._save(true);
this._params?.submit!(true);
this._markDirtyStateClean();
this.closeDialog();
} catch (err) {
// eslint-disable-next-line no-console
@@ -214,7 +220,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
<ha-dialog
.open=${this._open}
header-title=${this._stepTitle}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${isFirstStep
@@ -293,6 +299,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
password: this._config.create_backup.password,
},
};
this._updateDirtyState(this._config);
this._done();
}
@@ -515,6 +522,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
include_addons: data.include_addons || null,
},
};
this._updateDirtyState(this._config);
}
private _scheduleChanged(ev) {
@@ -524,6 +532,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
schedule: value.schedule,
retention: value.retention,
};
this._updateDirtyState(this._config);
}
private _agentsConfigChanged(ev) {
@@ -535,6 +544,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
agent_ids: agents,
},
};
this._updateDirtyState(this._config);
}
static get styles(): CSSResultGroup {
@@ -12,13 +12,17 @@ import {
getPreferredAgentForDownload,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { downloadBackupFile } from "../helper/download_backup";
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
@customElement("ha-dialog-download-decrypted-backup")
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
class DialogDownloadDecryptedBackup
extends DirtyStateProviderMixin<string>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -32,6 +36,7 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._open = true;
this._params = params;
this._initDirtyTracking({ type: "shallow" }, "");
}
public closeDialog() {
@@ -60,7 +65,7 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<p>
@@ -105,7 +110,11 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
<ha-button
slot="primaryAction"
@click=${this._submit}
.disabled=${!this.isDirtyState}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
@@ -136,6 +145,7 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
this._agentId,
this._encryptionKey
);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
if (err?.code === "password_incorrect") {
@@ -155,6 +165,7 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
private _keyChanged(ev) {
this._encryptionKey = ev.currentTarget.value;
this._error = "";
this._updateDirtyState(this._encryptionKey);
}
private get _agentId() {
@@ -28,6 +28,7 @@ import {
fetchBackupConfig,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "../components/config/ha-backup-config-data";
@@ -59,7 +60,10 @@ const STEPS = ["data", "sync"] as const;
const DISALLOWED_AGENTS_NO_HA = [CLOUD_AGENT];
@customElement("ha-dialog-generate-backup")
class DialogGenerateBackup extends LitElement implements HassDialog {
class DialogGenerateBackup
extends DirtyStateProviderMixin<FormData>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _step?: "data" | "sync";
@@ -79,6 +83,8 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
this._formData = INITIAL_DATA;
this._params = _params;
this._open = true;
this._initDirtyTracking({ type: "deep" }, INITIAL_DATA);
this._updateDirtyState(this._formData);
this._fetchAgents();
this._fetchBackupConfig();
@@ -160,6 +166,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
agents_mode: "custom",
agent_ids: filteredAgents,
};
this._updateDirtyState(this._formData);
}
}
}
@@ -180,7 +187,11 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
const selectedAgents = this._formData.agent_ids;
return html`
<ha-dialog .open=${this._open} @closed=${this._dialogClosed}>
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-dialog-header slot="header">
${isFirstStep
? html`
@@ -276,6 +287,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
...this._formData!,
data,
};
this._updateDirtyState(this._formData);
}
private _renderSync() {
@@ -370,6 +382,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
...this._formData!,
agents_mode: value,
};
this._updateDirtyState(this._formData);
}
private _agentsChanged(ev) {
@@ -377,6 +390,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
...this._formData!,
agent_ids: ev.detail.value,
};
this._updateDirtyState(this._formData);
}
private _nameChanged(ev: InputEvent) {
@@ -384,6 +398,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
...this._formData!,
name: (ev.target as HaInput).value ?? "",
};
this._updateDirtyState(this._formData);
}
private _disabledAgentIds() {
@@ -429,6 +444,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
}
this._params!.submit?.(params);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -13,6 +13,7 @@ import type {
} from "../../../../components/ha-form/types";
import { extractApiErrorMessage } from "../../../../data/hassio/common";
import { changeMountOptions } from "../../../../data/supervisor/mounts";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { LocalBackupLocationDialogParams } from "./show-dialog-local-backup-location";
@@ -25,8 +26,14 @@ const SCHEMA = [
},
] as const satisfies HaFormSchema[];
interface LocalBackupLocationFormState {
default_backup_mount: string | null | undefined;
}
@customElement("dialog-local-backup-location")
class LocalBackupLocationDialog extends LitElement {
class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocationFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _dialogParams?: LocalBackupLocationDialogParams;
@@ -44,6 +51,10 @@ class LocalBackupLocationDialog extends LitElement {
): Promise<void> {
this._dialogParams = dialogParams;
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{ default_backup_mount: undefined }
);
}
public closeDialog(): void {
@@ -68,7 +79,7 @@ class LocalBackupLocationDialog extends LitElement {
header-title=${this.hass.localize(
`ui.panel.config.backup.dialogs.local_backup_location.title`
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error
@@ -102,7 +113,7 @@ class LocalBackupLocationDialog extends LitElement {
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
.disabled=${this._waiting || !this._data}
.disabled=${this._waiting || !this.isDirtyState}
slot="primaryAction"
@click=${this._changeMount}
>
@@ -125,6 +136,9 @@ class LocalBackupLocationDialog extends LitElement {
this._data = {
default_backup_mount: newLocation === "/backup" ? null : newLocation,
};
this._updateDirtyState({
default_backup_mount: this._data.default_backup_mount,
});
}
private async _changeMount() {
@@ -140,6 +154,7 @@ class LocalBackupLocationDialog extends LitElement {
this._waiting = false;
return;
}
this._markDirtyStateClean();
this.closeDialog();
}
@@ -52,7 +52,6 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
)}
prevent-scrim-close
@closed=${this.closeDialog}
>
<ha-icon-button
@@ -21,6 +21,7 @@ import {
type BackupUploadFileFormData,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
@@ -28,7 +29,7 @@ import type { UploadBackupDialogParams } from "./show-dialog-upload-backup";
@customElement("ha-dialog-upload-backup")
export class DialogUploadBackup
extends LitElement
extends DirtyStateProviderMixin<BackupUploadFileFormData>()(LitElement)
implements HassDialog<UploadBackupDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -47,6 +48,8 @@ export class DialogUploadBackup
this._params = params;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, INITIAL_UPLOAD_FORM_DATA);
this._updateDirtyState(this._formData);
}
private _dialogClosed() {
@@ -64,10 +67,6 @@ export class DialogUploadBackup
return true;
}
private _formValid() {
return this._formData?.file !== undefined;
}
protected render() {
if (!this._params || !this._formData) {
return nothing;
@@ -79,7 +78,7 @@ export class DialogUploadBackup
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.title"
)}
?prevent-scrim-close=${this._uploading}
.preventScrimClose=${this.isDirtyState || this._uploading}
@closed=${this._dialogClosed}
>
${this._error
@@ -112,7 +111,7 @@ export class DialogUploadBackup
<ha-button
slot="primaryAction"
@click=${this._upload}
.disabled=${!this._formValid() || this._uploading}
.disabled=${!this.isDirtyState || this._uploading}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.action"
@@ -131,11 +130,13 @@ export class DialogUploadBackup
...this._formData!,
file,
};
this._updateDirtyState(this._formData);
}
private _filesCleared() {
this._error = undefined;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._updateDirtyState(this._formData);
}
private async _upload() {
@@ -161,6 +162,7 @@ export class DialogUploadBackup
try {
await uploadBackup(this.hass, file, agentIds);
this._params!.submit?.();
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -18,6 +18,7 @@ import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { getConfigFlowHandlers } from "../../../data/config_flow";
import { createCounter } from "../../../data/counter";
import { createInputBoolean } from "../../../data/input_boolean";
@@ -101,7 +102,9 @@ const HELPERS: HelperCreators = {
};
@customElement("dialog-helper-detail")
export class DialogHelperDetail extends LitElement {
export class DialogHelperDetail extends DirtyStateProviderMixin<
Helper | undefined
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _item?: Helper;
@@ -137,6 +140,7 @@ export class DialogHelperDetail extends LitElement {
this._item = undefined;
if (this._domain && this._domain in HELPERS) {
await HELPERS[this._domain].import();
this._initDirtyTracking({ type: "deep" }, undefined);
}
this._open = true;
await this.updateComplete;
@@ -293,6 +297,7 @@ export class DialogHelperDetail extends LitElement {
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
header-title=${this._domain
? this.hass.localize(
"ui.panel.config.helpers.dialog.create_platform",
@@ -364,6 +369,7 @@ export class DialogHelperDetail extends LitElement {
private _valueChanged(ev: CustomEvent): void {
this._item = ev.detail.value;
this._updateDirtyState(this._item);
}
private async _createItem(): Promise<void> {
@@ -383,6 +389,7 @@ export class DialogHelperDetail extends LitElement {
entityId: `${this._domain}.${createdEntity.id}`,
});
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message || "Unknown error";
@@ -410,6 +417,7 @@ export class DialogHelperDetail extends LitElement {
try {
await HELPERS[domain].import();
this._domain = domain;
this._initDirtyTracking({ type: "deep" }, undefined);
} finally {
this._loading = false;
}
@@ -283,7 +283,7 @@ class DialogMatterAddDevice extends LitElement {
const savedStep = this._step;
try {
this._step = "commissioning";
await commissionMatterDevice(this.hass, code);
await commissionMatterDevice(this.hass, code, true);
} catch (_err) {
showToast(this, {
message: this.hass.localize(
@@ -211,7 +211,7 @@ class MatterOptionsPage extends LitElement {
this._error = undefined;
this._redirectOnNewMatterDevice();
try {
await commissionMatterDevice(this.hass, code);
await commissionMatterDevice(this.hass, code, false);
} catch (err: any) {
this._error = err.message;
this._stopRedirect();
@@ -6,13 +6,16 @@ import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../lovelace/editor/dashboard-strategy-editor/hui-dashboard-strategy-element-editor";
import type { LovelaceDashboardConfigureStrategyDialogParams } from "./show-dialog-lovelace-dashboard-configure-strategy";
@customElement("dialog-lovelace-dashboard-configure-strategy")
export class DialogLovelaceDashboardConfigureStrategy extends LitElement {
export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProviderMixin<LovelaceStrategyConfig>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardConfigureStrategyDialogParams;
@@ -29,6 +32,7 @@ export class DialogLovelaceDashboardConfigureStrategy extends LitElement {
this._params = params;
this._data = params.config.strategy;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -49,7 +53,7 @@ export class DialogLovelaceDashboardConfigureStrategy extends LitElement {
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
header-title=${this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)}
@@ -80,6 +84,7 @@ export class DialogLovelaceDashboardConfigureStrategy extends LitElement {
private _handleConfigChanged(ev: CustomEvent): void {
this._data = ev.detail.config;
this._updateDirtyState(this._data!);
}
private async _save() {
@@ -92,6 +97,7 @@ export class DialogLovelaceDashboardConfigureStrategy extends LitElement {
strategy: this._data,
});
this._submitting = false;
this._markDirtyStateClean();
this.closeDialog();
}
@@ -14,13 +14,16 @@ import type {
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { pickAvailableDashboardUrlPath } from "./pick-available-dashboard-url-path";
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends LitElement {
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -42,6 +45,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
this._open = true;
if (this._params.dashboard) {
this._data = this._params.dashboard;
this._initDirtyTracking({ type: "deep" }, this._data);
} else {
const suggestions = this._params.suggestions;
this._data = {
@@ -51,9 +55,12 @@ export class DialogLovelaceDashboardDetail extends LitElement {
require_admin: false,
mode: "storage",
};
// New dashboards have no saved baseline, so track against an emptyobject to mark them dirty from the outset (keeps Create enabled).
this._initDirtyTracking({ type: "deep" }, {});
if (suggestions?.title) {
this._fillUrlPath(suggestions.title);
}
this._updateDirtyState(this._data!);
}
}
@@ -72,6 +79,8 @@ export class DialogLovelaceDashboardDetail extends LitElement {
return nothing;
}
const yamlMode = this._params.dashboard?.mode === "yaml";
const titleInvalid = !this._data.title || !this._data.title.trim();
const cancelButton = html`
@@ -95,11 +104,11 @@ export class DialogLovelaceDashboardDetail extends LitElement {
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div>
${this._params.dashboard?.mode === "yaml"
${yamlMode
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
@@ -142,10 +151,12 @@ export class DialogLovelaceDashboardDetail extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateDashboard}
.disabled=${(this._error && "url_path" in this._error) ||
titleInvalid ||
this._submitting}
?autofocus=${this._params.dashboard?.mode === "yaml"}
.disabled=${!yamlMode &&
((this._error && "url_path" in this._error) ||
titleInvalid ||
this._submitting ||
!this.isDirtyState)}
?autofocus=${yamlMode}
>
${this._params.urlPath
? this._params.dashboard?.mode === "storage"
@@ -251,6 +262,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
} else {
this._data = value;
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -270,6 +282,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
? pickAvailableDashboardUrlPath(baseSlug, taken)
: baseSlug,
};
this._updateDirtyState(this._data!);
}
private async _updateDashboard() {
@@ -292,6 +305,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
this._data as LovelaceDashboardCreateParams
);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
let localizedErrorMessage: string | undefined;
@@ -13,6 +13,7 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/ha-dialog";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { PanelMutableParams } from "../../../../data/panel";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { PanelDetailDialogParams } from "./show-dialog-panel-detail";
@@ -25,7 +26,9 @@ interface PanelDetailData {
}
@customElement("dialog-panel-detail")
export class DialogPanelDetail extends LitElement {
export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: PanelDetailDialogParams;
@@ -48,6 +51,7 @@ export class DialogPanelDetail extends LitElement {
show_in_sidebar: params.showInSidebar,
};
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -70,7 +74,7 @@ export class DialogPanelDetail extends LitElement {
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
header-title=${this.hass.localize(
"ui.panel.config.lovelace.dashboards.panel_detail.edit_panel"
)}
@@ -114,7 +118,7 @@ export class DialogPanelDetail extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updatePanel}
.disabled=${titleInvalid || this._submitting}
.disabled=${titleInvalid || this._submitting || !this.isDirtyState}
>
${this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.update"
@@ -171,6 +175,7 @@ export class DialogPanelDetail extends LitElement {
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
this._data = ev.detail.value;
this._updateDirtyState(this._data!);
}
private async _handleError(err: any) {
@@ -228,6 +233,7 @@ export class DialogPanelDetail extends LitElement {
if (Object.keys(updates).length > 0) {
await this._params!.updatePanel(updates);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._handleError(err);
@@ -9,6 +9,7 @@ import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-button";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { LovelaceResourcesMutableParams } from "../../../../data/lovelace/resource";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceResourceDetailsDialogParams } from "./show-dialog-lovelace-resource-detail";
@@ -30,7 +31,9 @@ const detectResourceType = (url?: string) => {
};
@customElement("dialog-lovelace-resource-detail")
export class DialogLovelaceResourceDetail extends LitElement {
export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
Partial<LovelaceResourcesMutableParams>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceResourceDetailsDialogParams;
@@ -57,6 +60,7 @@ export class DialogLovelaceResourceDetail extends LitElement {
};
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -83,7 +87,7 @@ export class DialogLovelaceResourceDetail extends LitElement {
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
header-title=${dialogTitle}
@closed=${this._dialogClosed}
>
@@ -119,7 +123,10 @@ export class DialogLovelaceResourceDetail extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateResource}
.disabled=${urlInvalid || !this._data?.res_type || this._submitting}
.disabled=${urlInvalid ||
!this._data?.res_type ||
this._submitting ||
!this.isDirtyState}
>
${this._params.resource
? this.hass!.localize(
@@ -203,6 +210,7 @@ export class DialogLovelaceResourceDetail extends LitElement {
if (!this._data!.res_type) {
const type = detectResourceType(this._data!.url);
if (!type) {
this._updateDirtyState(this._data!);
return;
}
this._data = {
@@ -210,6 +218,7 @@ export class DialogLovelaceResourceDetail extends LitElement {
res_type: type,
};
}
this._updateDirtyState(this._data!);
}
private async _updateResource() {
@@ -226,7 +235,8 @@ export class DialogLovelaceResourceDetail extends LitElement {
this._data! as LovelaceResourcesMutableParams
);
}
this._params = undefined;
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = { base: err?.message || "Unknown error" };
} finally {
@@ -13,6 +13,7 @@ import "../../../components/ha-picture-upload";
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
import "../../../components/input/ha-input";
import "../../../components/item/ha-row-item";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { adminChangeUsername } from "../../../data/auth";
import type { PersonMutableParams } from "../../../data/person";
import type { User } from "../../../data/user";
@@ -44,8 +45,20 @@ const cropOptions: CropOptions = {
aspectRatio: 1,
};
interface PersonFormState {
name: string;
picture: string | null;
userId: string | undefined;
deviceTrackers: string[];
isAdmin: boolean | undefined;
localOnly: boolean | undefined;
}
@customElement("dialog-person-detail")
class DialogPersonDetail extends LitElement implements HassDialog {
class DialogPersonDetail
extends DirtyStateProviderMixin<PersonFormState>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@@ -104,9 +117,21 @@ class DialogPersonDetail extends LitElement implements HassDialog {
this._picture = null;
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
}
private _currentState(): PersonFormState {
return {
name: this._name,
picture: this._picture,
userId: this._userId,
deviceTrackers: this._deviceTrackers,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
};
}
public closeDialog() {
this._open = false;
return true;
@@ -134,7 +159,7 @@ class DialogPersonDetail extends LitElement implements HassDialog {
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
header-title=${this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.person.detail.new_person")}
@@ -262,7 +287,7 @@ class DialogPersonDetail extends LitElement implements HassDialog {
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || this._submitting}
.disabled=${nameInvalid || this._submitting || !this.isDirtyState}
>
${this._params.entry
? this.hass!.localize("ui.common.save")
@@ -367,14 +392,17 @@ class DialogPersonDetail extends LitElement implements HassDialog {
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HTMLInputElement).value;
this._updateDirtyState(this._currentState());
}
private _adminChanged(ev): void {
this._isAdmin = ev.target.checked;
this._updateDirtyState(this._currentState());
}
private _localOnlyChanged(ev): void {
this._localOnly = ev.target.checked;
this._updateDirtyState(this._currentState());
}
private async _allowLoginChanged(ev): Promise<void> {
@@ -393,6 +421,7 @@ class DialogPersonDetail extends LitElement implements HassDialog {
this._userId = user.id;
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
this._localOnly = user.local_only;
this._updateDirtyState(this._currentState());
}
},
name: this._name,
@@ -421,17 +450,20 @@ class DialogPersonDetail extends LitElement implements HassDialog {
this._user = undefined;
this._isAdmin = undefined;
this._localOnly = undefined;
this._updateDirtyState(this._currentState());
}
}
private _deviceTrackersChanged(ev: ValueChangedEvent<string[]>) {
this._error = undefined;
this._deviceTrackers = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
}
private async _changePassword() {
@@ -527,6 +559,7 @@ class DialogPersonDetail extends LitElement implements HassDialog {
await this._params!.createEntry?.(values);
this._personExists = true;
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
+42 -2
View File
@@ -20,12 +20,24 @@ import {
createUser,
deleteUser,
} from "../../../data/user";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type { AddUserDialogParams } from "./show-dialog-add-user";
interface AddUserFormState {
name?: string;
username?: string;
password?: string;
passwordConfirm?: string;
isAdmin?: boolean;
localOnly?: boolean;
}
@customElement("dialog-add-user")
export class DialogAddUser extends LitElement {
export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
@@ -70,6 +82,18 @@ export class DialogAddUser extends LitElement {
}
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{
name: this._name,
username: this._username,
password: "",
passwordConfirm: "",
isAdmin: false,
localOnly: false,
}
);
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
@@ -89,7 +113,7 @@ export class DialogAddUser extends LitElement {
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
header-title=${this.hass.localize(
"ui.panel.config.users.add_user.caption"
)}
@@ -242,6 +266,7 @@ export class DialogAddUser extends LitElement {
if (parts.length) {
this._username = parts[0].toLowerCase();
this._publishDirtyState();
}
}
@@ -249,16 +274,30 @@ export class DialogAddUser extends LitElement {
this._error = undefined;
const target = ev.target as HaInput;
this[`_${target.name}`] = target.value;
this._publishDirtyState();
}
private async _adminChanged(ev: Event): Promise<void> {
const target = ev.target as HaSwitch;
this._isAdmin = target.checked;
this._publishDirtyState();
}
private _localOnlyChanged(ev: Event): void {
const target = ev.target as HaSwitch;
this._localOnly = target.checked;
this._publishDirtyState();
}
private _publishDirtyState(): void {
this._updateDirtyState({
name: this._name,
username: this._username,
password: this._password,
passwordConfirm: this._passwordConfirm,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
});
}
private async _createUser(ev: Event) {
@@ -306,6 +345,7 @@ export class DialogAddUser extends LitElement {
},
];
this._params!.userAddedCallback(user);
this._markDirtyStateClean();
this._close();
}
@@ -9,6 +9,7 @@ import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import { adminChangePassword } from "../../../data/auth";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
@@ -43,7 +44,9 @@ interface FormData {
}
@customElement("dialog-admin-change-password")
class DialogAdminChangePassword extends LitElement {
class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: AdminChangePasswordDialogParams;
@@ -65,7 +68,10 @@ class DialogAdminChangePassword extends LitElement {
this._userId = params.userId;
this._data = undefined;
this._error = undefined;
this._submitting = false;
this._success = false;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, {});
}
public closeDialog(): void {
@@ -117,7 +123,7 @@ class DialogAdminChangePassword extends LitElement {
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
header-title=${this.hass.localize(
"ui.panel.config.users.change_password.caption"
)}
@@ -173,6 +179,7 @@ class DialogAdminChangePassword extends LitElement {
private _valueChanged(ev) {
this._data = ev.detail.value;
this._updateDirtyState(this._data ?? {});
this._validate();
}
@@ -185,6 +192,7 @@ class DialogAdminChangePassword extends LitElement {
this._userId!,
this._data.new_password
);
this._markDirtyStateClean();
this._success = true;
} catch (err: any) {
showToast(this, {
+185 -163
View File
@@ -24,13 +24,23 @@ import {
showAlertDialog,
showPromptDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showAdminChangePasswordDialog } from "./show-dialog-admin-change-password";
import type { UserDetailDialogParams } from "./show-dialog-user-detail";
interface UserDetailFormState {
name: string;
isAdmin?: boolean;
localOnly?: boolean;
isActive?: boolean;
}
@customElement("dialog-user-detail")
class DialogUserDetail extends LitElement {
class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@@ -57,6 +67,15 @@ class DialogUserDetail extends LitElement {
this._localOnly = params.entry.local_only;
this._isActive = params.entry.is_active;
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{
name: this._name,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
isActive: this._isActive,
}
);
await this.updateComplete;
}
@@ -69,177 +88,164 @@ class DialogUserDetail extends LitElement {
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
header-title=${user.name}
@closed=${this._dialogClosed}
>
<div>
${
this._error
? html`<div class="error">${this._error}</div>`
: nothing
}
${this._error
? html`<div class="error">${this._error}</div>`
: nothing}
<div class="secondary">
${this.hass.localize("ui.panel.config.users.editor.id")}:
${user.id}<br />
</div>
${
badges.length === 0
? nothing
: html`
<div class="badge-container">
${badges.map(
([icon, label]) => html`
<ha-label>
<ha-svg-icon slot="icon" .path=${icon}></ha-svg-icon>
${label}
</ha-label>
`
)}
</div>
`
}
${badges.length === 0
? nothing
: html`
<div class="badge-container">
${badges.map(
([icon, label]) => html`
<ha-label>
<ha-svg-icon slot="icon" .path=${icon}></ha-svg-icon>
${label}
</ha-label>
`
)}
</div>
`}
<div class="form">
${
!user.system_generated
? html`
<ha-input
autofocus
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass!.localize(
"ui.panel.config.users.editor.name"
)}
></ha-input>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.username"
)}</span
>
<span slot="supporting-text">${user.username}</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changeUsername}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_username"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing
}
${
!user.system_generated && this.hass.user?.is_owner
? html`
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.password"
)}</span
>
<span slot="supporting-text">************</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changePassword}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_password"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing
}
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.active"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.active_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isActive}
@change=${this._activeChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated}
.checked=${this._localOnly}
@change=${this._localOnlyChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.admin"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.admin_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isAdmin}
@change=${this._adminChanged}
></ha-switch>
</ha-switch>
</ha-row-item>
${
!this._isAdmin && !user.system_generated
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.users.users_privileges_note"
)}
</ha-alert>
`
: nothing
}
</div>
${
user.system_generated
${!user.system_generated
? html`
<ha-input
autofocus
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass!.localize(
"ui.panel.config.users.editor.name"
)}
></ha-input>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.username"
)}</span
>
<span slot="supporting-text">${user.username}</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changeUsername}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_username"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing}
${!user.system_generated && this.hass.user?.is_owner
? html`
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.password"
)}</span
>
<span slot="supporting-text">************</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changePassword}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_password"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing}
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.active"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.active_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isActive}
@change=${this._activeChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated}
.checked=${this._localOnly}
@change=${this._localOnlyChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.admin"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.admin_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isAdmin}
@change=${this._adminChanged}
></ha-switch>
</ha-row-item>
${!this._isAdmin && !user.system_generated
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.users.editor.system_generated_read_only_users"
"ui.panel.config.users.users_privileges_note"
)}
</ha-alert>
`
: nothing
}
: nothing}
</div>
${user.system_generated
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.users.editor.system_generated_read_only_users"
)}
</ha-alert>
`
: nothing}
</div>
<ha-dialog-footer slot="footer">
@@ -248,18 +254,19 @@ class DialogUserDetail extends LitElement {
variant="danger"
appearance="plain"
@click=${this._deleteEntry}
.disabled=${
this._submitting || user.system_generated || user.is_owner
}
.disabled=${this._submitting ||
user.system_generated ||
user.is_owner}
>
${this.hass!.localize("ui.panel.config.users.editor.delete_user")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${
!this._name || this._submitting || user.system_generated
}
.disabled=${!this._name ||
this._submitting ||
user.system_generated ||
!this.isDirtyState}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
@@ -271,18 +278,31 @@ class DialogUserDetail extends LitElement {
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._publishDirtyState();
}
private _adminChanged(ev): void {
this._isAdmin = ev.target.checked;
this._publishDirtyState();
}
private _localOnlyChanged(ev): void {
this._localOnly = ev.target.checked;
this._publishDirtyState();
}
private _activeChanged(ev): void {
this._isActive = ev.target.checked;
this._publishDirtyState();
}
private _publishDirtyState(): void {
this._updateDirtyState({
name: this._name,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
isActive: this._isActive,
});
}
private async _updateEntry() {
@@ -296,6 +316,7 @@ class DialogUserDetail extends LitElement {
],
local_only: this._localOnly,
});
this._markDirtyStateClean();
this._close();
} catch (err: any) {
this._error = err?.message || "Unknown error";
@@ -308,6 +329,7 @@ class DialogUserDetail extends LitElement {
this._submitting = true;
try {
if (await this._params!.removeEntry()) {
this._markDirtyStateClean();
this._close();
}
} finally {
@@ -11,6 +11,7 @@ import "../../../components/ha-form/ha-form";
import "../../../components/ha-icon-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type {
AssistPipeline,
AssistPipelineMutableParams,
@@ -28,7 +29,9 @@ import type { VoiceAssistantPipelineDetailsDialogParams } from "./show-dialog-vo
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@customElement("dialog-voice-assistant-pipeline-detail")
export class DialogVoiceAssistantPipelineDetail extends LitElement {
export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
Partial<AssistPipeline>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: VoiceAssistantPipelineDetailsDialogParams;
@@ -62,6 +65,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
this._hideWakeWord =
this._params.hideWakeWord || !this._data.wake_word_entity;
this._initDirtyTracking({ type: "deep" }, this._data);
return;
}
@@ -98,6 +102,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
stt_engine: this._params.pipeline?.stt_engine || sstDefault,
tts_engine: this._params.pipeline?.tts_engine || ttsDefault,
};
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -145,7 +150,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
<ha-dialog
.open=${this._open}
header-title=${title}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${!this._hideWakeWord ||
@@ -234,6 +239,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
slot="primaryAction"
@click=${this._updatePipeline}
.loading=${this._submitting}
.disabled=${!this.isDirtyState}
>
${isExistingPipeline
? this.hass.localize(
@@ -266,6 +272,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
value[key] = ev.detail.value[key];
});
this._data = { ...this._data, ...value };
this._updateDirtyState(this._data);
}
private async _updatePipeline() {
@@ -299,6 +306,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
// eslint-disable-next-line no-console
console.error("No createPipeline function provided");
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err?.message || "Unknown error";
@@ -8,6 +8,7 @@ import "../../../components/ha-dialog";
import "../../../components/ha-form/ha-form";
import "../../../components/ha-button";
import type { HomeZoneMutableParams } from "../../../data/zone";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
@@ -21,7 +22,9 @@ const SCHEMA = [
];
@customElement("dialog-home-zone-detail")
class DialogHomeZoneDetail extends LitElement {
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@@ -43,6 +46,7 @@ class DialogHomeZoneDetail extends LitElement {
longitude: this.hass.config.longitude,
radius: this.hass.config.radius,
};
this._initDirtyTracking({ type: "deep" }, this._data);
this._open = true;
}
@@ -71,7 +75,7 @@ class DialogHomeZoneDetail extends LitElement {
header-title=${this.hass!.localize("ui.common.edit_item", {
name: this._data.name,
})}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-form
@@ -94,7 +98,7 @@ class DialogHomeZoneDetail extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!valid || this._submitting}
.disabled=${!valid || this._submitting || !this.isDirtyState}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
@@ -120,6 +124,7 @@ class DialogHomeZoneDetail extends LitElement {
value.radius = value.location.radius;
delete value.location;
this._data = value;
this._updateDirtyState(value);
}
private _computeLabel = (): string => "";
+8 -3
View File
@@ -11,12 +11,15 @@ import "../../../components/ha-button";
import type { SchemaUnion } from "../../../components/ha-form/types";
import type { ZoneMutableParams } from "../../../data/zone";
import { getZoneEditorInitData } from "../../../data/zone";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
@customElement("dialog-zone-detail")
class DialogZoneDetail extends LitElement {
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@@ -53,6 +56,7 @@ class DialogZoneDetail extends LitElement {
radius: 100,
};
}
this._initDirtyTracking({ type: "deep" }, this._data);
this._open = true;
}
@@ -93,7 +97,7 @@ class DialogZoneDetail extends LitElement {
name: this._params.entry.name,
})
: this.hass!.localize("ui.panel.config.zone.detail.new_zone")}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-form
@@ -131,7 +135,7 @@ class DialogZoneDetail extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!valid || this._submitting}
.disabled=${!valid || this._submitting || !this.isDirtyState}
>
${this._params.entry
? this.hass!.localize("ui.common.save")
@@ -189,6 +193,7 @@ class DialogZoneDetail extends LitElement {
delete value.icon;
}
this._data = value;
this._updateDirtyState(value);
}
private _computeLabel = (
@@ -32,6 +32,7 @@ import type { EnergyDevicesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "../../../../components/ha-card";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";
import "../../../../components/ha-icon-button";
@@ -189,9 +190,11 @@ export class HuiEnergyDevicesGraphCard
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.extraComponents=${[PieChart]}
click-label-for-more-info
@chart-click=${this._handleChartClick}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
</div>
</ha-card>
@@ -543,11 +546,20 @@ export class HuiEnergyDevicesGraphCard
chartData.splice(this._config.max_devices);
}
this._legendData = chartData.map((d) => ({
...d,
name: this._getDeviceName(d.name),
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
}));
this._legendData = chartData.map((d) => {
const id = (d as any).id as string;
return {
...d,
name: this._getDeviceName(d.name),
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
// Untracked is synthetic and external statistics aren't real entities,
// so their labels can't open more-info; fall back to toggling visibility.
noLabelClick:
id === "untracked" ||
isExternalStatistic(id) ||
!(id in this.hass.states),
};
});
// filter out hidden stats in place
for (let i = chartData.length - 1; i >= 0; i--) {
if (this._hiddenStats.includes((chartData[i] as any).id)) {
@@ -579,7 +591,11 @@ export class HuiEnergyDevicesGraphCard
e.detail.event?.target?.type === "tspan" // label
) {
const id = (e.detail.data as any).id as string;
if (id !== "untracked") {
if (
id !== "untracked" &&
!isExternalStatistic(id) &&
this.hass.states[id]
) {
fireEvent(this, "hass-more-info", {
entityId: id,
});
@@ -587,6 +603,16 @@ export class HuiEnergyDevicesGraphCard
}
}
private _handleLegendLabelClick(
ev: HASSDomEvent<HASSDomEvents["legend-label-click"]>
) {
const entityId = ev.detail.id;
if (isExternalStatistic(entityId) || !this.hass.states[entityId]) {
return;
}
fireEvent(this, "hass-more-info", { entityId });
}
private _handleChartTypeChange(): void {
if (!this._chartType) {
return;
@@ -49,6 +49,9 @@ const addEntityId = (entities: Set<string>, entity) => {
};
const addEntities = (entities: Set<string>, obj) => {
if (!obj) {
return;
}
if (obj.entity) {
addEntityId(entities, obj.entity);
}
@@ -12,6 +12,7 @@ import "../../../../../components/ha-dropdown";
import "../../../../../components/ha-dropdown-item";
import "../../../../../components/ha-icon-button";
import type { LovelaceStrategyConfig } from "../../../../../data/lovelace/config/strategy";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -27,7 +28,9 @@ import type { DashboardStrategyEditorDialogParams } from "./show-dialog-dashboar
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
@customElement("dialog-dashboard-strategy-editor")
class DialogDashboardStrategyEditor extends LitElement {
class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStrategyConfig>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DashboardStrategyEditorDialogParams;
@@ -49,6 +52,7 @@ class DialogDashboardStrategyEditor extends LitElement {
this._params = params;
this._strategyConfig = params.config.strategy;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._strategyConfig);
await this.updateComplete;
}
@@ -68,6 +72,7 @@ class DialogDashboardStrategyEditor extends LitElement {
ev.stopPropagation();
this._guiModeAvailable = ev.detail.guiModeAvailable;
this._strategyConfig = ev.detail.config as LovelaceStrategyConfig;
this._updateDirtyState(this._strategyConfig);
}
private _handleGUIModeChanged(ev: HASSDomEvent<GUIModeChangedEvent>): void {
@@ -82,6 +87,7 @@ class DialogDashboardStrategyEditor extends LitElement {
strategy: this._strategyConfig!,
});
showSaveSuccessToast(this, this.hass);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -137,6 +143,7 @@ class DialogDashboardStrategyEditor extends LitElement {
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
header-title=${title || "-"}
header-subtitle=${ifDefined(this._params.title)}
width="large"
@@ -195,7 +202,11 @@ class DialogDashboardStrategyEditor extends LitElement {
>
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._save}>
<ha-button
slot="primaryAction"
@click=${this._save}
?disabled=${!this.isDirtyState}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
@@ -30,6 +30,7 @@ import {
} from "../../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -53,7 +54,7 @@ const TABS = ["tab-settings", "tab-visibility"] as const;
@customElement("hui-dialog-edit-section")
export class HuiDialogEditSection
extends LitElement
extends DirtyStateProviderMixin<LovelaceSectionRawConfig>()(LitElement)
implements HassDialog<EditSectionDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -96,6 +97,7 @@ export class HuiDialogEditSection
this._viewConfig = findLovelaceContainer(this._params.lovelaceConfig, [
this._params.viewIndex,
]);
this._initDirtyTracking({ type: "deep" }, this._config);
}
public closeDialog() {
@@ -159,7 +161,7 @@ export class HuiDialogEditSection
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
class=${classMap({
@@ -231,7 +233,11 @@ export class HuiDialogEditSection
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._save}>
<ha-button
slot="primaryAction"
@click=${this._save}
?disabled=${!this.isDirtyState}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
@@ -242,6 +248,7 @@ export class HuiDialogEditSection
private _configChanged(ev: CustomEvent): void {
ev.stopPropagation();
this._config = ev.detail.value;
this._updateDirtyState(this._config!);
}
private _handleTabChanged(ev: CustomEvent): void {
@@ -399,6 +406,7 @@ export class HuiDialogEditSection
return;
}
this._config = ev.detail.value;
this._updateDirtyState(this._config!);
}
private _ignoreKeydown(ev: KeyboardEvent) {
@@ -423,6 +431,7 @@ export class HuiDialogEditSection
);
this._params.saveConfig(newConfig);
this._markDirtyStateClean();
this.closeDialog();
}
+23 -14
View File
@@ -1,4 +1,3 @@
import { undoDepth } from "@codemirror/commands";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
@@ -17,6 +16,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 +33,9 @@ const strategyStruct = type({
});
@customElement("hui-editor")
class LovelaceFullConfigEditor extends LitElement {
class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
LitElement
) {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -44,8 +46,6 @@ class LovelaceFullConfigEditor extends LitElement {
@state() private _saving?: boolean;
@state() private _changed?: boolean;
private _config?: LovelaceRawConfig;
private _yamlError?: string;
@@ -66,10 +66,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 +78,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
@@ -98,7 +98,7 @@ class LovelaceFullConfigEditor extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.yamlEditor.setValue(this.lovelace!.rawConfig);
this._setValue();
}
protected updated(changedProps: PropertyValues<this>) {
@@ -110,10 +110,19 @@ class LovelaceFullConfigEditor extends LitElement {
oldLovelace.rawConfig !== this.lovelace.rawConfig &&
!deepEqual(oldLovelace.rawConfig, this.lovelace.rawConfig)
) {
this.yamlEditor.setValue(this.lovelace!.rawConfig);
this._setValue();
}
}
private _setValue() {
this.yamlEditor.setValue(this.lovelace!.rawConfig);
// Baseline the dirty check against the loaded YAML so it resets on save.
this._initDirtyTracking(
{ type: "custom", compare: (a, b) => a === b },
this.yamlEditor.yaml
);
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -158,17 +167,17 @@ 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) {
this._updateDirtyState(this.yamlEditor.yaml);
if (this.isDirtyState && !window.onbeforeunload) {
window.onbeforeunload = () => true;
} else if (!this._changed && window.onbeforeunload) {
} else if (!this.isDirtyState && 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 +288,7 @@ class LovelaceFullConfigEditor extends LitElement {
});
}
window.onbeforeunload = null;
this._changed = false;
this._markDirtyStateClean();
this._saving = false;
}
+18 -10
View File
@@ -60,16 +60,24 @@ export const createLogMessage = async (
// - a possible list of aggregated errors
if (error instanceof Error) {
lines.push(error.toString() || messageFallback);
const stackLines = (await fromError(error))
.slice(0, MAX_STACK_FRAMES)
.map((frame) => {
frame.fileName ??= "";
if (URL.canParse(frame.fileName)) {
frame.fileName = new URL(frame.fileName).pathname;
}
frame.fileName = frame.fileName.replace(REMOVAL_PATHS, "");
return frame.toString();
});
let stackLines: (string | undefined)[];
try {
stackLines = (await fromError(error))
.slice(0, MAX_STACK_FRAMES)
.map((frame) => {
frame.fileName ??= "";
if (URL.canParse(frame.fileName)) {
frame.fileName = new URL(frame.fileName).pathname;
}
frame.fileName = frame.fileName.replace(REMOVAL_PATHS, "");
return frame.toString();
});
} catch {
// stacktrace-js cannot always parse a stack (for example a DOMException
// with no, or an unrecognized, stack), so fall back to the raw stack
// instead of letting the error logger itself throw.
stackLines = error.stack ? [error.stack] : [];
}
lines.push(...(stackLines.length > 0 ? stackLines : [stackFallback]));
// @ts-expect-error Requires library bump to ES2022
if (error.cause) {
+51
View File
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createLogMessage } from "../../src/resources/log-message";
const fromError = vi.hoisted(() => vi.fn());
vi.mock("stacktrace-js", () => ({ fromError }));
describe("createLogMessage", () => {
beforeEach(() => {
fromError.mockReset();
});
it("includes the error message and parsed stack frames", async () => {
fromError.mockResolvedValue([
{ fileName: "https://example.com/foo.js", toString: () => "at foo.js" },
]);
const error = new Error("boom");
const message = await createLogMessage(error);
expect(message).toContain("Error: boom");
expect(message).toContain("at foo.js");
});
it("does not throw when stacktrace-js cannot parse the stack", async () => {
fromError.mockRejectedValue(new Error("Cannot parse given Error object"));
const error = new Error("boom");
error.stack = "Error: boom\n at <anonymous>";
const message = await createLogMessage(error);
expect(message).toContain("Error: boom");
// Falls back to the raw stack instead of crashing the logger.
expect(message).toContain("at <anonymous>");
});
it("falls back to the provided stack fallback when no stack is available", async () => {
fromError.mockRejectedValue(new Error("Cannot parse given Error object"));
const error = new Error("boom");
error.stack = undefined;
const message = await createLogMessage(
error,
undefined,
undefined,
"@unknown:0:0"
);
expect(message).toContain("@unknown:0:0");
});
});
+88 -88
View File
@@ -1352,14 +1352,14 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/lint@npm:6.9.6, @codemirror/lint@npm:^6.0.0":
version: 6.9.6
resolution: "@codemirror/lint@npm:6.9.6"
"@codemirror/lint@npm:6.9.7, @codemirror/lint@npm:^6.0.0":
version: 6.9.7
resolution: "@codemirror/lint@npm:6.9.7"
dependencies:
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.42.0"
crelt: "npm:^1.0.5"
checksum: 10/70ed80eaec81038c014a89d8b4ba17396562b7dd541882fa981e2f81f7cdcbd67725a7c055c7772ece3bd052a276976d873f71746fc550b1aede7e18faa32f93
checksum: 10/f1af8295e1741a8d0c155cd7552c35d21c859d8848571fa084f5a6f2a50cac51ed25f04e423cab0f094e11b4828a54de4383a97b6b8cf6a242c5941034bffa04
languageName: node
linkType: hard
@@ -1383,15 +1383,15 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/view@npm:6.43.0, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
version: 6.43.0
resolution: "@codemirror/view@npm:6.43.0"
"@codemirror/view@npm:6.43.1, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
version: 6.43.1
resolution: "@codemirror/view@npm:6.43.1"
dependencies:
"@codemirror/state": "npm:^6.6.0"
crelt: "npm:^1.0.6"
style-mod: "npm:^4.1.0"
w3c-keyname: "npm:^2.2.4"
checksum: 10/7cfeebe1507f71a960dfb2d5152400507d28ed5827680bc73e0a093bfba9a796c2e559c960fd2b046379fac31ff0b59663dfc481baadf1d6ececd71eb5b48014
checksum: 10/a867a26cb511f161a8ef60b2574afae2561cfe94abd822e0af2a6e4bd94d1ff46f0d5085428c89f6f6b80a52482c8d23f80362173491903f49fb93462a216b95
languageName: node
linkType: hard
@@ -4548,105 +4548,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/eslint-plugin@npm:8.60.1"
"@typescript-eslint/eslint-plugin@npm:8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.61.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.60.1"
"@typescript-eslint/type-utils": "npm:8.60.1"
"@typescript-eslint/utils": "npm:8.60.1"
"@typescript-eslint/visitor-keys": "npm:8.60.1"
"@typescript-eslint/scope-manager": "npm:8.61.0"
"@typescript-eslint/type-utils": "npm:8.61.0"
"@typescript-eslint/utils": "npm:8.61.0"
"@typescript-eslint/visitor-keys": "npm:8.61.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.60.1
"@typescript-eslint/parser": ^8.61.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/f3633bb2700bc32299578baeaf6650418656229be256147ba9d1ab09b34ef2b7fed83804ef4d2439e9189dbdcb89399d67bc8fea55262be6caa32114be048538
checksum: 10/ca7fbaa2f03ec15bdbf39d2e4d42f1b682085f23830591d1d6c3d9f497fdda497341b2fa67c8d366514a3c22807557e45e7afe1ee70cef527b184250e5422e8f
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/parser@npm:8.60.1"
"@typescript-eslint/parser@npm:8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/parser@npm:8.61.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.60.1"
"@typescript-eslint/types": "npm:8.60.1"
"@typescript-eslint/typescript-estree": "npm:8.60.1"
"@typescript-eslint/visitor-keys": "npm:8.60.1"
"@typescript-eslint/scope-manager": "npm:8.61.0"
"@typescript-eslint/types": "npm:8.61.0"
"@typescript-eslint/typescript-estree": "npm:8.61.0"
"@typescript-eslint/visitor-keys": "npm:8.61.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/f9c484c4a3897015328f328a1c6ee778d113dd134201f635c0421cb72efe6e63f3a68524aff0df6e19e76ff93daf5cabd946e67f12f10dddcf19bda534aa68dc
checksum: 10/82060c36786339867d63337708a08bd4bc65569313998bd086dbe6b901664082c7e40d6b6e085296a459cd4fc1d064479ef570b51e1eb113688bb152a7a6d689
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/project-service@npm:8.60.1"
"@typescript-eslint/project-service@npm:8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/project-service@npm:8.61.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.60.1"
"@typescript-eslint/types": "npm:^8.60.1"
"@typescript-eslint/tsconfig-utils": "npm:^8.61.0"
"@typescript-eslint/types": "npm:^8.61.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/fec693dd79c3a1e6a24091127a37af4eb9d9cee8192cf2a434adae48543eadff834bc0623b5b563c8b592b250bc080570f9e7b42807252ea898442c525beeee9
checksum: 10/b7d7e973b565f604af43b8afb3ca1c3fbe6fcf16863bde83b42417a196ba9f3a5a3f5d39bf57ed96b8ce577047064d93c353ecb21db5e95dce69f81335c9cd81
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/scope-manager@npm:8.60.1"
"@typescript-eslint/scope-manager@npm:8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/scope-manager@npm:8.61.0"
dependencies:
"@typescript-eslint/types": "npm:8.60.1"
"@typescript-eslint/visitor-keys": "npm:8.60.1"
checksum: 10/7228c110410ff8cfc01e96d8f17c986f8b4dd447fe3a3291baaab8fe946026ccdf0291865f788f18cf538ab49bfc067fe797708b6b8590104a65f7e69f921cc5
"@typescript-eslint/types": "npm:8.61.0"
"@typescript-eslint/visitor-keys": "npm:8.61.0"
checksum: 10/295e306665d64f0330fede3fe72febd65c67c3083d747149b66097aa6f7d517f25731dc1dbec900b15768c40f92b082f501296e7524855fe82697f40b8d23ce1
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.60.1, @typescript-eslint/tsconfig-utils@npm:^8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/tsconfig-utils@npm:8.60.1"
"@typescript-eslint/tsconfig-utils@npm:8.61.0, @typescript-eslint/tsconfig-utils@npm:^8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.61.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/afc78b19b856a71dc4e493f931ae44e1a91dc6441a14cb92e4063db880892f3874768f9d347d4b2f45362f2090e4455407c70f42027d77ddc85d6cba95cdb76c
checksum: 10/f678ff5ec887a27d8e590e0c67403b12e372a027ab036dcfc1e3ef614d3bed7a3c455a65fa0a87ff7dae5b0ad1c49cf4aa40639cc368d7eb424efe8349d9cb9f
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/type-utils@npm:8.60.1"
"@typescript-eslint/type-utils@npm:8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/type-utils@npm:8.61.0"
dependencies:
"@typescript-eslint/types": "npm:8.60.1"
"@typescript-eslint/typescript-estree": "npm:8.60.1"
"@typescript-eslint/utils": "npm:8.60.1"
"@typescript-eslint/types": "npm:8.61.0"
"@typescript-eslint/typescript-estree": "npm:8.61.0"
"@typescript-eslint/utils": "npm:8.61.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/6f426263be597063831bf308e52328e8d387af5db955a09cb85fde1c72f5b1b36a365133b9c9a74330e5e948e59bf9a9b82605f4c9c4e3bf9b6cb7f4c37e4b18
checksum: 10/8290e5fc26241dfd5aeeffad0fb9857a3fa1f9c8107dfb01638970297e0e17be6088f0fd2d6fc7d450e9879afaa7e23f4111182bcf0b625eba74fdf13100b19e
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.60.1, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/types@npm:8.60.1"
checksum: 10/c603417e621b5b1263c2f60fad9e202d560fd07fce7f40e9a356c0530e5eaf0ff1a9af865237bf93aa18a5a4e2f034ee0cce0fe6c070f08df33e35a099bdea47
"@typescript-eslint/types@npm:8.61.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/types@npm:8.61.0"
checksum: 10/8e1e1cf5d092beed1a974b3b5d7cc20219ad3e4501b85bbef5bec1c81ab50b09ee70093ad2195c3061c499e804d63aac38dcc20293342b1fa774ba743c0d63bf
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/typescript-estree@npm:8.60.1"
"@typescript-eslint/typescript-estree@npm:8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/typescript-estree@npm:8.61.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.60.1"
"@typescript-eslint/tsconfig-utils": "npm:8.60.1"
"@typescript-eslint/types": "npm:8.60.1"
"@typescript-eslint/visitor-keys": "npm:8.60.1"
"@typescript-eslint/project-service": "npm:8.61.0"
"@typescript-eslint/tsconfig-utils": "npm:8.61.0"
"@typescript-eslint/types": "npm:8.61.0"
"@typescript-eslint/visitor-keys": "npm:8.61.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -4654,32 +4654,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/9c3a56266aadf589bc6e770cd04cb3f55b1ee1507dcacda61866408c656ae4462aa7e11baf39eb939bc4d1e3b843cf58e60f3ebdeb3e75f042ff0f6fb39c311b
checksum: 10/6d5ab7850226de23ab26d94388f729e792413f5a9e704c8c685b66eb20946efeb290cda91195f062e1065bc20129ec8f5955768316660132087347e66dec0d1a
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/utils@npm:8.60.1"
"@typescript-eslint/utils@npm:8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/utils@npm:8.61.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.60.1"
"@typescript-eslint/types": "npm:8.60.1"
"@typescript-eslint/typescript-estree": "npm:8.60.1"
"@typescript-eslint/scope-manager": "npm:8.61.0"
"@typescript-eslint/types": "npm:8.61.0"
"@typescript-eslint/typescript-estree": "npm:8.61.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/a75f8714995b6280b4c15ca957bbc6634862453461111e4a2a07b8bc72b51a504484a9b957fc5b7a646c4bf09f1e414a0c52cd3b6798c42fb8c4de83b1b5a364
checksum: 10/50ff451edb8e5dee92bbab11a75cbd570715623d89094d0541ddfbef208248e82d2f9478d1e09fb9c94496069afd4db9521384b77f7aaa63970f7edfebddfba9
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.60.1":
version: 8.60.1
resolution: "@typescript-eslint/visitor-keys@npm:8.60.1"
"@typescript-eslint/visitor-keys@npm:8.61.0":
version: 8.61.0
resolution: "@typescript-eslint/visitor-keys@npm:8.61.0"
dependencies:
"@typescript-eslint/types": "npm:8.60.1"
"@typescript-eslint/types": "npm:8.61.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/6d120b4a790477ae0291e69f6457686c71b929cc40519148f6b6c7fbc09604b15821ae8cf1005aa23acec5105b4016db256a68d68f30eda8d6c24d4fdb0ede86
checksum: 10/243018d9d8b1918d2863e50eec6628c792ccda05ad5534f5153fc783b7f54cdb8a58d758eb74260d113274bfab8bb38ad4664f3db9e7d3f844cdffbe6e47e285
languageName: node
linkType: hard
@@ -8442,10 +8442,10 @@ __metadata:
"@codemirror/lang-jinja": "npm:6.0.1"
"@codemirror/lang-yaml": "npm:6.1.3"
"@codemirror/language": "npm:6.12.3"
"@codemirror/lint": "npm:6.9.6"
"@codemirror/lint": "npm:6.9.7"
"@codemirror/search": "npm:6.7.0"
"@codemirror/state": "npm:6.6.0"
"@codemirror/view": "npm:6.43.0"
"@codemirror/view": "npm:6.43.1"
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
"@eslint/js": "npm:10.0.1"
@@ -8570,7 +8570,7 @@ __metadata:
node-vibrant: "npm:4.0.4"
object-hash: "npm:3.0.0"
pinst: "npm:3.0.0"
prettier: "npm:3.8.3"
prettier: "npm:3.8.4"
punycode: "npm:2.3.1"
qr-scanner: "npm:1.4.2"
qrcode: "npm:1.5.4"
@@ -8587,7 +8587,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.60.1"
typescript-eslint: "npm:8.61.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.8"
webpack-stats-plugin: "npm:1.1.3"
@@ -11350,12 +11350,12 @@ __metadata:
languageName: node
linkType: hard
"prettier@npm:3.8.3":
version: 3.8.3
resolution: "prettier@npm:3.8.3"
"prettier@npm:3.8.4":
version: 3.8.4
resolution: "prettier@npm:3.8.4"
bin:
prettier: bin/prettier.cjs
checksum: 10/4b3b12cbb29e4c96bed936e5d070167552500c18d37676fb3e0caae6199c42860662608e4dc116230698f6e2bb0267ef2548158224c92d40f188d309d72fdd6f
checksum: 10/54684a3cc6689238692b29fab541c01934af7677be94c02293ba49981a1ac121c8bebe2a865f0c3b963e99d208f847c53aed354cc0ce8750e2d45791d64506c5
languageName: node
linkType: hard
@@ -13461,18 +13461,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.60.1":
version: 8.60.1
resolution: "typescript-eslint@npm:8.60.1"
"typescript-eslint@npm:8.61.0":
version: 8.61.0
resolution: "typescript-eslint@npm:8.61.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.60.1"
"@typescript-eslint/parser": "npm:8.60.1"
"@typescript-eslint/typescript-estree": "npm:8.60.1"
"@typescript-eslint/utils": "npm:8.60.1"
"@typescript-eslint/eslint-plugin": "npm:8.61.0"
"@typescript-eslint/parser": "npm:8.61.0"
"@typescript-eslint/typescript-estree": "npm:8.61.0"
"@typescript-eslint/utils": "npm:8.61.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e12091ab2540b817c76b0ec6aad92e341f810310bec2b24bc95780aee106049c05363998f6ea52ed066130c8afc41dca1627f56e4c1df1dd519f4d4ca0ce4910
checksum: 10/5a21c6ef76400ea30a47629087787834abc1c17e4b406465dfd8c204ef635556f8e3a775d89c46f9eb175ebd6a218284685e935877a2b148c482f0478627bdf9
languageName: node
linkType: hard