Compare commits

...

16 Commits

Author SHA1 Message Date
Petar Petrov 1b6e150def Remove dead UV_THREADPOOL_SIZE assignment from gulpfile (#53490)
gulpfile.js set process.env.UV_THREADPOOL_SIZE, but gulp's CLI loads the
gulpfile through liftoff, which does async fs work first. libuv has already
sized its threadpool to the default 4 by then, so the assignment never took
effect. Measured through the gulp CLI, effective threadpool size is 3.4 both
with and without the line, and only reaches 6.9 when the variable comes from
the environment.

Hoisting it earlier cannot fix it either, since the pool is sized before any
line of gulpfile.js runs, and setting it for real measurably changes nothing.
Remove it rather than plumb the variable through the build.
2026-08-04 15:52:42 +02:00
Petar Petrov 939b7012e2 Fix wrong month in date picker calendar header (#53453) 2026-08-04 15:37:00 +02:00
Paulus Schoutsen 488d3d9b37 Revert "Show the automation state on mobile in the automations table" (#53493) 2026-08-04 15:25:41 +02:00
Petar Petrov 08dedc415f Compress faster by running zopfli in a worker pool (#53488)
Compress with zopfli in a worker pool

compress-app gzips every build artifact with zopfli, but @gfx/zopfli is
synchronous WASM: it ran on the main thread, pinned a single core and blocked
the event loop for the whole step, so it dominated the production build.

Replace gulp-zopfli-green with an equivalent gulp transform that runs the same
compressor in a pool of worker_threads sized to availableParallelism(). The
compressed output is byte-identical, and @gfx/zopfli is no longer loaded on the
main thread of every gulp invocation.

On a 12-core machine compress-app drops from 6.98 min to 1.27 min, and the full
production build from 8.87 min to 3.32 min.
2026-08-04 15:11:45 +02:00
karwosts 400bf78ce0 Color icons for "on" automations in automation-picker (#53360)
colorize active icons in automation-picker
2026-08-04 15:46:14 +03:00
Przemysław Szypowicz 9b2093125e Fix unreachable remove button on input chips with long labels (#53487)
Truncate long input chip labels

A chip with a long label, like an SSH public key in an add-on config
list option, grew past its container so the trailing remove button
ended up outside the visible area and could not be clicked. Cap the
chip at the container width so the built-in label ellipsis applies,
and show the full value as a native tooltip in the select selector.

Co-authored-by: Przemysław Szypowicz <2733699+pszypowicz@users.noreply.github.com>
2026-08-04 15:42:35 +03:00
renovate[bot] c0d963747a Update dependency @types/luxon to v3.7.3 (#53484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 13:14:02 +03:00
Paul Bottein 1bcd43fb75 Add form divider between actions in interaction editor (#53483) 2026-08-04 11:55:07 +02:00
Aidan Timson ce5925388c Add frontend review guidance (#53482)
* Add frontend review guidance

* Simplify frontend review skill routing

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-04 09:00:22 +00:00
jeffdunn 088f72fcc4 Fix map loading fully zoomed out when fitted before layout (#53428)
Leaflet computes the zoom level that fits a set of bounds from the current
size of the map container. When ha-map's fit runs before the browser has
laid out the container (a race that hits when the Leaflet chunks are
already cached), the container measures 0x0, the computed zoom collapses
to the minimum, and the map shows the entire world. The later
invalidateSize() from the resize observer fixes the size but keeps the
zoom, so the map stays on the world view.

Defer fitting while the container has no size and run the pending fit
once the resize observer reports a usable size.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:52:29 +00:00
renovate[bot] b93f09ffb2 Update dependency js-yaml to v5.2.3 (#53478)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 09:46:52 +03:00
Matthias Alphart fb499093e5 Drop websocket collections created by an iframe panel on teardown (#53448)
* Drop websocket collections created by an iframe panel on teardown

`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 user leaves the panel, `ha-panel-custom` removes the iframe and that
realm is destroyed - but the collection stays cached on the connection. Its
store, and the `setTimeout` that hands the cached state to new subscribers, are
gone, so every later subscriber waits forever for a callback that can never
fire. Since the registries moved to `LazyContextProvider` they are subscribed
on demand, which makes an iframe panel the first requester far more often: the
KNX panel's entity table renders empty after leaving and re-entering it, and
the main frontend can inherit the same dead cache.

Clean up from the iframe's own `pagehide` handler, while the realm is still
alive - `disconnectedCallback` on `ha-panel-custom` runs after the browsing
context is discarded, when `contentWindow` is already `null`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use unknown instead of any in the CachedCollection shape

The helper never inspects the subscribed state, so the parameter type only
needs to be a placeholder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:31:42 +03:00
karwosts c97716d4ad Fix automation paste with variables (#53441)
* Fix automation paste with variables

* fix script

* fix stuff

* fix append

* fix script
2026-08-04 09:17:05 +03:00
dependabot[bot] b36361ca34 Bump fast-uri from 3.1.4 to 3.1.5 (#53474)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 09:03:25 +03:00
dependabot[bot] 007a7916c0 Bump undici from 6.27.0 to 6.28.0 (#53475)
Bumps [undici](https://github.com/nodejs/undici) from 6.27.0 to 6.28.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.27.0...v6.28.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.28.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 09:02:51 +03:00
renovate[bot] 637f6955a8 Update dependency @types/leaflet to v1.9.22 (#53473)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 09:02:29 +03:00
30 changed files with 1398 additions and 177 deletions
+28 -1
View File
@@ -31,6 +31,33 @@ 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.
@@ -70,4 +97,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.
- 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.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
+1 -1
View File
@@ -3,8 +3,8 @@
import { constants } from "node:zlib";
import gulp from "gulp";
import brotli from "gulp-brotli";
import zopfli from "gulp-zopfli-green";
import paths from "../paths.cjs";
import zopfli from "../zopfli.mjs";
const filesGlob = "*.{js,json,css,svg,xml}";
const brotliOptions = {
+18
View File
@@ -0,0 +1,18 @@
// Worker side of the zopfli pool. @gfx/zopfli is a synchronous WASM build, so
// compressing on the main thread blocks the event loop; one instance per worker
// is what makes the work parallel.
import { parentPort } from "node:worker_threads";
import zopfli from "@gfx/zopfli";
parentPort.on("message", ({ contents, options }) => {
zopfli.gzip(contents, options, (error, result) => {
if (error) {
parentPort.postMessage({ error: error.message ?? String(error) });
return;
}
// `result` is a fresh Uint8Array copied out of the WASM heap, so it owns
// its ArrayBuffer and can be transferred instead of cloned.
parentPort.postMessage({ result }, [result.buffer]);
});
});
+196
View File
@@ -0,0 +1,196 @@
// Gulp transform that gzips files with zopfli across a pool of worker threads.
//
// Drop-in replacement for gulp-zopfli-green. That plugin compresses on the main
// thread, and @gfx/zopfli is synchronous WASM, so it pins a single core and
// blocks the event loop for the whole compression step. Running one WASM
// instance per worker parallelises it; the output bytes are unchanged.
import { availableParallelism } from "node:os";
import { Transform } from "node:stream";
import { buffer as readStream } from "node:stream/consumers";
import { Worker } from "node:worker_threads";
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
const EXTENSION = ".gz";
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
// what gulp-zopfli-green did, so compressed output stays byte-identical.
const ZOPFLI_OPTIONS = {};
const poolSize = () => {
const configured = Number(process.env.ZOPFLI_WORKERS);
return Number.isInteger(configured) && configured > 0
? configured
: availableParallelism();
};
// One job per worker at a time, so the worker itself is the job slot.
const createPool = (size) => {
const live = new Set();
const idle = [];
const waiting = [];
const inFlight = new Map();
// A dead worker must leave the pool, or it gets handed a job that never
// completes and the build hangs instead of failing.
const retire = (worker, error) => {
if (!live.delete(worker)) {
return;
}
const index = idle.indexOf(worker);
if (index !== -1) {
idle.splice(index, 1);
}
const job = inFlight.get(worker);
inFlight.delete(worker);
job?.reject(error);
waiting.shift()?.();
};
const spawn = () => {
const worker = new Worker(WORKER_URL);
live.add(worker);
// Idle workers must not hold the process open. Each job refs its worker for
// as long as it runs, so a pending compression always keeps the event loop
// alive.
worker.unref();
worker.on("message", ({ error, result }) => {
const job = inFlight.get(worker);
if (!job) {
return;
}
inFlight.delete(worker);
worker.unref();
idle.push(worker);
if (error) {
job.reject(new Error(error));
} else {
job.resolve(
Buffer.from(result.buffer, result.byteOffset, result.byteLength)
);
}
waiting.shift()?.();
});
worker.on("error", (error) => retire(worker, error));
worker.on("exit", () =>
retire(worker, new Error("zopfli worker exited unexpectedly"))
);
return worker;
};
return (contents) =>
new Promise((resolve, reject) => {
const start = () => {
const worker = idle.pop() ?? (live.size < size ? spawn() : undefined);
if (!worker) {
waiting.push(start);
return;
}
inFlight.set(worker, { resolve, reject });
worker.ref();
// `contents` is cloned rather than transferred: buffers read by vinyl
// can share a pooled ArrayBuffer with unrelated buffers, and
// transferring would detach those too.
worker.postMessage({ contents, options: ZOPFLI_OPTIONS });
};
start();
});
};
// Shared by every transform this module hands out, so the worker count is a
// property of the process rather than of how many streams happen to run.
let pool;
const sharedPool = () => {
if (!pool) {
const size = poolSize();
pool = { size, compress: createPool(size) };
}
return pool;
};
// through2 and node's Transform both wait for the previous callback before
// handling the next file, which would serialise the pool down to one worker.
// This keeps `limit` files in flight and applies backpressure beyond that.
class ParallelTransform extends Transform {
#limit;
#handle;
#inFlight = 0;
#resume;
#finish;
constructor(limit, handle) {
super({ objectMode: true, highWaterMark: limit });
this.#limit = limit;
this.#handle = handle;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_transform(file, _encoding, callback) {
this.#inFlight += 1;
this.#handle(file)
.then((result) => {
if (result) {
this.push(result);
}
})
.catch((error) => this.destroy(error))
.finally(() => {
this.#inFlight -= 1;
const resume = this.#resume;
this.#resume = undefined;
resume?.();
if (this.#inFlight === 0) {
const finish = this.#finish;
this.#finish = undefined;
finish?.();
}
});
if (this.#inFlight < this.#limit) {
callback();
} else {
this.#resume = callback;
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_flush(callback) {
if (this.#inFlight === 0) {
callback();
} else {
this.#finish = callback;
}
}
}
/**
* @param {object} [options]
* @param {number} [options.threshold] Skip files smaller than this many bytes.
*/
export default ({ threshold = 0 } = {}) => {
const { size, compress } = sharedPool();
return new ParallelTransform(size, async (file) => {
if (file.isNull()) {
return file;
}
if (file.isStream()) {
file.contents = await readStream(file.contents);
}
if (threshold && file.contents.length < threshold) {
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
return file;
}
file.contents = await compress(file.contents);
file.path += EXTENSION;
return file;
});
};
-3
View File
@@ -1,4 +1 @@
import { availableParallelism } from "node:os";
import "./build-scripts/gulp/index.mjs";
process.env.UV_THREADPOOL_SIZE = availableParallelism();
+4 -4
View File
@@ -103,12 +103,11 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"js-yaml": "5.2.2",
"js-yaml": "5.2.3",
"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",
@@ -145,6 +144,7 @@
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.3",
@@ -160,11 +160,11 @@
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.21",
"@types/leaflet": "1.9.22",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.2",
"@types/luxon": "3.7.3",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
+23
View File
@@ -294,3 +294,26 @@ export const formatCallyDateRange = (
return `${startDate}/${endDate}`;
};
/**
* August 2021, for calendar days coming out of cally.
*
* cally hands out calendar days as `Date` objects anchored to UTC midnight, and
* formats its own headings in UTC too. Anything derived from a cally event has
* to be formatted the same way: the resolved time zone shifts the day back for
* negative UTC offsets, which becomes a wrong month — and in January a wrong
* year — whenever the day is the 1st.
*/
export const formatCallyMonthYear = (
dateObj: Date,
locale: FrontendLocaleData
) => formatCallyMonthYearMem(locale.language).format(dateObj);
const formatCallyMonthYearMem = memoizeOne(
(language: string) =>
new Intl.DateTimeFormat(language, {
month: "long",
year: "numeric",
timeZone: "UTC",
})
);
+1
View File
@@ -15,6 +15,7 @@ export class HaInputChip extends InputChip {
styles,
css`
:host {
max-width: 100%;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--primary-text-color);
+21 -37
View File
@@ -9,8 +9,8 @@ import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
formatDateMonth,
formatDateYear,
formatCallyMonthYear,
formatDateMonthYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
@@ -60,13 +60,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
})
private _hassConfig!: HassConfig;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-range header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in calendar-range */
/**
* used for today to navigate focus in calendar-range
*
* Always mirrors the day calendar-range has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected range and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
@state() private _dateValue?: string;
@@ -92,12 +95,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -167,9 +165,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
@@ -233,12 +229,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -311,19 +302,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
});
}
private _focusChanged(ev: HASSDomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
private _focusChanged(
ev: HASSDomEvent<Date> &
HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = ev.target.focusedDate;
}
private _handleChange(
@@ -331,7 +315,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = undefined;
this._focusDate = dateElement.focusedDate;
}
private _clickDateRangeChip(
@@ -6,7 +6,8 @@ import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
formatDateMonth,
formatCallyMonthYear,
formatDateMonthYear,
formatDateShort,
formatDateYear,
formatISODateOnly,
@@ -60,13 +61,16 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
dateString: string;
};
/** used to show month in calendar-date header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-date header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in cally-calendar-date */
/**
* used for today to navigate focus in cally-calendar-date
*
* Always mirrors the day calendar-date has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected value and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
public connectedCallback() {
@@ -75,12 +79,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (this.params) {
const date = valueToDate(this.params.value);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -88,7 +87,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
this._value = this.params.value
? {
year: this._pickerYear,
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
@@ -144,9 +143,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
@@ -189,12 +186,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -202,19 +194,11 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
}
}
private _focusChanged(ev: HASSDomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
private _focusChanged(
ev: HASSDomEvent<Date> & HASSDomTargetEvent<CalendarDate>
) {
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = ev.target.focusedDate;
}
private _clear() {
+30
View File
@@ -0,0 +1,30 @@
import { css, html, LitElement } from "lit";
import type { TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import type { HaFormDividerSchema, HaFormElement } from "./types";
@customElement("ha-form-divider")
export class HaFormDivider extends LitElement implements HaFormElement {
@property({ attribute: false }) public schema!: HaFormDividerSchema;
protected render(): TemplateResult {
return html`<hr />`;
}
static styles = css`
:host {
display: block;
}
hr {
border: none;
border-top: 1px solid var(--ha-color-border-neutral-quiet);
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-form-divider": HaFormDivider;
}
}
@@ -12,9 +12,11 @@ import type { HaDropdownSelectEvent } from "../ha-dropdown";
import "../ha-dropdown-item";
import "../ha-svg-icon";
import "./ha-form";
import "./ha-form-divider";
import type { HaForm } from "./ha-form";
import type {
HaFormDataContainer,
HaFormDividerSchema,
HaFormElement,
HaFormOptionalActionsSchema,
HaFormSchema,
@@ -22,6 +24,11 @@ import type {
const NO_ACTIONS = [];
const DIVIDER = {
name: "",
type: "divider",
} as const satisfies HaFormDividerSchema;
@customElement("ha-form-optional_actions")
export class HaFormOptionalActions extends LitElement implements HaFormElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@@ -87,7 +94,9 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
schema: readonly HaFormSchema[],
displayActions: string[]
): HaFormSchema[] =>
schema.filter((item) => displayActions.includes(item.name))
schema
.filter((item) => displayActions.includes(item.name))
.flatMap((item) => [DIVIDER, item])
);
public render(): TemplateResult {
@@ -128,6 +137,7 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
${
hiddenActions.length > 0
? html`
<ha-form-divider></ha-form-divider>
<ha-dropdown
@wa-select=${this._handleAddAction}
@closed=${stopPropagation}
+1
View File
@@ -20,6 +20,7 @@ type HaFormDataContainerChangedEvent = ValueChangedEvent<HaFormDataContainer>;
const LOAD_ELEMENTS = {
boolean: () => import("./ha-form-boolean"),
constant: () => import("./ha-form-constant"),
divider: () => import("./ha-form-divider"),
float: () => import("./ha-form-float"),
grid: () => import("./ha-form-grid"),
expandable: () => import("./ha-form-expandable"),
+5
View File
@@ -4,6 +4,7 @@ import type { HaDurationData } from "../ha-duration-input";
export type HaFormSchema =
| HaFormConstantSchema
| HaFormDividerSchema
| HaFormStringSchema
| HaFormIntegerSchema
| HaFormFloatSchema
@@ -97,6 +98,10 @@ export interface HaFormConstantSchema extends HaFormBaseSchema {
value?: string;
}
export interface HaFormDividerSchema extends HaFormBaseSchema {
type: "divider";
}
export interface HaFormIntegerSchema extends HaFormBaseSchema {
type: "integer";
default?: HaFormIntegerData;
@@ -187,6 +187,7 @@ export class HaSelectSelector extends LitElement {
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
+45
View File
@@ -175,6 +175,8 @@ export class HaMap extends ReactiveElement {
private _pauseAutoFit = false;
private _pendingFit?: () => void;
public connectedCallback(): void {
this._pauseAutoFit = false;
document.addEventListener("visibilitychange", this._handleVisibilityChange);
@@ -204,6 +206,7 @@ export class HaMap extends ReactiveElement {
this.Leaflet = undefined;
}
this._pendingFit = undefined;
this._loaded = false;
if (this._resizeObserver) {
@@ -347,6 +350,10 @@ export class HaMap extends ReactiveElement {
return;
}
if (this._deferIfUnsized(() => this.fitMap(options))) {
return;
}
if (
!this._mapFocusItems.length &&
!this._mapFocusZones.length &&
@@ -387,6 +394,40 @@ 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 }
@@ -394,6 +435,9 @@ 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
);
@@ -773,6 +817,7 @@ 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,3 +1,4 @@
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";
@@ -7,6 +8,7 @@ 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";
@@ -29,8 +31,14 @@ 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;
}
@@ -150,4 +158,9 @@ 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);
}
});
@@ -317,7 +317,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
color:
automation.state === UNAVAILABLE
? "var(--error-color)"
: "unset",
: automation.state === "on"
? "var(--state-active-color)"
: "unset",
})}
></ha-state-icon>`,
},
@@ -346,19 +348,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) =>
narrow
? automation.formatted_state
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -51,6 +51,8 @@ const baseConfigStruct = object({
mode: optional(string()),
max_exceeded: optional(string()),
id: optional(string()),
variables: optional(object()),
trigger_variables: optional(object()),
});
const automationConfigStruct = union([
@@ -257,6 +259,27 @@ 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];
@@ -276,7 +299,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
found = true;
(newConfig.conditions as Condition[]).push(cfg);
}
if (getActionType(cfg) !== "unknown") {
if (isQualifiedAction(cfg)) {
found = true;
(newConfig.actions as Action[]).push(cfg);
}
@@ -293,7 +316,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
if (isCondition(config)) {
config = { conditions: [config] };
}
if (getActionType(config) !== "unknown") {
if (isQualifiedAction(config)) {
config = { actions: [config] };
}
@@ -375,6 +398,11 @@ 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(
@@ -47,6 +47,7 @@ const scriptConfigStruct = object({
mode: optional(enums([typeof MODES])),
max: optional(number()),
fields: optional(object()),
variables: optional(object()),
});
@customElement("manual-script-editor")
@@ -232,8 +233,9 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
const actionType = getActionType(config);
if (
!["sequence", "unknown"].includes(actionType) ||
(actionType === "sequence" && "metadata" in config)
!["sequence", "variables", "unknown"].includes(actionType) ||
(actionType === "sequence" && "metadata" in config) ||
(actionType === "variables" && !("sequence" in config))
) {
config = { sequence: [config] };
}
@@ -312,12 +314,14 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
return;
}
if ("fields" in config) {
workingCopy.fields = {
...workingCopy.fields,
...config.fields,
};
}
["fields", "variables"].forEach((key) => {
if (key in config) {
workingCopy[key] = {
...workingCopy[key],
...config[key],
};
}
});
if ("sequence" in config) {
workingCopy.sequence = ensureArray(workingCopy.sequence || []).concat(
ensureArray(config.sequence)
@@ -135,6 +135,10 @@ export class HuiShortcutBadgeEditor
},
},
},
{
name: "",
type: "divider",
},
{
name: "double_tap_action",
selector: {
@@ -168,6 +168,10 @@ export class HuiShortcutCardEditor
},
},
},
{
name: "",
type: "divider",
},
{
name: "double_tap_action",
selector: {
@@ -199,6 +199,10 @@ export class HuiTileCardEditor
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "divider",
},
{
name: "icon_tap_action",
selector: {
@@ -0,0 +1,40 @@
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];
}
}
};
+152
View File
@@ -0,0 +1,152 @@
/**
* @vitest-environment node
*/
import { Buffer } from "node:buffer";
import process from "node:process";
import { Readable } from "node:stream";
import gfxZopfli from "@gfx/zopfli";
import { describe, expect, it } from "vitest";
// Keep the pool small; it is created once, on the first factory call.
process.env.ZOPFLI_WORKERS = "2";
const { default: zopfli } = await import("../../build-scripts/zopfli.mjs");
const THRESHOLD = 150;
// Stand-in for the vinyl files gulp.src yields in buffer mode.
const file = (path, contents) => ({
path,
contents,
isNull() {
return this.contents === null;
},
isStream() {
return this.contents instanceof Readable;
},
});
const run = (files, options = { threshold: THRESHOLD }) =>
new Promise((resolve, reject) => {
const stream = zopfli(options);
const out = [];
stream.on("data", (result) => out.push(result));
stream.on("error", reject);
stream.on("end", () => resolve(out));
files.forEach((entry) => stream.write(entry));
stream.end();
});
// What the old in-process plugin did, for byte-for-byte comparison.
const gzipInProcess = (contents) =>
new Promise((resolve, reject) => {
gfxZopfli.gzip(contents, {}, (error, result) =>
error ? reject(error) : resolve(Buffer.from(result))
);
});
const filler = (length) => Buffer.alloc(length, "a");
describe("zopfli worker pool", () => {
it("appends .gz and matches in-process zopfli byte for byte", async () => {
const contents = filler(4096);
const [result] = await run([file("/out/app.js", contents)]);
expect(result.path).toBe("/out/app.js.gz");
expect(result.contents).toEqual(await gzipInProcess(contents));
});
it("returns contents as a Buffer, which vinyl requires", async () => {
const [result] = await run([file("/out/app.js", filler(1024))]);
expect(Buffer.isBuffer(result.contents)).toBe(true);
});
it("appends to the path rather than replacing the extension", async () => {
const [result] = await run([file("/out/nested/chunk.min.js", filler(500))]);
expect(result.path).toBe("/out/nested/chunk.min.js.gz");
});
it("passes files under the threshold through untouched", async () => {
const contents = filler(THRESHOLD - 1);
const [result] = await run([file("/out/tiny.js", contents)]);
expect(result.path).toBe("/out/tiny.js");
expect(result.contents).toBe(contents);
});
it("compresses a file exactly at the threshold", async () => {
const [result] = await run([file("/out/edge.js", filler(THRESHOLD))]);
expect(result.path).toBe("/out/edge.js.gz");
});
it("compresses everything when no threshold is given", async () => {
const [result] = await run([file("/out/tiny.js", filler(1))], {});
expect(result.path).toBe("/out/tiny.js.gz");
});
it("passes null files through", async () => {
const [result] = await run([file("/out/adirectory", null)]);
expect(result.path).toBe("/out/adirectory");
expect(result.contents).toBeNull();
});
it("compresses only the requested slice of a pooled buffer", async () => {
// Small reads share a pooled ArrayBuffer, so contents often start at a
// non-zero byteOffset into a much larger allocation.
const pool = Buffer.alloc(8192, "z");
const contents = pool.subarray(1000, 1000 + 512);
contents.fill("a");
const [result] = await run([file("/out/pooled.js", contents)]);
expect(result.contents).toEqual(await gzipInProcess(filler(512)));
});
it("buffers stream-mode contents", async () => {
const contents = filler(600);
const [result] = await run([
file("/out/streamed.js", Readable.from([contents])),
]);
expect(result.path).toBe("/out/streamed.js.gz");
expect(result.contents).toEqual(await gzipInProcess(contents));
});
it("handles more files than the pool has workers", async () => {
const count = 25;
const files = Array.from({ length: count }, (_, index) =>
file(`/out/chunk${index}.js`, filler(200 + index))
);
const results = await run(files);
expect(results).toHaveLength(count);
expect(new Set(results.map((result) => result.path)).size).toBe(count);
await Promise.all(
results.map(async (result) => {
const index = Number(result.path.match(/chunk(\d+)/)[1]);
expect(result.contents).toEqual(
await gzipInProcess(filler(200 + index))
);
})
);
});
it("serves two concurrent streams from the same pool", async () => {
const [first, second] = await Promise.all([
run([file("/out/a.js", filler(300))]),
run([file("/out/b.js", filler(400))]),
]);
expect(first[0].path).toBe("/out/a.js.gz");
expect(second[0].path).toBe("/out/b.js.gz");
expect(first[0].contents).toEqual(await gzipInProcess(filler(300)));
expect(second[0].contents).toEqual(await gzipInProcess(filler(400)));
});
});
+40
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
formatCallyMonthYear,
formatDate,
formatDateWeekdayDay,
formatDateShort,
@@ -263,4 +264,43 @@ describe("formatDate", () => {
).toBe("Sat");
});
});
describe("formatCallyMonthYear", () => {
// How cally hands out a calendar day: a Date anchored to UTC midnight
const julyFirst = new Date(Date.UTC(2026, 6, 1));
const januaryFirst = new Date(Date.UTC(2026, 0, 1));
// demoConfig.time_zone is America/Los_Angeles, so this resolves to a
// negative UTC offset whatever zone the tests run in
const locale = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
it("Formats in UTC rather than the resolved time zone", () => {
// A time zone aware formatter reads UTC midnight on the 1st as the
// previous month, which desyncs the header from the calendar grid
expect(formatDateMonthYear(julyFirst, locale, demoConfig)).toBe(
"June 2026"
);
expect(formatCallyMonthYear(julyFirst, locale)).toBe("July 2026");
});
it("Keeps the year on a January page", () => {
expect(formatDateMonthYear(januaryFirst, locale, demoConfig)).toBe(
"December 2025"
);
expect(formatCallyMonthYear(januaryFirst, locale)).toBe("January 2026");
});
it("Orders month and year by locale", () => {
expect(
formatCallyMonthYear(julyFirst, { ...locale, language: "ja" })
).toBe("2026年7月");
});
});
});
+143
View File
@@ -0,0 +1,143 @@
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!);
}
});
});
@@ -0,0 +1,338 @@
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);
}
);
});
@@ -0,0 +1,176 @@
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";
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,
});
});
// 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);
}
);
});
+25 -72
View File
@@ -2945,7 +2945,7 @@ __metadata:
languageName: node
linkType: hard
"@gfx/zopfli@npm:^1.0.15":
"@gfx/zopfli@npm:1.0.15":
version: 1.0.15
resolution: "@gfx/zopfli@npm:1.0.15"
dependencies:
@@ -5616,12 +5616,12 @@ __metadata:
languageName: node
linkType: hard
"@types/leaflet@npm:1.9.21, @types/leaflet@npm:^1.9":
version: 1.9.21
resolution: "@types/leaflet@npm:1.9.21"
"@types/leaflet@npm:1.9.22, @types/leaflet@npm:^1.9":
version: 1.9.22
resolution: "@types/leaflet@npm:1.9.22"
dependencies:
"@types/geojson": "npm:*"
checksum: 10/a02eff00db3cbc374c67fa7c0df6c564918482fa00407146bfea19f92600a4009f39af7bb9e3face39845979c133bb67b5bd972472720beef1f285596f47a6d1
checksum: 10/fdfe2f4a1adb2cd5e594320613d7673e8058928abb7caf0a79a6a9e58bc743cbefb2d1fa6d45e0a054d298a9f57632a04dd4e8c2c8e7ddfaa3c376d8f72b90a6
languageName: node
linkType: hard
@@ -5641,10 +5641,10 @@ __metadata:
languageName: node
linkType: hard
"@types/luxon@npm:3.7.2":
version: 3.7.2
resolution: "@types/luxon@npm:3.7.2"
checksum: 10/6634ebaceeec84d39cbb456d4db358481bc2ba7d05df47890a4f2d235100c29a52f621e1401f016747093093fa86c117bef01446199e0b1af3bcdcee47a57b1f
"@types/luxon@npm:3.7.3":
version: 3.7.3
resolution: "@types/luxon@npm:3.7.3"
checksum: 10/f097bd3f7c47ae766928e0fa496ec13fe4d72c42b29afa4bed457775ce4fb4d8574ccccc0c974bf8915d1e6a7e226fa6efb989905786c4c9c1714754f74e8a17
languageName: node
linkType: hard
@@ -6744,13 +6744,6 @@ __metadata:
languageName: node
linkType: hard
"any-promise@npm:^1.1.0":
version: 1.3.0
resolution: "any-promise@npm:1.3.0"
checksum: 10/6737469ba353b5becf29e4dc3680736b9caa06d300bda6548812a8fee63ae7d336d756f88572fa6b5219aed36698d808fa55f62af3e7e6845c7a1dc77d240edb
languageName: node
linkType: hard
"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2":
version: 3.1.3
resolution: "anymatch@npm:3.1.3"
@@ -7328,7 +7321,7 @@ __metadata:
languageName: node
linkType: hard
"bytes@npm:3.1.2, bytes@npm:^3.1.2":
"bytes@npm:3.1.2":
version: 3.1.2
resolution: "bytes@npm:3.1.2"
checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388
@@ -9133,9 +9126,9 @@ __metadata:
linkType: hard
"fast-uri@npm:^3.0.1":
version: 3.1.4
resolution: "fast-uri@npm:3.1.4"
checksum: 10/1c1ff8e7b6f7b38e997b1528aa2c97e290e0173d51250bfe4e11a303e454fc297a287555b5cb2ded59dbfce7876855fb15994a1a6b439f2497a2b8926513e397
version: 3.1.5
resolution: "fast-uri@npm:3.1.5"
checksum: 10/784bef687ca3ecfb4587a05104a4d41101796f0fec72be47a9abed743b10109e69a24d1ad0854ee7d2705912dc2ca9d93e03d35b65df0a3da0339e05b5d83b5c
languageName: node
linkType: hard
@@ -9773,19 +9766,6 @@ __metadata:
languageName: node
linkType: hard
"gulp-zopfli-green@npm:7.0.0":
version: 7.0.0
resolution: "gulp-zopfli-green@npm:7.0.0"
dependencies:
"@gfx/zopfli": "npm:^1.0.15"
bytes: "npm:^3.1.2"
plugin-error: "npm:^2.0.1"
stream-to-array: "npm:^2.3.0"
through2: "npm:^4.0.2"
checksum: 10/25db42b9a8f6395847e3cc06c548c3f3d51c84606166f18de71d91d505e7293176b45b4b78b7e0b1566c26263fe0dc212e56d2b367461c3d712bee9d22d5224f
languageName: node
linkType: hard
"gulp@npm:5.0.1":
version: 5.0.1
resolution: "gulp@npm:5.0.1"
@@ -9919,6 +9899,7 @@ __metadata:
"@fullcalendar/list": "npm:6.1.21"
"@fullcalendar/luxon3": "npm:6.1.21"
"@fullcalendar/timegrid": "npm:6.1.21"
"@gfx/zopfli": "npm:1.0.15"
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
"@html-eslint/eslint-plugin": "npm:0.64.0"
"@lezer/highlight": "npm:1.2.3"
@@ -9952,11 +9933,11 @@ __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.21"
"@types/leaflet": "npm:1.9.22"
"@types/leaflet-draw": "npm:1.0.13"
"@types/leaflet.markercluster": "npm:1.5.6"
"@types/lodash.merge": "npm:4.6.9"
"@types/luxon": "npm:3.7.2"
"@types/luxon": "npm:3.7.3"
"@types/qrcode": "npm:1.5.6"
"@types/sortablejs": "npm:1.15.9"
"@types/tar": "npm:7.0.87"
@@ -10001,14 +9982,13 @@ __metadata:
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.1.0"
gulp-zopfli-green: "npm:7.0.0"
hls.js: "npm:1.6.16"
home-assistant-js-websocket: "npm:9.6.0"
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.13"
js-yaml: "npm:5.2.2"
js-yaml: "npm:5.2.3"
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
@@ -10942,14 +10922,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.2.2":
version: 5.2.2
resolution: "js-yaml@npm:5.2.2"
"js-yaml@npm:5.2.3":
version: 5.2.3
resolution: "js-yaml@npm:5.2.3"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/2b4c2933af12c97e1c4894a4f27fe9b06dab70a64a96bb50624b4429bef6bf11008bde20d868bce52a36784473314efc30078ba6025b58cf7537961e23b1ae9c
checksum: 10/5d2562f2d7e7bc51bd678b43d2dbc3965129ac854222c794157369c8904d249cfc78325dff8fc64becb505a2506242548c54c8fa50cdb24554bb1a557e460004
languageName: node
linkType: hard
@@ -12784,15 +12764,6 @@ __metadata:
languageName: node
linkType: hard
"plugin-error@npm:^2.0.1":
version: 2.0.1
resolution: "plugin-error@npm:2.0.1"
dependencies:
ansi-colors: "npm:^1.0.1"
checksum: 10/9a4f91461cd24cce401112098969991d7aa6b4c94f78e0381234280c07da779570a8b21ab143292b534ec0117c09705a67e5d756c1c303d4706fdd7f861bf5bc
languageName: node
linkType: hard
"pngjs@npm:^3.0.0":
version: 3.4.0
resolution: "pngjs@npm:3.4.0"
@@ -13006,7 +12977,7 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:2 || 3, readable-stream@npm:3, readable-stream@npm:^3.4.0":
"readable-stream@npm:2 || 3, readable-stream@npm:^3.4.0":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
@@ -14194,15 +14165,6 @@ __metadata:
languageName: node
linkType: hard
"stream-to-array@npm:^2.3.0":
version: 2.3.0
resolution: "stream-to-array@npm:2.3.0"
dependencies:
any-promise: "npm:^1.1.0"
checksum: 10/7feaf63b38399b850615e6ffcaa951e96e4c8f46745dbce4b553a94c5dc43966933813747014935a3ff97793e7f30a65270bde19f82b2932871a1879229a77cf
languageName: node
linkType: hard
"streamx@npm:^2.12.0, streamx@npm:^2.12.5, streamx@npm:^2.13.2, streamx@npm:^2.14.0":
version: 2.28.0
resolution: "streamx@npm:2.28.0"
@@ -14609,15 +14571,6 @@ __metadata:
languageName: node
linkType: hard
"through2@npm:^4.0.2":
version: 4.0.2
resolution: "through2@npm:4.0.2"
dependencies:
readable-stream: "npm:3"
checksum: 10/72c246233d9a989bbebeb6b698ef0b7b9064cb1c47930f79b25d87b6c867e075432811f69b7b2ac8da00ca308191c507bdab913944be8019ac43b036ce88f6ba
languageName: node
linkType: hard
"time-stamp@npm:^1.0.0":
version: 1.1.0
resolution: "time-stamp@npm:1.1.0"
@@ -15050,9 +15003,9 @@ __metadata:
linkType: hard
"undici@npm:^6.25.0":
version: 6.27.0
resolution: "undici@npm:6.27.0"
checksum: 10/30c18cdb235edf4dd36f8aa3ace1ffaf44060289a7d62ad44c33180d2d74a224015d25574812f62ce9c625b5beb1b0b766495b650fedf356aca11eed7ce2c816
version: 6.28.0
resolution: "undici@npm:6.28.0"
checksum: 10/672a7a53bd1f6c80edb73b357ab36e98ef9e474024c442aab2b8ea2bc32f1103cab05525c78661ae724f3e1340e23d03efb9730fba28e76218ab0ec52e15d75a
languageName: node
linkType: hard