Compare commits

..
11 Commits
Author SHA1 Message Date
Bram Kragten 235541748b Bumped version to 20260729.6 2026-08-07 16:30:37 +02:00
Paul BotteinandBram Kragten 6bd1d698ce Fix disabled save button when leaving an automation with unsaved changes (#53538) 2026-08-07 16:27:35 +02:00
Bram Kragten 18862b868f Wait with showing pending http changes dialog until loading screen is… (#53534)
* Wait with showing pending http changes dialog until loading screen is gone

* fix timing
2026-08-07 16:27:33 +02:00
Bram Kragten 662d817a5e Fix link to different port being handled as internal link (#53530) 2026-08-07 16:27:32 +02:00
Aidan TimsonandBram Kragten 7412ab5578 Remove "dev" from tools actions description (#53528)
* Remove "dev" from tools actions description

* Clarify vt is browser developer tools
2026-08-07 16:27:31 +02:00
Krisjanis LejejsandBram Kragten b2be0bd673 Remove cloud demo controls (#53524) 2026-08-07 16:27:30 +02:00
Bram Kragten a141432546 Don't disable interaction when row is disabled (#53517)
dont disable interaction when row is disabled
2026-08-07 16:27:29 +02:00
Paul BotteinandBram Kragten cd50531b67 Stack the sidebar when only one column fits (#53515) 2026-08-07 16:27:29 +02:00
Petar PetrovandBram Kragten f7438f4d0e Accept hostnames and localize validation messages in the HTTP config form (#53511)
Accept hostnames in HTTP config listen addresses and localize number validation
2026-08-07 16:27:28 +02:00
Petar PetrovandBram Kragten a7fabb8a8c Only show the sidebar tab switcher when its tabs are labelled (#53508) 2026-08-07 16:26:46 +02:00
Aidan TimsonandBram Kragten 9dab55f39a Fix notification stacking (#53486)
* Stack notifications independently

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
(cherry picked from commit d03ba15c09)

# Conflicts:
#	test/panels/config/script/script-paste.test.ts
2026-08-07 16:23:38 +02:00
29 changed files with 827 additions and 800 deletions
-304
View File
@@ -1,304 +0,0 @@
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { mainWindow } from "../../../src/common/dom/get_main_window";
import { navigate } from "../../../src/common/navigate";
import "../../../src/components/ha-button";
import "../../../src/components/ha-card";
import "../../../src/components/ha-icon-button";
import "../../../src/components/ha-svg-icon";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "../stubs/cloud-demo-state";
// Walk the DOM, descending into shadow roots, to find the first matching
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
// can ask it to re-fetch after a scenario change.
const deepQuery = (
selector: string,
root: Document | ShadowRoot = document
): Element | null => {
const direct = root.querySelector(selector);
if (direct) {
return direct;
}
const elements = root.querySelectorAll("*");
for (const element of elements) {
const shadow = element.shadowRoot;
if (shadow) {
const found = deepQuery(selector, shadow);
if (found) {
return found;
}
}
}
return null;
};
/**
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
* reviewers can preview every UI state of the cloud account page. It writes to
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
* then nudges the page to re-read it. Lives entirely under demo/.
*/
@customElement("cloud-demo-controls")
export class CloudDemoControls extends LitElement {
@state() private _open = true;
@state() private _visible = false;
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
private _unsub?: () => void;
// The demo uses hash-based routing (navigate() sets location.hash), so the
// active route lives in the hash, not the pathname.
private get _currentPath(): string {
const hash = mainWindow.location.hash;
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
}
private _locationChanged = () => {
this._visible = this._currentPath.startsWith("/config/cloud");
};
public connectedCallback(): void {
super.connectedCallback();
this._locationChanged();
mainWindow.addEventListener("location-changed", this._locationChanged);
mainWindow.addEventListener("popstate", this._locationChanged);
mainWindow.addEventListener("hashchange", this._locationChanged);
this._unsub = subscribeCloudDemoScenario((scenario) => {
this._scenario = { ...scenario };
});
}
public disconnectedCallback(): void {
super.disconnectedCallback();
mainWindow.removeEventListener("location-changed", this._locationChanged);
mainWindow.removeEventListener("popstate", this._locationChanged);
mainWindow.removeEventListener("hashchange", this._locationChanged);
this._unsub?.();
}
protected render() {
if (!this._visible) {
return nothing;
}
if (!this._open) {
return html`
<ha-icon-button
class="fab"
label="Cloud demo controls"
.path=${mdiFlaskOutline}
@click=${this._toggleOpen}
></ha-icon-button>
`;
}
return html`
<ha-card>
<div class="header">
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
<span class="title">Cloud demo controls</span>
<ha-icon-button
label="Close"
.path=${mdiClose}
@click=${this._toggleOpen}
></ha-icon-button>
</div>
<p class="note">
Demo only. Flips the mocked cloud state shown on this page.
</p>
<div class="controls">
${this._segment("Subscription", "account", [
["active", "Active"],
["trialing", "Trialing"],
["canceled", "Canceled"],
["expired", "Expired"],
["unknown", "Unknown"],
])}
${this._toggle("Onboarded", "onboarded")}
${this._toggle("Onboarding postponed", "postponed")}
${this._toggle("Remote access", "remote")}
${this._segment("Remote status", "remoteStatus", [
["ready", "Ready"],
["generating", "Preparing"],
["loading", "Loading"],
["loaded", "Loaded"],
["error", "Error"],
])}
${this._segment("Backups", "backup", [
["fresh", "Recent"],
["stale", "Old"],
["failed", "Failed"],
["local", "Local only"],
["none", "None"],
])}
${this._toggle("Alexa linked", "alexa")}
${this._toggle("Google linked", "google")}
${this._toggle("Cameras (WebRTC)", "webrtc")}
${this._toggle("Has webhooks", "webhooks")}
</div>
</ha-card>
`;
}
private _segment(
label: string,
field: keyof CloudDemoScenario,
options: [string, string][]
) {
return html`
<div class="row">
<span>${label}</span>
<div class="segment">
${options.map(
([value, text]) => html`
<ha-button
size="s"
appearance=${
this._scenario[field] === value ? "filled" : "plain"
}
data-field=${field}
data-value=${value}
@click=${this._segmentClick}
>
${text}
</ha-button>
`
)}
</div>
</div>
`;
}
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<div class="row">
<span>${label}</span>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._toggleChange}
></ha-switch>
</div>
`;
}
private _toggleOpen() {
this._open = !this._open;
}
private _segmentClick(ev: Event) {
const target = ev.currentTarget as HTMLElement;
this._set(
target.dataset.field as keyof CloudDemoScenario,
target.dataset.value!
);
}
private _toggleChange(ev: Event) {
const target = ev.target as HaSwitch;
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
}
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
this._refresh();
}
private _refresh() {
// Refresh the shared cloud status so login-state changes (signed out) and
// status-derived fields update.
const panel = deepQuery("ha-panel-config");
if (panel) {
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
}
// cloud-account fetches its subscription/backup/webhook data once on mount
// and is not cached by the router, so bounce through a sibling cloud route
// to force a clean remount that re-reads the updated mocks.
const path = this._currentPath;
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
const sibling =
path === "/config/cloud/remote"
? "/config/cloud/account"
: "/config/cloud/remote";
navigate(sibling, { replace: true });
window.setTimeout(() => navigate(path, { replace: true }), 0);
}
}
static styles = css`
:host {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 9999;
}
.fab {
--mdc-icon-button-size: 48px;
--mdc-icon-size: 24px;
background-color: var(--primary-color);
color: var(--text-primary-color, #fff);
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
ha-card {
display: block;
width: 320px;
max-height: 80vh;
overflow: auto;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--divider-color);
}
.header .title {
flex: 1;
font-weight: var(--ha-font-weight-medium, 500);
}
.header ha-svg-icon {
color: var(--secondary-text-color);
}
.note {
margin: 8px 16px;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s, 0.875rem);
}
.controls {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 16px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
}
.segment {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"cloud-demo-controls": CloudDemoControls;
}
}
-6
View File
@@ -32,7 +32,6 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import "./cloud/cloud-demo-controls";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
@@ -90,11 +89,6 @@ export class HaDemo extends HomeAssistantAppEl {
},
});
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
// the document level; it shows itself only on the cloud panel.
if (!document.querySelector("cloud-demo-controls")) {
document.body.appendChild(document.createElement("cloud-demo-controls"));
}
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+25 -109
View File
@@ -7,31 +7,43 @@ import type {
import { BackupScheduleRecurrence } from "../../../src/data/backup";
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { DemoCloudBackup } from "./cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const CLOUD_AGENT = "cloud.cloud";
// Fixed "recent" backup state: an automatic backup completed 12h ago to both the
// local and cloud agents, with the next run scheduled for tomorrow. This is the
// healthy state the cloud overview status line and backup sub-page render.
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const future = new Date(now + 86400000).toISOString();
const backupInfo: BackupInfo = {
backups: [],
backups: [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: recent,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
} as BackupContent,
],
agent_errors: {},
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
last_action_event: { manager_state: "idle" },
next_automatic_backup: null,
next_automatic_backup: future,
next_automatic_backup_additional: false,
state: "idle",
};
const backupConfig: BackupConfig = {
automatic_backups_configured: true,
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
next_automatic_backup: null,
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
next_automatic_backup: future,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: ["backup.local", CLOUD_AGENT],
@@ -61,88 +73,6 @@ const agentsInfo: BackupAgentsInfo = {
],
};
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
// cloud overview status line and the backup sub-page reflect the chosen state.
const applyScenario = () => {
const kind = getCloudDemoScenario().backup;
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const old = new Date(now - 5 * 86400000).toISOString();
const future = new Date(now + 86400000).toISOString();
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
// actually reads as overdue rather than slipping under the margin.
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
// The cloud agent is a backup target for the cloud-backed states only. For
// "local" a backup exists but is stored locally (no cloud copy), and for
// "none" there are no automatic backups at all.
const cloudEnabled =
kind === "fresh" || kind === "stale" || kind === "failed";
backupConfig.create_backup.agent_ids = cloudEnabled
? ["backup.local", CLOUD_AGENT]
: ["backup.local"];
switch (kind) {
case "fresh":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "local":
// Automatic backups run, but only to the local agent.
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "stale":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
backupConfig.last_attempted_automatic_backup = old;
// Next scheduled backup is in the past, so it reads as overdue.
backupConfig.next_automatic_backup = overdue;
break;
case "failed":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
// Most recent attempt is newer than the last success, so it failed.
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "none":
backupConfig.automatic_backups_configured = false;
backupConfig.last_completed_automatic_backup = null;
backupConfig.last_attempted_automatic_backup = null;
backupConfig.next_automatic_backup = null;
break;
}
backupInfo.last_completed_automatic_backup =
backupConfig.last_completed_automatic_backup;
backupInfo.last_attempted_automatic_backup =
backupConfig.last_attempted_automatic_backup;
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
backupInfo.backups =
cloudEnabled && backupConfig.last_completed_automatic_backup
? [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: backupConfig.last_completed_automatic_backup,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
} as BackupContent,
]
: [];
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
export const mockBackup = (hass: MockHomeAssistant) => {
// Fresh objects each fetch so re-reading after a mutation actually re-renders
// (Lit change detection is identity-based; the real WS API returns new
@@ -171,20 +101,6 @@ export const mockBackup = (hass: MockHomeAssistant) => {
if (update.agents) {
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
}
// Reflect the UI-driven backup change into the demo scenario so the demo
// controls panel stays in sync with the mocked state.
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
const current = getCloudDemoScenario().backup;
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
? "none"
: cloudNow
? current === "fresh" || current === "stale" || current === "failed"
? current
: "fresh"
: "local";
if (next !== current) {
setCloudDemoScenario({ backup: next });
}
return null;
});
hass.mockWS(
-89
View File
@@ -1,89 +0,0 @@
// Demo-only switchable Home Assistant Cloud scenario.
//
// The redesigned cloud account page (src/panels/config/cloud/account) renders
// purely from real WS data. To let reviewers preview every UI state without a
// real cloud account, this module holds a mutable "scenario" that the cloud and
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
// to. It is persisted to localStorage so the choice survives the data the page
// fetches once per visit (subscription, backup config, webhooks).
//
// This lives entirely under demo/ — no production code imports it.
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
// The five PaymentSubscriptionState values.
export type DemoCloudAccount =
"active" | "trialing" | "canceled" | "expired" | "unknown";
// "local": automatic backups are configured, but not to the cloud agent
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
export interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
// Onboarding postponed server-side (maps to onboarding_postponed); hides
// the onboarding UI without marking it completed.
postponed: boolean;
remote: boolean;
remoteStatus: RemoteCertificateStatus;
backup: DemoCloudBackup;
alexa: boolean;
google: boolean;
webrtc: boolean;
webhooks: boolean;
}
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
account: "active",
onboarded: true,
postponed: false,
remote: true,
remoteStatus: "ready",
backup: "fresh",
alexa: true,
google: true,
webrtc: true,
webhooks: true,
};
const STORAGE_KEY = "cloudDemoScenario";
const readScenario = (): CloudDemoScenario => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
}
} catch (_err) {
// Ignore malformed or unavailable storage and fall back to the default.
}
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
};
let scenario: CloudDemoScenario = readScenario();
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
export const subscribeCloudDemoScenario = (
listener: (scenario: CloudDemoScenario) => void
): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const setCloudDemoScenario = (
partial: Partial<CloudDemoScenario>
): void => {
scenario = { ...scenario, ...partial };
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
} catch (_err) {
// Ignore storage failures (e.g. private mode); state still applies in-memory.
}
listeners.forEach((listener) => listener(scenario));
};
+24 -107
View File
@@ -6,11 +6,6 @@ import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
import type { Webhook } from "../../../src/data/webhook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const emptyFilter = () => ({
include_domains: [],
@@ -34,14 +29,30 @@ const demoWebhooks: Webhook[] = [
},
];
// A single mutable status object so that preference changes made in the demo
// (both via the real UI and the demo scenario controls) are reflected back.
const demoCloudhooks = Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
);
// A single mutable status object seeded to one fixed demo state: an active cloud
// subscription with remote ready, cloud backups recent, webhooks set up and voice
// set up (Alexa linked), but cameras (WebRTC) off, so onboarding is still in
// progress (streaming is the remaining step) and not postponed. Page-driven
// changes (connect remote, postpone onboarding, add/delete webhooks) mutate this
// object directly and reset on reload.
const cloudStatus: CloudStatusLoggedIn = {
logged_in: true,
cloud: "connected",
cloud_last_disconnect_reason: null,
email: "[email protected]",
google_registered: true,
google_registered: false,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: true,
@@ -58,20 +69,20 @@ const cloudStatus: CloudStatusLoggedIn = {
http_use_ssl: false,
active_subscription: true,
onboarding_postponed: false,
onboarding_completed: true,
onboarding_completed: false,
prefs: {
google_enabled: true,
google_enabled: false,
alexa_enabled: true,
remote_enabled: true,
remote_allow_remote_enable: true,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks: {},
cloudhooks: demoCloudhooks,
alexa_report_state: true,
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: true,
onboarded_items: [...ONBOARDING_ITEMS],
cloud_ice_servers_enabled: false,
onboarded_items: [],
onboarding_postponed_until: null,
},
};
@@ -93,94 +104,6 @@ const ttsInfo: CloudTTSInfo = {
],
};
// Map the high-level demo scenario onto the mutable cloud status / subscription.
const applyScenario = () => {
const scenario = getCloudDemoScenario();
switch (scenario.account) {
case "trialing":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "trialing" };
break;
case "canceled":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "canceled" };
break;
case "expired":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "expired" };
break;
case "unknown":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "unknown" };
break;
default:
// "active"
cloudStatus.active_subscription = true;
subscription.subscription = { status: "active" };
}
cloudStatus.prefs.onboarded_items = scenario.onboarded
? [...ONBOARDING_ITEMS]
: [];
cloudStatus.onboarding_completed = scenario.onboarded;
cloudStatus.onboarding_postponed = scenario.postponed;
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null;
cloudStatus.prefs.remote_enabled = scenario.remote;
cloudStatus.remote_connected = scenario.remote;
cloudStatus.remote_certificate_status = scenario.remoteStatus;
cloudStatus.alexa_registered = scenario.alexa;
cloudStatus.google_registered = scenario.google;
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
if (scenario.webhooks && !hasCloudhooks) {
cloudStatus.prefs.cloudhooks = Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
);
} else if (!scenario.webhooks && hasCloudhooks) {
cloudStatus.prefs.cloudhooks = {};
}
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
// back into the demo scenario so the demo controls panel stays in sync with the
// mocked state. Only writes when a value actually changed, to avoid needless
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
// this stays idempotent and does not fight the direct mutation above.
const syncScenarioFromStatus = () => {
const scenario = getCloudDemoScenario();
const next = {
onboarded: cloudStatus.onboarding_completed,
postponed: cloudStatus.onboarding_postponed,
remote: cloudStatus.prefs.remote_enabled,
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
};
if (
scenario.onboarded !== next.onboarded ||
scenario.postponed !== next.postponed ||
scenario.remote !== next.remote ||
scenario.webrtc !== next.webrtc ||
scenario.webhooks !== next.webhooks
) {
setCloudDemoScenario(next);
}
};
export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/status", () => ({
...cloudStatus,
@@ -193,7 +116,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
syncScenarioFromStatus();
return { success: true };
});
@@ -202,7 +124,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
Date.now() + 24 * 3600 * 1000
).toISOString();
cloudStatus.onboarding_postponed = true;
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
@@ -222,7 +143,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
(onboardingItem) =>
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
);
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
@@ -245,20 +165,17 @@ export const mockCloud = (hass: MockHomeAssistant) => {
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
delete cloudhooks[msg.webhook_id];
cloudStatus.prefs.cloudhooks = cloudhooks;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/connect", () => {
cloudStatus.remote_connected = true;
cloudStatus.prefs.remote_enabled = true;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
cloudStatus.remote_connected = false;
cloudStatus.prefs.remote_enabled = false;
syncScenarioFromStatus();
return null;
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.5"
version = "20260729.6"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+11 -11
View File
@@ -23,24 +23,24 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
return undefined;
}
let href = anchor.href;
if (!href || href.indexOf("mailto:") !== -1) {
let url: URL;
try {
// anchor.href is always absolute; an empty or unparseable value throws.
url = new URL(anchor.href);
} catch {
return undefined;
}
const location = window.location;
const origin = location.origin || location.protocol + "//" + location.host;
if (!href.startsWith(origin)) {
return undefined;
}
href = href.slice(origin.length);
if (href === "#") {
// Only intercept same-origin links. A different scheme, host, or port is a
// different origin (e.g. another port like ":8123") and must trigger a full
// browser navigation instead of an in-app route change. Non-http(s) schemes
// such as mailto: resolve to a null origin and are excluded here too.
if (url.origin !== window.location.origin) {
return undefined;
}
if (preventDefault) {
e.preventDefault();
}
return href;
return url.pathname + url.search + url.hash;
};
+13
View File
@@ -0,0 +1,13 @@
// RFC 1123 hostname label: 1-63 chars, alphanumeric, with hyphens allowed
// only between the first and last character. The hyphen is escaped because a
// `pattern` attribute is compiled with the `v` flag, under which a trailing
// unescaped hyphen is an invalid character class — and a pattern that fails to
// compile is silently ignored, disabling validation altogether.
const LABEL = "[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?";
// Hostname such as "localhost" or "homeassistant.lan", as dot-separated
// labels. Unanchored, for use as an HTML `pattern` attribute (the browser
// anchors it as `^(?:…)$`). The final label may not be all digits, so a
// mistyped IP address like "300.1.1.1" is rejected rather than accepted as a
// hostname. Deliberately excludes underscores and a trailing dot.
export const HOSTNAME_PATTERN = `(?:${LABEL}\\.)*(?!\\d+$)${LABEL}`;
@@ -131,6 +131,7 @@ export class HaNumberSelector extends LitElement {
.hint=${isBox ? this.helper : undefined}
.disabled=${this.disabled}
.required=${this.required}
.validationMessage=${this.selector.number?.validation_message}
type="number"
autoValidate
.withoutSpinButtons=${!isBox}
+27 -3
View File
@@ -31,6 +31,8 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
0;
@property({ type: Boolean }) public stacked = false;
@query(".toast")
private _toast?: HTMLDivElement;
@@ -148,7 +150,12 @@ export class HaToast extends LitElement {
}
private _showToastPopover(): void {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
this._isPopoverOpen()
) {
return;
}
@@ -156,7 +163,12 @@ export class HaToast extends LitElement {
}
private _hideToastPopover(): void {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
!this._isPopoverOpen()
) {
return;
}
@@ -187,12 +199,15 @@ export class HaToast extends LitElement {
class=${classMap({
toast: true,
active: this._active,
stacked: this.stacked,
visible: this._visible,
})}
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
popover=${ifDefined(
popoverSupported && !this.stacked ? "manual" : undefined
)}
>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
@@ -253,6 +268,15 @@ export class HaToast extends LitElement {
transform: translate(calc(-50% * var(--scale-direction)), 0);
}
.toast.stacked {
position: static;
transform: translateY(var(--ha-space-2));
}
.toast.stacked.visible {
transform: translateY(0);
}
.toast:not(.active) {
display: none;
}
+3
View File
@@ -397,6 +397,9 @@ export interface NumberSelector {
unit_of_measurement?: string;
slider_ticks?: boolean;
translation_key?: string;
// Shown instead of the browser's native message when the value fails
// min/max/step constraint validation.
validation_message?: string;
} | null;
}
+26 -13
View File
@@ -102,13 +102,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.checkDataBaseMigration();
}
// Wait for `hass.user` to populate so the admin guard can run; it arrives
// asynchronously after `hass.config`.
if (
changedProps.has("hass") &&
this.hass?.user &&
oldHass?.user !== this.hass.user
) {
// Wait for `hass.user` to first populate so the admin guard can run; it
// arrives asynchronously after `hass.config`. `hass.user` also gets a fresh
// reference at runtime (reconnect, profile refresh via subscribeUser), so
// only trigger on the initial population (null -> user). Reconnect re-checks
// come from connection-mixin, the launch-screen swap re-check from update().
if (changedProps.has("hass") && this.hass?.user && !oldHass?.user) {
this.checkHttpPendingConfig();
}
if (
@@ -125,12 +124,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
protected update(changedProps: PropertyValues<this>) {
if (
this.hass?.states &&
this.hass.config &&
this.hass.services &&
this._databaseMigration === false
) {
const removingLaunchScreen =
!!this.hass?.states &&
!!this.hass.config &&
!!this.hass.services &&
this._databaseMigration === false;
if (removingLaunchScreen) {
this.render = this.renderHass;
this.update = super.update;
// partial-panel-resolver removes the launch screen after the first panel
@@ -138,6 +137,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// screen covers the frontend until frontend/loaded is sent.
}
super.update(changedProps);
if (removingLaunchScreen) {
// Surface the HTTP pending config dialog only after super.update() has
// committed the render swap above, which clears the launch screen from
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
@@ -256,6 +262,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (__DEMO__ || this._httpPendingDialogOpen) {
return;
}
// Only show once the main UI is rendered. During startup the root swaps
// the launch screen for the app, which clears its shadow root and would
// tear the freshly-appended dialog straight back out of the DOM (closing
// it). When called too early we skip; the swap in update() re-runs this.
if (this.render !== this.renderHass) {
return;
}
if (!this.hass?.user?.is_admin) {
return;
}
+220 -81
View File
@@ -1,7 +1,20 @@
import { mdiClose } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-button";
@@ -10,7 +23,7 @@ import type { ToastClosedEventDetail } from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
// Unique ID for updating or closing a specific toast without flickering.
id?: string;
message:
| string
@@ -34,105 +47,150 @@ export interface ToastActionParams {
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
interface Notification {
key: string;
parameters: ShowToastParams;
}
@customElement("notification-manager")
class NotificationManager extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _parameters?: ShowToastParams;
@state() private _notifications: Notification[] = [];
@query("ha-toast")
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
@query(".stack") private _stack?: HTMLDivElement;
private _showDialogId = 0;
@queryAll("ha-toast")
private _toasts!: NodeListOf<HTMLElementTagNameMap["ha-toast"]>;
private _anonymousId = 0;
public async showDialog(parameters: ShowToastParams) {
const showId = ++this._showDialogId;
if (!parameters.id || this._parameters?.id !== parameters.id) {
await this._toast?.hide();
}
if (showId !== this._showDialogId) {
return;
}
if (parameters.duration === 0) {
this._parameters = undefined;
if (parameters.id) {
await this._closeNotification(`identified-${parameters.id}`);
} else {
const notification = [...this._notifications]
.reverse()
.find(({ parameters: { id } }) => !id);
if (notification) {
await this._closeNotification(notification.key);
}
}
return;
}
this._parameters = parameters;
const normalizedParameters = {
...parameters,
duration:
parameters.duration === undefined ||
(parameters.duration > 0 && parameters.duration <= 4000)
? 4000
: parameters.duration,
};
const key = parameters.id
? `identified-${parameters.id}`
: `anonymous-${++this._anonymousId}`;
const existingIndex = parameters.id
? this._notifications.findIndex(
(notification) => notification.key === key
)
: -1;
if (
this._parameters.duration === undefined ||
(this._parameters.duration > 0 && this._parameters.duration <= 4000)
) {
this._parameters.duration = 4000;
}
this._notifications =
existingIndex === -1
? [...this._notifications, { key, parameters: normalizedParameters }]
: this._notifications.map((notification, index) =>
index === existingIndex
? { key, parameters: normalizedParameters }
: notification
);
await this.updateComplete;
if (showId !== this._showDialogId) {
return;
}
this._toast?.show();
this._showStack();
this._getToast(key)?.show();
}
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
private _toastClosed(
ev: HASSDomEvent<ToastClosedEventDetail> &
HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-toast"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
if (ev.detail.reason === "dismiss") {
this._parameters?.dismiss?.();
this._getNotification(key)?.parameters.dismiss?.();
}
this._parameters = undefined;
this._removeNotification(key);
}
protected render() {
if (!this._parameters) {
if (!this._notifications.length) {
return nothing;
}
const bottomOffset = Math.max(
...this._notifications.map(
({ parameters }) => parameters.bottomOffset ?? 0
)
);
return html`
<ha-toast
.labelText=${
typeof this._parameters.message !== "string"
? this.hass.localize(
this._parameters.message.translationKey,
this._parameters.message.args
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
<div
class="stack"
style=${styleMap({
"--notification-stack-bottom-offset": `${bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
${repeat(
this._notifications,
(notification) => notification.key,
({ key, parameters }) => html`
<ha-toast
data-notification-key=${key}
.labelText=${
typeof parameters.message !== "string"
? this.hass.localize(
parameters.message.translationKey,
parameters.message.args
)
: parameters.message
}
.announceText=${
parameters.announceMessage
? typeof parameters.announceMessage !== "string"
? this.hass.localize(
parameters.announceMessage.translationKey,
parameters.announceMessage.args
)
: parameters.announceMessage
: undefined
}
.timeoutMs=${parameters.duration!}
.stacked=${true}
@toast-closed=${this._toastClosed}
>
${this._renderAction(key, parameters.secondaryAction, true)}
${this._renderAction(key, parameters.action, false)}
${
parameters.dismissable
? html`
<ha-icon-button
data-notification-key=${key}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`
)}
</div>
`;
}
private _renderAction(
key: string,
action: ToastActionParams | undefined,
secondary: boolean
) {
@@ -141,6 +199,7 @@ class NotificationManager extends LitElement {
}
return html`
<ha-button
data-notification-key=${key}
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@@ -155,19 +214,99 @@ class NotificationManager extends LitElement {
`;
}
private _buttonClicked() {
this._toast?.hide("action");
this._parameters?.action?.action();
private _buttonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.action?.action();
}
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
private _secondaryButtonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.secondaryAction?.action();
}
private _dismissClicked() {
this._toast?.hide("dismiss");
private _dismissClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
) {
this._getToast(ev.currentTarget.dataset.notificationKey!)?.hide("dismiss");
}
private _getNotification(key: string) {
return this._notifications.find((notification) => notification.key === key);
}
private _getToast(key: string) {
return [...this._toasts].find(
(toast) => toast.dataset.notificationKey === key
);
}
private async _closeNotification(key: string) {
const notification = this._getNotification(key);
if (!notification) {
return;
}
await this._getToast(key)?.hide();
if (this._getNotification(key) === notification) {
this._removeNotification(key);
}
}
private _removeNotification(key: string) {
this._notifications = this._notifications.filter(
(notification) => notification.key !== key
);
if (!this._notifications.length) {
this._hideStack();
}
}
private _showStack() {
if (!popoverSupported || !this._stack) {
return;
}
// Top-layer order is order of entry — re-enter so we paint above any
// dialog backdrop opened since the stack was first shown.
if (this._stack.matches(":popover-open")) {
this._stack.hidePopover();
}
this._stack.showPopover();
}
private _hideStack() {
if (!popoverSupported || !this._stack?.matches(":popover-open")) {
return;
}
this._stack.hidePopover();
}
static override styles = css`
.stack {
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--notification-stack-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
padding: 0;
border: none;
overflow: visible;
background: transparent;
transform: translateX(calc(-50% * var(--scale-direction)));
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-2);
}
`;
}
declare global {
@@ -49,10 +49,7 @@ export default class HaAutomationActionEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled:
!this.indent &&
(this.disabled ||
(this.action.enabled === false && !this.yamlMode)),
disabled: !this.indent && this.disabled,
yaml: yamlMode,
indent: this.indent,
card: !this.inSidebar,
@@ -128,8 +128,12 @@ class DialogAutomationSave
`;
}
private get _isDiscardDialog(): boolean {
return this._params?.onDiscard !== undefined;
}
protected _renderDiscard() {
if (!this._params?.onDiscard) {
if (!this._isDiscardDialog) {
return nothing;
}
return html`
@@ -324,10 +328,14 @@ class DialogAutomationSave
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!!this._params.config.alias && !this.isDirtyState}
.disabled=${
!!this._params.config.alias &&
!this._isDiscardDialog &&
!this.isDirtyState
}
>
${this.hass.localize(
this._params.config.alias && !this._params.onDiscard
this._params.config.alias && !this._isDiscardDialog
? "ui.panel.config.automation.editor.rename"
: "ui.common.save"
)}
@@ -56,10 +56,7 @@ export default class HaAutomationConditionEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled:
!this.indent &&
(this.disabled ||
(this.condition.enabled === false && !this.yamlMode)),
disabled: !this.indent && this.disabled,
yaml: yamlMode,
indent: this.indent,
card: !this.inSidebar,
@@ -35,6 +35,8 @@ import type HaAutomationSidebar from "./ha-automation-sidebar";
export const SIDEBAR_DEFAULT_WIDTH = 500;
export const PASTED_CONFIG_TOAST_ID = "pasted-config";
export const ManualEditorMixin = <TConfig>(
superClass: Constructor<LitElement>
) => {
@@ -237,6 +239,7 @@ export const ManualEditorMixin = <TConfig>(
this.pastedConfig = undefined;
showToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: "",
duration: 0,
});
@@ -37,7 +37,10 @@ import "./action/ha-automation-action";
import type HaAutomationAction from "./action/ha-automation-action";
import "./condition/ha-automation-condition";
import type HaAutomationCondition from "./condition/ha-automation-condition";
import { ManualEditorMixin } from "./ha-manual-editor-mixin";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "./ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "./styles";
import "./trigger/ha-automation-trigger";
@@ -403,6 +406,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.automation.editor.paste_toast_message"
),
@@ -48,11 +48,7 @@ export default class HaAutomationTriggerEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled:
this.disabled ||
("enabled" in this.trigger &&
this.trigger.enabled === false &&
!this.yamlMode),
disabled: this.disabled,
yaml: yamlMode,
card: !this.inSidebar,
})}
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mainWindow } from "../../../common/dom/get_main_window";
import { HOSTNAME_PATTERN } from "../../../common/string/is_hostname";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
@@ -38,7 +39,16 @@ const SCHEMA = memoizeOne(
{
name: "server_port",
required: true,
selector: { number: { min: 1, max: 65535, mode: "box" } },
selector: {
number: {
min: 1,
max: 65535,
mode: "box",
validation_message: localize(
"ui.panel.config.network.http.invalid_port"
),
},
},
},
{
name: "ssl",
@@ -118,7 +128,16 @@ const SCHEMA = memoizeOne(
{
name: "login_attempts_threshold",
required: true,
selector: { number: { min: -1, max: 1000, mode: "box" } },
selector: {
number: {
min: -1,
max: 1000,
mode: "box",
validation_message: localize(
"ui.panel.config.network.http.invalid_login_attempts_threshold"
),
},
},
},
],
},
@@ -133,7 +152,7 @@ const SCHEMA = memoizeOne(
selector: {
text: {
multiple: true,
pattern: IP_ADDRESS_PATTERN,
pattern: `${IP_ADDRESS_PATTERN}|${HOSTNAME_PATTERN}`,
validation_message: localize(
"ui.panel.config.network.http.invalid_host"
),
@@ -443,7 +462,7 @@ class HaConfigHttpForm extends LitElement {
"ui.panel.config.network.http.restart_address.text"
)}
</p>
<a href=${url} rel="noreferrer noopener">${url}</a>
<a href=${url} target="_blank" rel="noreferrer noopener">${url}</a>
<p class="dialog-note">
${this.hass.localize(
"ui.panel.config.network.http.restart_address.note"
@@ -33,7 +33,10 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showEditorToast } from "../automation/editor-toast";
import "../automation/action/ha-automation-action";
import type HaAutomationAction from "../automation/action/ha-automation-action";
import { ManualEditorMixin } from "../automation/ha-manual-editor-mixin";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "../automation/ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "../automation/paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "../automation/styles";
import "./ha-script-fields";
@@ -335,6 +338,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.script.editor.paste_toast_message"
),
@@ -568,10 +568,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
...(sidebarSection && {
sidebar: {
sections: [sidebarSection],
content_label: hass.localize("ui.panel.lovelace.strategy.home.home"),
sidebar_label: hass.localize(
"ui.panel.lovelace.strategy.home.summaries"
),
visibility: [LARGE_SCREEN_CONDITION],
},
}),
+37 -19
View File
@@ -87,11 +87,13 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
if (!totalWidth) return 1;
const style = getComputedStyle(this);
const wrapper = this.shadowRoot!.querySelector(".wrapper")!;
const wrapperStyle = getComputedStyle(wrapper);
const container = this.shadowRoot!.querySelector(".container")!;
const containerStyle = getComputedStyle(container);
const paddingLeft = parsePx(containerStyle.paddingLeft);
const paddingRight = parsePx(containerStyle.paddingRight);
const paddingLeft = parsePx(wrapperStyle.paddingLeft);
const paddingRight = parsePx(wrapperStyle.paddingRight);
const padding = paddingLeft + paddingRight;
const minColumnWidth = parsePx(
style.getPropertyValue("--column-min-width")
@@ -176,6 +178,16 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
const editMode = this.lovelace.editMode;
const hasSidebar =
this._config?.sidebar && (this._sidebarVisible || editMode);
const singleColumn = this._maxColumns <= 1;
// The tab switcher is opt-in: a sidebar enables it by labelling both tabs,
// otherwise the sidebar stacks below the content.
const useSidebarTabs = Boolean(
singleColumn &&
hasSidebar &&
this._config?.sidebar?.content_label &&
this._config?.sidebar?.sidebar_label
);
const totalSectionCount =
this._sectionColumnCount + (editMode ? 1 : 0) + (hasSidebar ? 1 : 0);
@@ -184,9 +196,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
Math.min(this._maxColumns, totalSectionCount),
1
);
// On mobile with sidebar, use full width for whichever view is active
const contentColumnCount =
hasSidebar && !this.narrow ? Math.max(1, columnCount - 1) : columnCount;
const contentColumnCount = hasSidebar
? Math.max(1, columnCount - 1)
: columnCount;
const sectionNeedsMargin = computeSectionsBackgroundAlignment(
sections,
@@ -198,7 +211,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
class="wrapper ${classMap({
"top-margin": Boolean(this._config?.top_margin),
"has-sidebar": Boolean(hasSidebar),
narrow: this.narrow,
"single-column": singleColumn,
"has-sidebar-tabs": useSidebarTabs,
})}"
style=${styleMap({
"--column-count": columnCount,
@@ -213,9 +227,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.config=${this._config?.header}
></hui-view-header>
${
this.narrow && hasSidebar
useSidebarTabs
? html`
<div class="mobile-tabs">
<div class="sidebar-tabs">
<ha-control-select
.value=${this._sidebarTabActive ? "sidebar" : "content"}
@value-changed=${this._viewChanged}
@@ -246,7 +260,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<div
class="content ${classMap({
dense: Boolean(this._config?.dense_section_placement),
"mobile-hidden": this.narrow && this._sidebarTabActive,
hidden: useSidebarTabs && this._sidebarTabActive,
})}"
>
${repeat(
@@ -333,8 +347,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
? html`
<hui-view-sidebar
class=${classMap({
"mobile-hidden":
!hasSidebar || (this.narrow && !this._sidebarTabActive),
hidden:
!hasSidebar ||
(useSidebarTabs && !this._sidebarTabActive),
})}
.hass=${this.hass}
.badges=${this.badges}
@@ -602,8 +617,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
[sidebar-start] 1fr;
}
/* On mobile with sidebar, content and sidebar both take full width */
.wrapper.narrow.has-sidebar .container {
/* With a single column, content and sidebar stack at full width */
.wrapper.single-column.has-sidebar .container {
grid-template-columns: 1fr;
}
@@ -611,18 +626,21 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
grid-column: sidebar-start / -1;
}
.wrapper.narrow hui-view-sidebar {
.wrapper.single-column hui-view-sidebar {
grid-column: 1 / -1;
}
.wrapper.has-sidebar-tabs hui-view-sidebar {
padding-bottom: calc(
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
);
}
.mobile-hidden {
.hidden {
display: none !important;
}
.mobile-tabs {
.sidebar-tabs {
position: fixed;
bottom: calc(var(--ha-space-3) + var(--safe-area-inset-bottom));
left: 50%;
@@ -631,7 +649,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
z-index: 1;
}
.mobile-tabs ha-control-select {
.sidebar-tabs ha-control-select {
width: max-content;
min-width: 280px;
max-width: 90%;
@@ -659,11 +677,11 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
gap: var(--row-gap) var(--column-gap);
}
.wrapper.narrow .content {
.wrapper.single-column .content {
grid-column: 1 / -1;
}
.wrapper.narrow.has-sidebar .content {
.wrapper.has-sidebar-tabs .content {
padding-bottom: calc(
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
);
+9 -1
View File
@@ -13,6 +13,9 @@ import { showToast } from "../util/toast";
import type { HassBaseEl } from "./hass-base-mixin";
import { navigate } from "../common/navigate";
const CONNECTION_LOST_TOAST_ID = "connection-lost";
const SERVER_STARTUP_TOAST_ID = "server-startup";
export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
class extends superClass {
private _subscribedBootstrapIntegrations?: Promise<UnsubscribeFunc>;
@@ -34,6 +37,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (oldHass?.config?.state !== this.hass!.config.state) {
if (this.hass!.config.state === STATE_NOT_RUNNING) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.starting") ||
"Home Assistant is starting. Not everything will be available until it is finished.",
@@ -57,6 +61,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
) {
this._unsubscribeBootstrapIntegrations();
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.started"),
duration: 5000,
});
@@ -95,6 +100,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
return;
}
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: "",
duration: 0,
});
@@ -106,6 +112,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
this._disconnectedTimeout = window.setTimeout(() => {
this._disconnectedTimeout = undefined;
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.connection_lost"),
duration: -1,
dismissable: false,
@@ -120,6 +127,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (Object.keys(message).length === 0) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.wrapping_up_startup") ||
`Wrapping up startup. Not everything will be available until it is finished.`,
@@ -142,7 +150,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
)[0][0];
showToast(this, {
id: "integration_starting",
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.integration_starting", {
integration: domainToName(this.hass!.localize, integration),
+6 -4
View File
@@ -3851,7 +3851,7 @@
},
"disable_view_transition": {
"title": "Disable view transitions",
"description": "Disable animated view transitions to prevent browser crash when using browser dev tools."
"description": "Disable animated view transitions to prevent browser crash when using browser developer tools."
},
"entity_diagnostic": {
"title": "Entity diagnostic",
@@ -3896,7 +3896,7 @@
},
"actions": {
"title": "Actions",
"description": "The actions dev tool allows you to perform any action available in Home Assistant.",
"description": "The actions tool allows you to perform any action available in Home Assistant.",
"call_service": "Perform action",
"response": "Response",
"column_parameter": "Parameter",
@@ -8763,8 +8763,10 @@
"save_error": "Could not save the HTTP configuration.",
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
"server_host_warning": "Leave this empty to listen on all interfaces. If you set specific addresses, Home Assistant is only reachable through those — binding it only to an address that other devices can't reach (for example localhost) will make it unreachable from those devices.",
"invalid_host": "Enter a valid IP address.",
"invalid_host": "Enter a valid IP address or hostname.",
"invalid_network": "Enter a valid IP address or network.",
"invalid_port": "Enter a port number between 1 and 65535.",
"invalid_login_attempts_threshold": "Enter a number between -1 and 1000.",
"running_default": "Your saved HTTP configuration could not be applied, so Home Assistant is running on the built-in default configuration.",
"reverted_not_confirmed": "The last HTTP configuration change was not confirmed in time and was rolled back. Home Assistant is running on the previous configuration.",
"reverted_failed": "The last HTTP configuration change could not be applied and was rolled back. Home Assistant is running on the previous configuration. Reason: {error}",
@@ -8803,7 +8805,7 @@
},
"helpers": {
"server_port": "The port Home Assistant listens on. Default is {port}.",
"server_host": "IP addresses to bind to. Leave empty to listen on all interfaces.",
"server_host": "IP addresses or hostnames to bind to. Leave empty to listen on all interfaces.",
"ssl_certificate": "Absolute path to your TLS certificate (for example, /ssl/fullchain.pem).",
"ssl_key": "Absolute path to your TLS private key (for example, /ssl/privkey.pem).",
"ssl_peer_certificate": "Absolute path to a client certificate Home Assistant should require for secure connections.",
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import { isNavigationClick } from "../../../src/common/dom/is-navigation-click";
const createAnchor = (
href: string,
attributes: Record<string, string> = {}
): HTMLAnchorElement => {
const anchor = document.createElement("a");
anchor.href = href;
for (const [name, value] of Object.entries(attributes)) {
anchor.setAttribute(name, value);
}
return anchor;
};
const clickEvent = (
anchor: HTMLAnchorElement,
overrides: Partial<MouseEvent> = {}
): MouseEvent =>
({
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
composedPath: () => [anchor],
preventDefault: vi.fn(),
...overrides,
}) as unknown as MouseEvent;
describe("isNavigationClick", () => {
it("returns the path for same-origin links", () => {
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}/config`)))
).toEqual("/config");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/energy?historyBack=1`))
)
).toEqual("/energy?historyBack=1");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config/areas#section`))
)
).toEqual("/config/areas#section");
});
it("does not intercept a link on a different port", () => {
// The current origin has no port, so a ported URL shares its string prefix
// but is a different origin — it must not be treated as internal.
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}:8123/`)))
).toBeUndefined();
});
it("does not intercept a host that merely starts with the origin", () => {
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}.example.com/`))
)
).toBeUndefined();
});
it("does not intercept other origins", () => {
expect(
isNavigationClick(clickEvent(createAnchor("https://example.com/")))
).toBeUndefined();
});
it("calls preventDefault for intercepted navigations", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
isNavigationClick(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it("does not preventDefault when disabled", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
expect(isNavigationClick(event, false)).toEqual("/config");
expect(event.preventDefault).not.toHaveBeenCalled();
});
it("ignores clicks with a target, download, or rel=external", () => {
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { target: "_blank" })
)
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config`, { download: "" }))
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { rel: "external" })
)
)
).toBeUndefined();
});
it("ignores modified clicks and non-left buttons", () => {
const anchor = createAnchor(`${location.origin}/config`);
expect(
isNavigationClick(clickEvent(anchor, { button: 1 }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { metaKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { ctrlKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { shiftKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { defaultPrevented: true }))
).toBeUndefined();
});
it("ignores mailto links and clicks without an anchor", () => {
expect(
isNavigationClick(clickEvent(createAnchor("mailto:[email protected]")))
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(undefined as any, {
composedPath: () => [document.createElement("div")],
})
)
).toBeUndefined();
});
});
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { HOSTNAME_PATTERN } from "../../../src/common/string/is_hostname";
import { IP_ADDRESS_PATTERN } from "../../../src/common/string/is_ip_address";
const HOSTNAMES = [
"localhost",
"homeassistant.lan",
"home-assistant.local",
"3com.com",
"sub.domain.example.com",
"a".repeat(63),
];
const INVALID = [
"",
"foo bar",
"foo_bar",
"-foo.lan",
"foo-.lan",
"foo..lan",
"héllo.lan",
"host:8123",
"http://host",
"a".repeat(64),
// All-numeric final label: a mistyped IP address, not a hostname.
"123",
"foo.123",
"300.1.1.1",
"999.999.999.999",
];
// The browser anchors a `pattern` attribute as `^(?:…)$` and compiles it with
// the `v` flag. A pattern that fails to compile under `v` is silently ignored,
// disabling validation entirely, so compile it exactly the same way here.
const patternRegexp = (pattern: string): RegExp =>
new RegExp(`^(?:${pattern})$`, "v");
describe("HOSTNAME_PATTERN", () => {
it("compiles as an HTML pattern attribute", () => {
expect(() => patternRegexp(HOSTNAME_PATTERN)).not.toThrow();
});
it("accepts hostnames", () => {
const regexp = patternRegexp(HOSTNAME_PATTERN);
for (const value of HOSTNAMES) {
expect(regexp.test(value), value).toBe(true);
}
});
it("rejects malformed hostnames", () => {
const regexp = patternRegexp(HOSTNAME_PATTERN);
for (const value of INVALID) {
expect(regexp.test(value), value).toBe(false);
}
});
});
// How ha-config-http-form validates its listen addresses.
describe("IP address or hostname pattern", () => {
const composed = `${IP_ADDRESS_PATTERN}|${HOSTNAME_PATTERN}`;
it("compiles as an HTML pattern attribute", () => {
expect(() => patternRegexp(composed)).not.toThrow();
});
it("accepts hostnames and IP addresses", () => {
const regexp = patternRegexp(composed);
for (const value of [
...HOSTNAMES,
"192.168.1.10",
"0.0.0.0",
"255.255.255.255",
"fe80::1",
"::1",
]) {
expect(regexp.test(value), value).toBe(true);
}
});
it("rejects malformed addresses", () => {
const regexp = patternRegexp(composed);
for (const value of INVALID) {
expect(regexp.test(value), value).toBe(false);
}
});
});
+13
View File
@@ -118,6 +118,19 @@ describe("ha-toast", () => {
).toBe(true);
});
it("shows as part of a stack without becoming a popover", async () => {
const element = await mountToast({ stacked: true });
await showToast(element);
expect(
element.shadowRoot!.querySelector(".toast")!.hasAttribute("popover")
).toBe(false);
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it.each(["action", "dismiss", "programmatic"] as const)(
"reports a %s close reason",
async (reason) => {
+143 -26
View File
@@ -60,14 +60,6 @@ const mountManager = async () => {
return host.shadowRoot!.querySelector("notification-manager")!;
};
const deferred = () => {
let resolve: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve: resolve! };
};
afterEach(() => {
host?.remove();
host = undefined;
@@ -81,9 +73,12 @@ describe("notification-manager", () => {
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(toast.bottomOffset).toBe(0);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("0px");
expect(toast.show).toHaveBeenCalledOnce();
});
@@ -98,21 +93,34 @@ describe("notification-manager", () => {
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.timeoutMs).toBe(-1);
expect(toast.bottomOffset).toBe(16);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("16px");
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
it("clears the current notification when duration is zero", async () => {
it("clears the latest anonymous notification when duration is zero without an ID", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "First message", duration: -1 });
await manager.showDialog({
id: "identified",
message: "Identified message",
duration: -1,
});
await manager.showDialog({ message: "Second message", duration: -1 });
await manager.showDialog({ message: "", duration: 0 });
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["First message", "Identified message"]);
});
it.each([
@@ -190,6 +198,32 @@ describe("notification-manager", () => {
expect(primary).toHaveBeenCalledOnce();
});
it("invokes the action belonging to the selected stacked toast", async () => {
const manager = await mountManager();
const firstAction = vi.fn();
const secondAction = vi.fn();
await manager.showDialog({
id: "first",
message: "First",
action: { action: firstAction, text: "First action" },
duration: -1,
});
await manager.showDialog({
id: "second",
message: "Second",
action: { action: secondAction, text: "Second action" },
duration: -1,
});
manager.shadowRoot!.querySelectorAll("ha-button")[1].click();
expect(secondAction).toHaveBeenCalledOnce();
expect(firstAction).not.toHaveBeenCalled();
expect(
manager.shadowRoot!.querySelectorAll("ha-toast")[1].hide
).toHaveBeenCalledWith("action");
});
it("keeps a same-ID toast visible while replacing its content", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
@@ -202,31 +236,114 @@ describe("notification-manager", () => {
expect(toast.labelText).toBe("Second");
});
it("hides a toast before replacing it with a different ID", async () => {
it("stacks toasts with different IDs", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "second", message: "Second" });
expect(toast.hide).toHaveBeenCalledOnce();
expect(toast.labelText).toBe("Second");
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(2);
expect([...toasts].map((toast) => toast.labelText)).toEqual([
"First",
"Second",
]);
});
it("ignores a stale replacement after a newer notification starts", async () => {
it("uses the largest bottom offset for the notification stack", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "first",
message: "First",
bottomOffset: 16,
});
await manager.showDialog({
id: "second",
message: "Second",
bottomOffset: 48,
});
expect(
manager
.shadowRoot!.querySelector<HTMLElement>(".stack")!
.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("48px");
});
it("closes only the toast matching a duration-zero ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
await manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "first", message: "", duration: 0 });
await manager.updateComplete;
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(1);
expect(toasts[0].labelText).toBe("Second");
});
it("keeps a same-ID update that arrives while the previous toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const delayedHide = deferred();
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
let finishHide: () => void;
vi.mocked(toast.hide).mockImplementation(
() =>
new Promise<void>((resolve) => {
finishHide = resolve;
})
);
const staleShow = manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "third", message: "Third" });
delayedHide.resolve();
await staleShow;
const closing = manager.showDialog({
id: "status",
message: "",
duration: 0,
});
await Promise.resolve();
await manager.showDialog({ id: "status", message: "Second" });
finishHide!();
await closing;
await manager.updateComplete;
expect(toast.labelText).toBe("Third");
const remainingToast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(remainingToast.labelText).toBe("Second");
});
it("keeps a frontend update visible throughout server startup", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "frontend-update-available",
message: "Frontend update available",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Home Assistant is starting",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Starting recorder",
duration: -1,
});
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Starting recorder"]);
await manager.showDialog({
id: "server-startup",
message: "Home Assistant started",
duration: 5000,
});
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Home Assistant started"]);
});
it("clears rendered state after the toast closes", async () => {