Compare commits

..

1 Commits

Author SHA1 Message Date
Petar Petrov e4788748cd Add expand_legend option to energy graph cards 2026-08-04 08:53:21 +03:00
25 changed files with 173 additions and 1271 deletions
+1 -28
View File
@@ -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
View File
@@ -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",
+3 -27
View File
@@ -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;
}
-45
View File
@@ -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);
-13
View File
@@ -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);
}
});
+82 -216
View File
@@ -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)
+4
View File
@@ -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()),
})
);
+1 -9
View File
@@ -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),
+1
View File
@@ -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];
}
}
};
-13
View File
@@ -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) => {
-143
View File
@@ -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!);
}
});
});
+27 -144
View File
@@ -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
);
}
);
});
+16 -16
View File
@@ -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