mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-04 21:52:59 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4788748cd |
@@ -31,33 +31,6 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
|
||||
|
||||
## Recurring Review Issues
|
||||
|
||||
Scope and public surface:
|
||||
|
||||
- Keep changes independently reviewable and limited to the requested area.
|
||||
- Prefer existing Home Assistant helpers, Lit primitives, and component seams over parallel implementations.
|
||||
- Challenge new public properties and optional feature surface when transient options or existing seams meet the requirement with less lifecycle and consistency cost.
|
||||
|
||||
Stateful and asynchronous UI:
|
||||
|
||||
- Review transitions in both directions, not only individual rendered states.
|
||||
- When controls reappear, restore valid defaults instead of retaining state that was only valid while they were hidden.
|
||||
- Establish immutable dirty-state baselines before asynchronous work, guard against stale responses, and preserve unsaved state in mounted editors.
|
||||
- Determine an action's current meaning before applying dirty-state checks, especially when an action can change between Save and Close.
|
||||
|
||||
Readiness and invalidation:
|
||||
|
||||
- Treat readiness as the first displayable terminal result, including stable empty and error states.
|
||||
- Register child readiness before resolving the parent, do not treat fallback work as terminal, and replay readiness correctly for cached or reused panels.
|
||||
- Ensure every value read by memoized output participates in its invalidation.
|
||||
|
||||
Repository-owned contracts:
|
||||
|
||||
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
|
||||
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
|
||||
- Prefer canonical repository helpers and test setup over duplicate local implementations.
|
||||
- Promote AI-review concerns into durable guidance only when supported by code evidence, reproduced behavior, an accepted corrective commit, or human-maintainer validation.
|
||||
|
||||
User experience and accessibility:
|
||||
|
||||
- Forms need proper labels, helper text, and validation feedback.
|
||||
@@ -97,4 +70,4 @@ Configuration and props:
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
|
||||
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-events`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
|
||||
|
||||
+2
-2
@@ -108,7 +108,7 @@
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"js-yaml": "5.2.3",
|
||||
"js-yaml": "5.2.2",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -160,7 +160,7 @@
|
||||
"@types/color-name": "2.0.0",
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
|
||||
@@ -31,8 +31,6 @@ 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;
|
||||
|
||||
@@ -150,12 +148,7 @@ export class HaToast extends LitElement {
|
||||
}
|
||||
|
||||
private _showToastPopover(): void {
|
||||
if (
|
||||
!this._toast ||
|
||||
this.stacked ||
|
||||
!popoverSupported ||
|
||||
this._isPopoverOpen()
|
||||
) {
|
||||
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,12 +156,7 @@ export class HaToast extends LitElement {
|
||||
}
|
||||
|
||||
private _hideToastPopover(): void {
|
||||
if (
|
||||
!this._toast ||
|
||||
this.stacked ||
|
||||
!popoverSupported ||
|
||||
!this._isPopoverOpen()
|
||||
) {
|
||||
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -199,15 +187,12 @@ 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 && !this.stacked ? "manual" : undefined
|
||||
)}
|
||||
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
|
||||
>
|
||||
<span class="message">${this.labelText}</span>
|
||||
<div class=${classMap({ actions: true, "has-action": hasAction })}>
|
||||
@@ -268,15 +253,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -175,8 +175,6 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _pauseAutoFit = false;
|
||||
|
||||
private _pendingFit?: () => void;
|
||||
|
||||
public connectedCallback(): void {
|
||||
this._pauseAutoFit = false;
|
||||
document.addEventListener("visibilitychange", this._handleVisibilityChange);
|
||||
@@ -206,7 +204,6 @@ export class HaMap extends ReactiveElement {
|
||||
this.Leaflet = undefined;
|
||||
}
|
||||
|
||||
this._pendingFit = undefined;
|
||||
this._loaded = false;
|
||||
|
||||
if (this._resizeObserver) {
|
||||
@@ -350,10 +347,6 @@ export class HaMap extends ReactiveElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._deferIfUnsized(() => this.fitMap(options))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this._mapFocusItems.length &&
|
||||
!this._mapFocusZones.length &&
|
||||
@@ -394,40 +387,6 @@ export class HaMap extends ReactiveElement {
|
||||
}, PROGRAMMITIC_FIT_DELAY);
|
||||
}
|
||||
|
||||
// Leaflet derives the zoom level that fits given bounds from the current
|
||||
// size of the map container. When the container has not been laid out yet,
|
||||
// that size is 0x0 and the computed zoom collapses to the minimum, leaving
|
||||
// the map zoomed out to the world even after the container gets its size.
|
||||
// Defer fitting until the resize observer reports a usable size.
|
||||
private _deferIfUnsized(fit: () => void): boolean {
|
||||
const size = this.leafletMap!.getSize();
|
||||
if (size.x > 0 && size.y > 0) {
|
||||
this._pendingFit = undefined;
|
||||
return false;
|
||||
}
|
||||
const container = this.leafletMap!.getContainer();
|
||||
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
||||
// The container was laid out since Leaflet last measured it.
|
||||
this.leafletMap!.invalidateSize(false);
|
||||
this._pendingFit = undefined;
|
||||
return false;
|
||||
}
|
||||
this._pendingFit = fit;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _runPendingFit(): void {
|
||||
if (!this._pendingFit || !this.leafletMap) {
|
||||
return;
|
||||
}
|
||||
const size = this.leafletMap.getSize();
|
||||
if (size.x > 0 && size.y > 0) {
|
||||
const pendingFit = this._pendingFit;
|
||||
this._pendingFit = undefined;
|
||||
pendingFit();
|
||||
}
|
||||
}
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: LatLngExpression[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
@@ -435,9 +394,6 @@ export class HaMap extends ReactiveElement {
|
||||
if (!this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) {
|
||||
return;
|
||||
}
|
||||
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
|
||||
options?.pad ?? 0.5
|
||||
);
|
||||
@@ -817,7 +773,6 @@ export class HaMap extends ReactiveElement {
|
||||
if (!this._resizeObserver) {
|
||||
this._resizeObserver = new ResizeObserver(() => {
|
||||
this.leafletMap?.invalidateSize({ debounceMoveend: true });
|
||||
this._runPendingFit();
|
||||
});
|
||||
}
|
||||
this._resizeObserver.observe(this);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { CSSResult } from "lit";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
@@ -8,7 +7,6 @@ import { navigate } from "../common/navigate";
|
||||
import type { CustomPanelInfo } from "../data/panel_custom";
|
||||
import { baseEntrypointStyles } from "../resources/styles";
|
||||
import { createCustomPanelElement } from "../util/custom-panel/create-custom-panel-element";
|
||||
import { dropRealmCollections } from "../util/custom-panel/drop-realm-collections";
|
||||
import { loadCustomPanel } from "../util/custom-panel/load-custom-panel";
|
||||
import { setCustomPanelProperties } from "../util/custom-panel/set-custom-panel-properties";
|
||||
|
||||
@@ -31,14 +29,8 @@ window.loadES5Adapter = () => {
|
||||
|
||||
let panelEl: HTMLElement | undefined;
|
||||
let initialized = false;
|
||||
// Kept so we can clean up after ourselves on pagehide without depending on
|
||||
// `window.parent.customPanel`, which `_cleanupPanel()` may already have deleted.
|
||||
let connection: Connection | undefined;
|
||||
|
||||
function setProperties(properties) {
|
||||
if (properties.hass?.connection) {
|
||||
connection = properties.hass.connection;
|
||||
}
|
||||
if (!panelEl) {
|
||||
return;
|
||||
}
|
||||
@@ -158,9 +150,4 @@ window.addEventListener("pagehide", () => {
|
||||
while (document.body.lastChild) {
|
||||
document.body.removeChild(document.body.lastChild);
|
||||
}
|
||||
// The connection is owned by the main window and outlives this realm, so any
|
||||
// collection we created on it has to go with us.
|
||||
if (connection) {
|
||||
dropRealmCollections(connection);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
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 { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-icon-button";
|
||||
@@ -23,7 +10,7 @@ import type { ToastClosedEventDetail } from "../components/ha-toast";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface ShowToastParams {
|
||||
// Unique ID for updating or closing a specific toast without flickering.
|
||||
// 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.
|
||||
id?: string;
|
||||
message:
|
||||
| string
|
||||
@@ -47,150 +34,105 @@ 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 _notifications: Notification[] = [];
|
||||
@state() private _parameters?: ShowToastParams;
|
||||
|
||||
@query(".stack") private _stack?: HTMLDivElement;
|
||||
@query("ha-toast")
|
||||
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
|
||||
|
||||
@queryAll("ha-toast")
|
||||
private _toasts!: NodeListOf<HTMLElementTagNameMap["ha-toast"]>;
|
||||
|
||||
private _anonymousId = 0;
|
||||
private _showDialogId = 0;
|
||||
|
||||
public async showDialog(parameters: ShowToastParams) {
|
||||
if (parameters.duration === 0) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
const showId = ++this._showDialogId;
|
||||
|
||||
if (!parameters.id || this._parameters?.id !== parameters.id) {
|
||||
await this._toast?.hide();
|
||||
}
|
||||
|
||||
if (showId !== this._showDialogId) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 (parameters.duration === 0) {
|
||||
this._parameters = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
this._notifications =
|
||||
existingIndex === -1
|
||||
? [...this._notifications, { key, parameters: normalizedParameters }]
|
||||
: this._notifications.map((notification, index) =>
|
||||
index === existingIndex
|
||||
? { key, parameters: normalizedParameters }
|
||||
: notification
|
||||
);
|
||||
this._parameters = parameters;
|
||||
|
||||
if (
|
||||
this._parameters.duration === undefined ||
|
||||
(this._parameters.duration > 0 && this._parameters.duration <= 4000)
|
||||
) {
|
||||
this._parameters.duration = 4000;
|
||||
}
|
||||
|
||||
await this.updateComplete;
|
||||
this._showStack();
|
||||
this._getToast(key)?.show();
|
||||
|
||||
if (showId !== this._showDialogId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._toast?.show();
|
||||
}
|
||||
|
||||
private _toastClosed(
|
||||
ev: HASSDomEvent<ToastClosedEventDetail> &
|
||||
HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-toast"]>
|
||||
) {
|
||||
const key = ev.currentTarget.dataset.notificationKey!;
|
||||
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
|
||||
if (ev.detail.reason === "dismiss") {
|
||||
this._getNotification(key)?.parameters.dismiss?.();
|
||||
this._parameters?.dismiss?.();
|
||||
}
|
||||
this._removeNotification(key);
|
||||
this._parameters = undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._notifications.length) {
|
||||
if (!this._parameters) {
|
||||
return nothing;
|
||||
}
|
||||
const bottomOffset = Math.max(
|
||||
...this._notifications.map(
|
||||
({ parameters }) => parameters.bottomOffset ?? 0
|
||||
)
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
class="stack"
|
||||
style=${styleMap({
|
||||
"--notification-stack-bottom-offset": `${bottomOffset}px`,
|
||||
})}
|
||||
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
|
||||
<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}
|
||||
>
|
||||
${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>
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAction(
|
||||
key: string,
|
||||
action: ToastActionParams | undefined,
|
||||
secondary: boolean
|
||||
) {
|
||||
@@ -199,7 +141,6 @@ class NotificationManager extends LitElement {
|
||||
}
|
||||
return html`
|
||||
<ha-button
|
||||
data-notification-key=${key}
|
||||
appearance=${action.primary ? "filled" : "plain"}
|
||||
size="s"
|
||||
slot="action"
|
||||
@@ -214,94 +155,19 @@ class NotificationManager extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
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 _buttonClicked() {
|
||||
this._toast?.hide("action");
|
||||
this._parameters?.action?.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 _secondaryButtonClicked() {
|
||||
this._toast?.hide("action");
|
||||
this._parameters?.secondaryAction?.action();
|
||||
}
|
||||
|
||||
private _dismissClicked(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
|
||||
) {
|
||||
this._getToast(ev.currentTarget.dataset.notificationKey!)?.hide("dismiss");
|
||||
private _dismissClicked() {
|
||||
this._toast?.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,8 +35,6 @@ 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>
|
||||
) => {
|
||||
@@ -239,7 +237,6 @@ export const ManualEditorMixin = <TConfig>(
|
||||
this.pastedConfig = undefined;
|
||||
|
||||
showToast(this, {
|
||||
id: PASTED_CONFIG_TOAST_ID,
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
@@ -37,10 +37,7 @@ 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,
|
||||
PASTED_CONFIG_TOAST_ID,
|
||||
} from "./ha-manual-editor-mixin";
|
||||
import { ManualEditorMixin } 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";
|
||||
@@ -54,8 +51,6 @@ const baseConfigStruct = object({
|
||||
mode: optional(string()),
|
||||
max_exceeded: optional(string()),
|
||||
id: optional(string()),
|
||||
variables: optional(object()),
|
||||
trigger_variables: optional(object()),
|
||||
});
|
||||
|
||||
const automationConfigStruct = union([
|
||||
@@ -262,27 +257,6 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
}
|
||||
}
|
||||
|
||||
// If an object can be ambiguously an action or an automation, check for
|
||||
// toplevel automation keywords to disqualify it
|
||||
function isQualifiedAction(cfg): boolean {
|
||||
const type = getActionType(cfg);
|
||||
if (type === "variables") {
|
||||
if (
|
||||
[
|
||||
"trigger",
|
||||
"triggers",
|
||||
"condition",
|
||||
"conditions",
|
||||
"action",
|
||||
"actions",
|
||||
].some((key) => key in cfg)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return type !== "unknown";
|
||||
}
|
||||
|
||||
if (Array.isArray(config)) {
|
||||
if (config.length === 1) {
|
||||
config = config[0];
|
||||
@@ -302,7 +276,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
found = true;
|
||||
(newConfig.conditions as Condition[]).push(cfg);
|
||||
}
|
||||
if (isQualifiedAction(cfg)) {
|
||||
if (getActionType(cfg) !== "unknown") {
|
||||
found = true;
|
||||
(newConfig.actions as Action[]).push(cfg);
|
||||
}
|
||||
@@ -319,7 +293,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
if (isCondition(config)) {
|
||||
config = { conditions: [config] };
|
||||
}
|
||||
if (isQualifiedAction(config)) {
|
||||
if (getActionType(config) !== "unknown") {
|
||||
config = { actions: [config] };
|
||||
}
|
||||
|
||||
@@ -401,11 +375,6 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
if (!workingCopy) {
|
||||
return;
|
||||
}
|
||||
["variables", "trigger_variables"].forEach((key) => {
|
||||
if (key in config) {
|
||||
workingCopy[key] = { ...workingCopy[key], ...config[key] };
|
||||
}
|
||||
});
|
||||
|
||||
if ("triggers" in config) {
|
||||
workingCopy.triggers = ensureArray(workingCopy.triggers || []).concat(
|
||||
@@ -434,7 +403,6 @@ 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,10 +33,7 @@ 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,
|
||||
PASTED_CONFIG_TOAST_ID,
|
||||
} from "../automation/ha-manual-editor-mixin";
|
||||
import { ManualEditorMixin } 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";
|
||||
@@ -50,7 +47,6 @@ const scriptConfigStruct = object({
|
||||
mode: optional(enums([typeof MODES])),
|
||||
max: optional(number()),
|
||||
fields: optional(object()),
|
||||
variables: optional(object()),
|
||||
});
|
||||
|
||||
@customElement("manual-script-editor")
|
||||
@@ -236,9 +232,8 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
|
||||
|
||||
const actionType = getActionType(config);
|
||||
if (
|
||||
!["sequence", "variables", "unknown"].includes(actionType) ||
|
||||
(actionType === "sequence" && "metadata" in config) ||
|
||||
(actionType === "variables" && !("sequence" in config))
|
||||
!["sequence", "unknown"].includes(actionType) ||
|
||||
(actionType === "sequence" && "metadata" in config)
|
||||
) {
|
||||
config = { sequence: [config] };
|
||||
}
|
||||
@@ -317,14 +312,12 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
|
||||
return;
|
||||
}
|
||||
|
||||
["fields", "variables"].forEach((key) => {
|
||||
if (key in config) {
|
||||
workingCopy[key] = {
|
||||
...workingCopy[key],
|
||||
...config[key],
|
||||
};
|
||||
}
|
||||
});
|
||||
if ("fields" in config) {
|
||||
workingCopy.fields = {
|
||||
...workingCopy.fields,
|
||||
...config.fields,
|
||||
};
|
||||
}
|
||||
if ("sequence" in config) {
|
||||
workingCopy.sequence = ensureArray(workingCopy.sequence || []).concat(
|
||||
ensureArray(config.sequence)
|
||||
@@ -342,7 +335,6 @@ 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"
|
||||
),
|
||||
|
||||
@@ -149,6 +149,7 @@ export class HuiEnergyDevicesDetailGraphCard
|
||||
this._yAxisFractionDigits,
|
||||
this._legendData
|
||||
)}
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
click-label-for-more-info
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
|
||||
@@ -192,6 +192,7 @@ export class HuiEnergyDevicesGraphCard
|
||||
)}
|
||||
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
|
||||
.extraComponents=${[PieChart]}
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
click-label-for-more-info
|
||||
@chart-click=${this._handleChartClick}
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
|
||||
@@ -177,6 +177,7 @@ export class HuiEnergyUsageGraphCard
|
||||
this._legendData
|
||||
)}
|
||||
chart-type="bar"
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
></ha-chart-base>
|
||||
${
|
||||
!this._chartData.some((dataset) => dataset.data!.length)
|
||||
|
||||
@@ -121,6 +121,7 @@ export class HuiPowerSourcesGraphCard
|
||||
this._legendData,
|
||||
this._yAxisFractionDigits
|
||||
)}
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
></ha-chart-base>
|
||||
${
|
||||
!this._chartData.some((dataset) => dataset.data!.length)
|
||||
|
||||
@@ -189,6 +189,7 @@ export interface EnergyDistributionCardConfig extends EnergyCardConfig {
|
||||
export interface EnergyUsageGraphCardConfig extends EnergyCardConfig {
|
||||
type: "energy-usage-graph";
|
||||
show_legend?: boolean;
|
||||
expand_legend?: boolean;
|
||||
}
|
||||
|
||||
export interface EnergySolarGraphCardConfig extends EnergyCardConfig {
|
||||
@@ -208,11 +209,13 @@ export interface EnergyDevicesGraphCardConfig extends EnergyCardConfig {
|
||||
max_devices?: number;
|
||||
hide_compound_stats?: boolean;
|
||||
modes?: ("bar" | "pie")[];
|
||||
expand_legend?: boolean;
|
||||
}
|
||||
|
||||
export interface EnergyDevicesDetailGraphCardConfig extends EnergyCardConfig {
|
||||
type: "energy-devices-detail-graph";
|
||||
max_devices?: number;
|
||||
expand_legend?: boolean;
|
||||
}
|
||||
|
||||
export interface EnergySourcesTableCardConfig extends EnergyCardConfig {
|
||||
@@ -244,6 +247,7 @@ export interface EnergyCarbonGaugeCardConfig extends EnergyCardConfig {
|
||||
export interface PowerSourcesGraphCardConfig extends EnergyCardConfig {
|
||||
type: "power-sources-graph";
|
||||
show_legend?: boolean;
|
||||
expand_legend?: boolean;
|
||||
}
|
||||
|
||||
export interface EnergySankeyCardConfig extends EnergyCardSankeyConfig {
|
||||
|
||||
@@ -38,6 +38,7 @@ const cardConfigStruct = assign(
|
||||
max_devices: optional(number()),
|
||||
modes: optional(array(union([literal("bar"), literal("pie")]))),
|
||||
hide_compound_stats: optional(boolean()),
|
||||
expand_legend: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -91,6 +92,11 @@ export class HuiEnergyDevicesCardEditor
|
||||
visible: { field: "type", value: "energy-devices-graph" },
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "max_devices",
|
||||
required: false,
|
||||
|
||||
@@ -40,6 +40,19 @@ const SCHEMA: HaFormSchema[] = [
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
visible: [
|
||||
{
|
||||
field: "type",
|
||||
operator: "in",
|
||||
value: ["power-sources-graph", "energy-usage-graph"],
|
||||
},
|
||||
{ field: "show_legend", operator: "not_eq", value: false },
|
||||
],
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "link_dashboard",
|
||||
visible: { field: "type", value: "energy-distribution" },
|
||||
@@ -73,6 +86,7 @@ const cardConfigStruct = assign(
|
||||
title: optional(string()),
|
||||
collection_key: optional(string()),
|
||||
show_legend: optional(boolean()),
|
||||
expand_legend: optional(boolean()),
|
||||
link_dashboard: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -13,9 +13,6 @@ 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>;
|
||||
@@ -37,7 +34,6 @@ 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.",
|
||||
@@ -61,7 +57,6 @@ 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,
|
||||
});
|
||||
@@ -100,7 +95,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
||||
return;
|
||||
}
|
||||
showToast(this, {
|
||||
id: CONNECTION_LOST_TOAST_ID,
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
@@ -112,7 +106,6 @@ 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,
|
||||
@@ -127,7 +120,6 @@ 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.`,
|
||||
@@ -150,7 +142,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
||||
)[0][0];
|
||||
|
||||
showToast(this, {
|
||||
id: SERVER_STARTUP_TOAST_ID,
|
||||
id: "integration_starting",
|
||||
message:
|
||||
this.hass!.localize("ui.notification_toast.integration_starting", {
|
||||
integration: domainToName(this.hass!.localize, integration),
|
||||
|
||||
@@ -10095,6 +10095,7 @@
|
||||
"double_tap_action": "Double tap behavior",
|
||||
"entities": "Entities",
|
||||
"entity": "Entity",
|
||||
"expand_legend": "Expand legend",
|
||||
"fit_mode": "Fit mode",
|
||||
"fit_mode_options": {
|
||||
"contain": "Scaled (Contain)",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
|
||||
/** Duck-typed shape of a `getCollection()` result cached on a `Connection`. */
|
||||
interface CachedCollection {
|
||||
refresh: () => Promise<unknown>;
|
||||
subscribe: (subscriber: (state: unknown) => void) => () => void;
|
||||
}
|
||||
|
||||
const isCachedCollection = (value: unknown): value is CachedCollection =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as CachedCollection).subscribe === "function" &&
|
||||
typeof (value as CachedCollection).refresh === "function";
|
||||
|
||||
/**
|
||||
* Drop the websocket collections that were created by this window from the
|
||||
* shared connection cache.
|
||||
*
|
||||
* `getCollection()` caches collections (entity registry, label registry, ...) on
|
||||
* the `Connection` object. A custom panel embedded in an iframe shares the
|
||||
* connection with the main window, so a collection the panel is the first to
|
||||
* request is created inside the iframe's realm. When the iframe is removed, that
|
||||
* realm is destroyed while the collection stays cached on the connection - its
|
||||
* store, and the timer that hands the cached state to new subscribers, are gone.
|
||||
* Every later subscriber (the main frontend or the next instance of the panel)
|
||||
* then waits forever for a callback that can never fire.
|
||||
*
|
||||
* Must be called from the realm that is going away - `instanceof Function` is
|
||||
* realm-specific and only matches collections created by this window.
|
||||
*/
|
||||
export const dropRealmCollections = (connection: Connection): void => {
|
||||
// `getCollection()` stores collections on the connection under keys that are
|
||||
// not part of its type, so treat it as the bag of properties it is.
|
||||
const cache = connection as unknown as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(cache)) {
|
||||
if (isCachedCollection(value) && value.subscribe instanceof Function) {
|
||||
delete cache[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -118,19 +118,6 @@ 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) => {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "../../../src/components/map/ha-map";
|
||||
import type { HaMap } from "../../../src/components/map/ha-map";
|
||||
|
||||
type ROCallback = (
|
||||
entries: ResizeObserverEntry[],
|
||||
observer: ResizeObserver
|
||||
) => void;
|
||||
|
||||
const resizeCallbacks: ROCallback[] = [];
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(cb: ROCallback) {
|
||||
resizeCallbacks.push(cb);
|
||||
}
|
||||
|
||||
observe = vi.fn();
|
||||
|
||||
unobserve = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
const STATES = {
|
||||
"device_tracker.paulus": {
|
||||
entity_id: "device_tracker.paulus",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
friendly_name: "Paulus",
|
||||
latitude: 52.372,
|
||||
longitude: 4.89,
|
||||
},
|
||||
context: { id: "1", user_id: null, parent_id: null },
|
||||
last_changed: "2026-01-01T00:00:00Z",
|
||||
last_updated: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
"device_tracker.anne_therese": {
|
||||
entity_id: "device_tracker.anne_therese",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
friendly_name: "Anne Therese",
|
||||
latitude: 52.377,
|
||||
longitude: 4.895,
|
||||
},
|
||||
context: { id: "2", user_id: null, parent_id: null },
|
||||
last_changed: "2026-01-01T00:00:00Z",
|
||||
last_updated: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
} as unknown as HassEntities;
|
||||
|
||||
const createMap = async (): Promise<HaMap> => {
|
||||
const el = document.createElement("ha-map");
|
||||
el.entities = [
|
||||
"device_tracker.paulus",
|
||||
"device_tracker.anne_therese",
|
||||
] as string[];
|
||||
el.clusterMarkers = false;
|
||||
(el as any)._states = STATES;
|
||||
(el as any)._config = {
|
||||
config: { latitude: 52.3731339, longitude: 4.8903147 },
|
||||
};
|
||||
document.body.appendChild(el);
|
||||
await vi.waitUntil(() => el.leafletMap !== undefined && (el as any)._loaded);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
};
|
||||
|
||||
const setMapSize = (el: HaMap, width: number, height: number) => {
|
||||
const mapDiv = el.shadowRoot!.getElementById("map")!;
|
||||
Object.defineProperty(mapDiv, "clientWidth", {
|
||||
value: width,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mapDiv, "clientHeight", {
|
||||
value: height,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const fireResizeObservers = () => {
|
||||
resizeCallbacks.forEach((cb) => cb([], {} as ResizeObserver));
|
||||
};
|
||||
|
||||
describe("ha-map", () => {
|
||||
beforeEach(() => {
|
||||
resizeCallbacks.length = 0;
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("fits the map to its entities once the container is laid out", async () => {
|
||||
// While the container still has a 0x0 size (before browser layout),
|
||||
// Leaflet cannot compute a fit zoom.
|
||||
const el = await createMap();
|
||||
|
||||
// Container gets its size and the resize observer fires, as happens
|
||||
// when the browser completes layout after the map loaded.
|
||||
setMapSize(el, 800, 500);
|
||||
fireResizeObservers();
|
||||
await el.updateComplete;
|
||||
|
||||
const map = el.leafletMap!;
|
||||
// The map should be fitted to the markers, not zoomed out to the world.
|
||||
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
|
||||
expect(map.getCenter().lat).toBeCloseTo(52.3745, 2);
|
||||
expect(map.getCenter().lng).toBeCloseTo(4.8925, 2);
|
||||
});
|
||||
|
||||
it("does not defer fitting when the container already has a size", async () => {
|
||||
const originalWidth = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
"clientWidth"
|
||||
);
|
||||
const originalHeight = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
"clientHeight"
|
||||
);
|
||||
Object.defineProperty(Element.prototype, "clientWidth", {
|
||||
get: () => 800,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(Element.prototype, "clientHeight", {
|
||||
get: () => 500,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const el = await createMap();
|
||||
const map = el.leafletMap!;
|
||||
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
|
||||
expect(map.getCenter().lat).toBeCloseTo(52.3745, 2);
|
||||
expect(map.getCenter().lng).toBeCloseTo(4.8925, 2);
|
||||
} finally {
|
||||
Object.defineProperty(Element.prototype, "clientWidth", originalWidth!);
|
||||
Object.defineProperty(Element.prototype, "clientHeight", originalHeight!);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,14 @@ 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;
|
||||
@@ -73,12 +81,9 @@ 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(
|
||||
stack.style.getPropertyValue("--notification-stack-bottom-offset")
|
||||
).toBe("0px");
|
||||
expect(toast.bottomOffset).toBe(0);
|
||||
expect(toast.show).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -93,34 +98,21 @@ describe("notification-manager", () => {
|
||||
});
|
||||
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
|
||||
expect(toast.timeoutMs).toBe(-1);
|
||||
expect(
|
||||
stack.style.getPropertyValue("--notification-stack-bottom-offset")
|
||||
).toBe("16px");
|
||||
expect(toast.bottomOffset).toBe(16);
|
||||
|
||||
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
|
||||
expect(toast.hide).toHaveBeenCalledWith("dismiss");
|
||||
});
|
||||
|
||||
it("clears the latest anonymous notification when duration is zero without an ID", async () => {
|
||||
it("clears the current notification when duration is zero", 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!.querySelectorAll("ha-toast")].map(
|
||||
(toast) => toast.labelText
|
||||
)
|
||||
).toEqual(["First message", "Identified message"]);
|
||||
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -198,32 +190,6 @@ 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" });
|
||||
@@ -236,114 +202,31 @@ describe("notification-manager", () => {
|
||||
expect(toast.labelText).toBe("Second");
|
||||
});
|
||||
|
||||
it("stacks toasts with different IDs", async () => {
|
||||
it("hides a toast before replacing it with a different ID", async () => {
|
||||
const manager = await mountManager();
|
||||
await manager.showDialog({ id: "first", message: "First" });
|
||||
await manager.showDialog({ id: "second", message: "Second" });
|
||||
|
||||
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
|
||||
expect(toasts).toHaveLength(2);
|
||||
expect([...toasts].map((toast) => toast.labelText)).toEqual([
|
||||
"First",
|
||||
"Second",
|
||||
]);
|
||||
});
|
||||
|
||||
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")!;
|
||||
let finishHide: () => void;
|
||||
vi.mocked(toast.hide).mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishHide = resolve;
|
||||
})
|
||||
);
|
||||
vi.mocked(toast.hide).mockClear();
|
||||
|
||||
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;
|
||||
await manager.showDialog({ id: "second", message: "Second" });
|
||||
|
||||
const remainingToast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
expect(remainingToast.labelText).toBe("Second");
|
||||
expect(toast.hide).toHaveBeenCalledOnce();
|
||||
expect(toast.labelText).toBe("Second");
|
||||
});
|
||||
|
||||
it("keeps a frontend update visible throughout server startup", async () => {
|
||||
it("ignores a stale replacement after a newer notification starts", 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,
|
||||
});
|
||||
await manager.showDialog({ id: "first", message: "First" });
|
||||
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
|
||||
const delayedHide = deferred();
|
||||
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
|
||||
|
||||
expect(
|
||||
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
|
||||
(toast) => toast.labelText
|
||||
)
|
||||
).toEqual(["Frontend update available", "Starting recorder"]);
|
||||
const staleShow = manager.showDialog({ id: "second", message: "Second" });
|
||||
await manager.showDialog({ id: "third", message: "Third" });
|
||||
delayedHide.resolve();
|
||||
await staleShow;
|
||||
|
||||
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"]);
|
||||
expect(toast.labelText).toBe("Third");
|
||||
});
|
||||
|
||||
it("clears rendered state after the toast closes", async () => {
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import { describe, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import "../../../../src/panels/config/automation/manual-automation-editor";
|
||||
import type { HaManualAutomationEditor } from "../../../../src/panels/config/automation/manual-automation-editor";
|
||||
import { createMockHass } from "../../../fixtures/hass";
|
||||
import { showPasteReplaceDialog } from "../../../../src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace";
|
||||
|
||||
const pasteCases = [
|
||||
{
|
||||
name: "single action",
|
||||
paste: `
|
||||
action: light.turn_on
|
||||
target:
|
||||
entity_id: light.kitchen
|
||||
`,
|
||||
expected: {
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.kitchen" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single trigger",
|
||||
paste: `
|
||||
trigger: state
|
||||
entity_id: binary_sensor.front_door
|
||||
`,
|
||||
expected: {
|
||||
triggers: [
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.front_door",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full automation config with variables ",
|
||||
paste: `
|
||||
variables:
|
||||
foo: "bar"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.front_door
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.hallway
|
||||
`,
|
||||
expected: {
|
||||
variables: {
|
||||
foo: "bar",
|
||||
},
|
||||
triggers: [
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.front_door",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.hallway" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full automation config (array) with variables ",
|
||||
paste: `
|
||||
- triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.front_door
|
||||
variables:
|
||||
foo: "bar"
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.hallway
|
||||
`,
|
||||
expected: {
|
||||
variables: {
|
||||
foo: "bar",
|
||||
},
|
||||
triggers: [
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.front_door",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.hallway" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "legacy config with variables ",
|
||||
paste: `
|
||||
- trigger:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.front_door
|
||||
variables:
|
||||
foo: "bar"
|
||||
`,
|
||||
expected: {
|
||||
variables: {
|
||||
foo: "bar",
|
||||
},
|
||||
triggers: [
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.front_door",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sequence",
|
||||
paste: `
|
||||
actions:
|
||||
- sequence:
|
||||
- stop: ''
|
||||
`,
|
||||
expected: {
|
||||
actions: [{ sequence: [{ stop: "" }] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "many variables",
|
||||
paste: `
|
||||
variables: {}
|
||||
actions:
|
||||
- variables: {}
|
||||
`,
|
||||
expected: {
|
||||
variables: {},
|
||||
actions: [{ variables: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full automation config [append]",
|
||||
config: {
|
||||
variables: { x: 1 },
|
||||
trigger_variables: { y: 2 },
|
||||
triggers: [{ trigger: "time", at: "1:00:00" }],
|
||||
conditions: [
|
||||
{
|
||||
condition: "window.is_open",
|
||||
target: { entity_id: "binary_sensor.window" },
|
||||
},
|
||||
],
|
||||
actions: [{ stop: "" }],
|
||||
},
|
||||
paste: `
|
||||
variables:
|
||||
foo: "bar"
|
||||
trigger_variables:
|
||||
z: 3
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.front_door
|
||||
conditions:
|
||||
- "{{ true }}"
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.hallway
|
||||
`,
|
||||
response: "append",
|
||||
expected: {
|
||||
variables: {
|
||||
foo: "bar",
|
||||
x: 1,
|
||||
},
|
||||
trigger_variables: {
|
||||
y: 2,
|
||||
z: 3,
|
||||
},
|
||||
triggers: [
|
||||
{ trigger: "time", at: "1:00:00" },
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.front_door",
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
condition: "window.is_open",
|
||||
target: { entity_id: "binary_sensor.window" },
|
||||
},
|
||||
"{{ true }}",
|
||||
],
|
||||
actions: [
|
||||
{ stop: "" },
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.hallway" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full automation config [replace]",
|
||||
config: {
|
||||
variables: { x: 1 },
|
||||
trigger_variables: { y: 2 },
|
||||
triggers: [{ trigger: "time", at: "1:00:00" }],
|
||||
conditions: [
|
||||
{
|
||||
condition: "window.is_open",
|
||||
target: { entity_id: "binary_sensor.window" },
|
||||
},
|
||||
],
|
||||
actions: [{ stop: "" }],
|
||||
},
|
||||
paste: `
|
||||
variables:
|
||||
foo: "bar"
|
||||
trigger_variables:
|
||||
z: 3
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.front_door
|
||||
conditions:
|
||||
- "{{ true }}"
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.hallway
|
||||
`,
|
||||
response: "replace",
|
||||
expected: {
|
||||
variables: {
|
||||
foo: "bar",
|
||||
},
|
||||
trigger_variables: {
|
||||
z: 3,
|
||||
},
|
||||
triggers: [
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "binary_sensor.front_door",
|
||||
},
|
||||
],
|
||||
conditions: ["{{ true }}"],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.hallway" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock(
|
||||
"../../../../src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace",
|
||||
() => ({
|
||||
showPasteReplaceDialog: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const makePasteEvent = (text: string): ClipboardEvent => {
|
||||
const event = new Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}) as ClipboardEvent;
|
||||
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
getData: (type: string) => (type === "text" ? text : ""),
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(event, "composedPath", {
|
||||
value: () => [document.body],
|
||||
});
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
describe("manual automation paste", () => {
|
||||
let dialogResponse: string | undefined;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dialogResponse = undefined;
|
||||
vi.mocked(showPasteReplaceDialog).mockImplementation((_ctx, options) => {
|
||||
if (dialogResponse === "append") {
|
||||
options.onAppend();
|
||||
return;
|
||||
}
|
||||
if (dialogResponse === "replace") {
|
||||
options.onReplace();
|
||||
return;
|
||||
}
|
||||
throw new Error("Did not expect dialog to be raised");
|
||||
});
|
||||
});
|
||||
|
||||
test.each(pasteCases)(
|
||||
"pastes $name into an empty editor",
|
||||
async ({ config, paste, expected, response }) => {
|
||||
const el = document.createElement(
|
||||
"manual-automation-editor"
|
||||
) as HaManualAutomationEditor;
|
||||
dialogResponse = response;
|
||||
el.hass = createMockHass();
|
||||
el.config =
|
||||
config ??
|
||||
({
|
||||
triggers: [],
|
||||
conditions: [],
|
||||
actions: [],
|
||||
} as any);
|
||||
|
||||
const valueChanged = new Promise<CustomEvent>((resolve) => {
|
||||
el.addEventListener("value-changed", resolve as EventListener, {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Call the protected method through `any` to avoid full DOM lifecycle.
|
||||
await (el as any).handlePaste(makePasteEvent(paste));
|
||||
|
||||
expect(showPasteReplaceDialog).toHaveBeenCalledTimes(response ? 1 : 0);
|
||||
|
||||
const ev = await valueChanged;
|
||||
expect(ev.detail.value).toEqual(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import { describe, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
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 = [
|
||||
{
|
||||
name: "empty sequence",
|
||||
paste: `
|
||||
sequence: []
|
||||
`,
|
||||
expected: {
|
||||
sequence: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sequence action",
|
||||
paste: `
|
||||
sequence: []
|
||||
metadata: {}
|
||||
`,
|
||||
expected: {
|
||||
sequence: [{ sequence: [] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sequence with top variables",
|
||||
paste: `
|
||||
sequence: []
|
||||
variables: {}
|
||||
`,
|
||||
expected: {
|
||||
sequence: [],
|
||||
variables: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed sequence and variables",
|
||||
paste: `
|
||||
- variables:
|
||||
a: b
|
||||
sequence:
|
||||
- variables:
|
||||
c: d
|
||||
- sequence:
|
||||
- stop: ""
|
||||
`,
|
||||
expected: {
|
||||
sequence: [{ variables: { c: "d" } }, { sequence: [{ stop: "" }] }],
|
||||
variables: { a: "b" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "append test",
|
||||
config: {
|
||||
variables: { a: 1 },
|
||||
fields: { x: {} },
|
||||
sequence: [{ stop: "1" }],
|
||||
},
|
||||
response: "append",
|
||||
paste: `
|
||||
- variables:
|
||||
b: 2
|
||||
fields:
|
||||
"y": {}
|
||||
sequence:
|
||||
- stop: "2"
|
||||
`,
|
||||
expected: {
|
||||
sequence: [{ stop: "1" }, { stop: "2" }],
|
||||
fields: { x: {}, y: {} },
|
||||
variables: { a: 1, b: 2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replace test",
|
||||
config: {
|
||||
variables: { a: 1 },
|
||||
fields: { x: {} },
|
||||
sequence: [{ stop: "1" }],
|
||||
},
|
||||
response: "replace",
|
||||
paste: `
|
||||
- variables:
|
||||
b: 2
|
||||
fields:
|
||||
"y": {}
|
||||
sequence:
|
||||
- stop: "2"
|
||||
`,
|
||||
expected: {
|
||||
sequence: [{ stop: "2" }],
|
||||
fields: { y: {} },
|
||||
variables: { b: 2 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock(
|
||||
"../../../../src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace",
|
||||
() => ({
|
||||
showPasteReplaceDialog: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const makePasteEvent = (text: string): ClipboardEvent => {
|
||||
const event = new Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}) as ClipboardEvent;
|
||||
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
getData: (type: string) => (type === "text" ? text : ""),
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(event, "composedPath", {
|
||||
value: () => [document.body],
|
||||
});
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
describe("manual automation paste", () => {
|
||||
let dialogResponse: string | undefined;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dialogResponse = undefined;
|
||||
vi.mocked(showPasteReplaceDialog).mockImplementation((_ctx, options) => {
|
||||
if (dialogResponse === "append") {
|
||||
options.onAppend();
|
||||
return;
|
||||
}
|
||||
if (dialogResponse === "replace") {
|
||||
options.onReplace();
|
||||
return;
|
||||
}
|
||||
throw new Error("Did not expect dialog to be raised");
|
||||
});
|
||||
});
|
||||
|
||||
test.each(pasteCases)(
|
||||
"pastes $name into an empty editor",
|
||||
async ({ config, paste, response, expected }) => {
|
||||
const el = document.createElement(
|
||||
"manual-script-editor"
|
||||
) as HaManualScriptEditor;
|
||||
|
||||
dialogResponse = response;
|
||||
el.hass = createMockHass();
|
||||
el.config =
|
||||
config ??
|
||||
({
|
||||
sequence: [],
|
||||
} as any);
|
||||
|
||||
const valueChanged = new Promise<CustomEvent>((resolve) => {
|
||||
el.addEventListener("value-changed", resolve as EventListener, {
|
||||
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));
|
||||
|
||||
expect(showPasteReplaceDialog).toHaveBeenCalledTimes(response ? 1 : 0);
|
||||
|
||||
const ev = await valueChanged;
|
||||
expect(ev.detail.value).toEqual(expected);
|
||||
expect(notification.mock.calls[0][0].detail.id).toBe(
|
||||
PASTED_CONFIG_TOAST_ID
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -5616,12 +5616,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/leaflet@npm:1.9.22, @types/leaflet@npm:^1.9":
|
||||
version: 1.9.22
|
||||
resolution: "@types/leaflet@npm:1.9.22"
|
||||
"@types/leaflet@npm:1.9.21, @types/leaflet@npm:^1.9":
|
||||
version: 1.9.21
|
||||
resolution: "@types/leaflet@npm:1.9.21"
|
||||
dependencies:
|
||||
"@types/geojson": "npm:*"
|
||||
checksum: 10/fdfe2f4a1adb2cd5e594320613d7673e8058928abb7caf0a79a6a9e58bc743cbefb2d1fa6d45e0a054d298a9f57632a04dd4e8c2c8e7ddfaa3c376d8f72b90a6
|
||||
checksum: 10/a02eff00db3cbc374c67fa7c0df6c564918482fa00407146bfea19f92600a4009f39af7bb9e3face39845979c133bb67b5bd972472720beef1f285596f47a6d1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9133,9 +9133,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"fast-uri@npm:^3.0.1":
|
||||
version: 3.1.5
|
||||
resolution: "fast-uri@npm:3.1.5"
|
||||
checksum: 10/784bef687ca3ecfb4587a05104a4d41101796f0fec72be47a9abed743b10109e69a24d1ad0854ee7d2705912dc2ca9d93e03d35b65df0a3da0339e05b5d83b5c
|
||||
version: 3.1.4
|
||||
resolution: "fast-uri@npm:3.1.4"
|
||||
checksum: 10/1c1ff8e7b6f7b38e997b1528aa2c97e290e0173d51250bfe4e11a303e454fc297a287555b5cb2ded59dbfce7876855fb15994a1a6b439f2497a2b8926513e397
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9952,7 +9952,7 @@ __metadata:
|
||||
"@types/color-name": "npm:2.0.0"
|
||||
"@types/culori": "npm:4.0.1"
|
||||
"@types/html-minifier-terser": "npm:7.0.2"
|
||||
"@types/leaflet": "npm:1.9.22"
|
||||
"@types/leaflet": "npm:1.9.21"
|
||||
"@types/leaflet-draw": "npm:1.0.13"
|
||||
"@types/leaflet.markercluster": "npm:1.5.6"
|
||||
"@types/lodash.merge": "npm:4.6.9"
|
||||
@@ -10008,7 +10008,7 @@ __metadata:
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.13"
|
||||
js-yaml: "npm:5.2.3"
|
||||
js-yaml: "npm:5.2.2"
|
||||
jsdom: "npm:30.0.1"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
@@ -10942,14 +10942,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:5.2.3":
|
||||
version: 5.2.3
|
||||
resolution: "js-yaml@npm:5.2.3"
|
||||
"js-yaml@npm:5.2.2":
|
||||
version: 5.2.2
|
||||
resolution: "js-yaml@npm:5.2.2"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.mjs
|
||||
checksum: 10/5d2562f2d7e7bc51bd678b43d2dbc3965129ac854222c794157369c8904d249cfc78325dff8fc64becb505a2506242548c54c8fa50cdb24554bb1a557e460004
|
||||
checksum: 10/2b4c2933af12c97e1c4894a4f27fe9b06dab70a64a96bb50624b4429bef6bf11008bde20d868bce52a36784473314efc30078ba6025b58cf7537961e23b1ae9c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15050,9 +15050,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^6.25.0":
|
||||
version: 6.28.0
|
||||
resolution: "undici@npm:6.28.0"
|
||||
checksum: 10/672a7a53bd1f6c80edb73b357ab36e98ef9e474024c442aab2b8ea2bc32f1103cab05525c78661ae724f3e1340e23d03efb9730fba28e76218ab0ec52e15d75a
|
||||
version: 6.27.0
|
||||
resolution: "undici@npm:6.27.0"
|
||||
checksum: 10/30c18cdb235edf4dd36f8aa3ace1ffaf44060289a7d62ad44c33180d2d74a224015d25574812f62ce9c625b5beb1b0b766495b650fedf356aca11eed7ce2c816
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user