mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-05 05:58:13 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a3f3099ed | |||
| e1076d5f11 | |||
| 9037739496 | |||
| 42e4c6e4f4 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,94 @@ 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?.matches(":popover-open")) {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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";
|
||||
@@ -431,6 +434,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"
|
||||
),
|
||||
|
||||
@@ -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";
|
||||
@@ -339,6 +342,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"
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import "../../../../src/panels/config/script/manual-script-editor";
|
||||
import type { HaManualScriptEditor } from "../../../../src/panels/config/script/manual-script-editor";
|
||||
import { createMockHass } from "../../../fixtures/hass";
|
||||
import { showPasteReplaceDialog } from "../../../../src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace";
|
||||
import { PASTED_CONFIG_TOAST_ID } from "../../../../src/panels/config/automation/ha-manual-editor-mixin";
|
||||
|
||||
const pasteCases = [
|
||||
{
|
||||
@@ -163,6 +164,8 @@ describe("manual automation paste", () => {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
const notification = vi.fn();
|
||||
el.addEventListener("hass-notification", notification);
|
||||
|
||||
// Call the protected method through `any` to avoid full DOM lifecycle.
|
||||
await (el as any).handlePaste(makePasteEvent(paste));
|
||||
@@ -171,6 +174,9 @@ describe("manual automation paste", () => {
|
||||
|
||||
const ev = await valueChanged;
|
||||
expect(ev.detail.value).toEqual(expected);
|
||||
expect(notification.mock.calls[0][0].detail.id).toBe(
|
||||
PASTED_CONFIG_TOAST_ID
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user