mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-04 21:52:59 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b6e150def | |||
| 939b7012e2 | |||
| 488d3d9b37 | |||
| 08dedc415f | |||
| 400bf78ce0 | |||
| 9b2093125e | |||
| c0d963747a | |||
| 1bcd43fb75 | |||
| ce5925388c | |||
| 088f72fcc4 | |||
| b93f09ffb2 | |||
| fb499093e5 | |||
| c97716d4ad | |||
| b36361ca34 | |||
| 007a7916c0 | |||
| 637f6955a8 |
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1 @@
|
||||
import { availableParallelism } from "node:os";
|
||||
import "./build-scripts/gulp/index.mjs";
|
||||
|
||||
process.env.UV_THREADPOOL_SIZE = availableParallelism();
|
||||
|
||||
+4
-4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
>
|
||||
${
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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月");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user