mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-04 21:52:59 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d059777cc3 | |||
| 6e8a8fda6a | |||
| ea2e959d06 |
@@ -31,33 +31,6 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
|
||||
|
||||
## Recurring Review Issues
|
||||
|
||||
Scope and public surface:
|
||||
|
||||
- Keep changes independently reviewable and limited to the requested area.
|
||||
- Prefer existing Home Assistant helpers, Lit primitives, and component seams over parallel implementations.
|
||||
- Challenge new public properties and optional feature surface when transient options or existing seams meet the requirement with less lifecycle and consistency cost.
|
||||
|
||||
Stateful and asynchronous UI:
|
||||
|
||||
- Review transitions in both directions, not only individual rendered states.
|
||||
- When controls reappear, restore valid defaults instead of retaining state that was only valid while they were hidden.
|
||||
- Establish immutable dirty-state baselines before asynchronous work, guard against stale responses, and preserve unsaved state in mounted editors.
|
||||
- Determine an action's current meaning before applying dirty-state checks, especially when an action can change between Save and Close.
|
||||
|
||||
Readiness and invalidation:
|
||||
|
||||
- Treat readiness as the first displayable terminal result, including stable empty and error states.
|
||||
- Register child readiness before resolving the parent, do not treat fallback work as terminal, and replay readiness correctly for cached or reused panels.
|
||||
- Ensure every value read by memoized output participates in its invalidation.
|
||||
|
||||
Repository-owned contracts:
|
||||
|
||||
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
|
||||
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
|
||||
- Prefer canonical repository helpers and test setup over duplicate local implementations.
|
||||
- Promote AI-review concerns into durable guidance only when supported by code evidence, reproduced behavior, an accepted corrective commit, or human-maintainer validation.
|
||||
|
||||
User experience and accessibility:
|
||||
|
||||
- Forms need proper labels, helper text, and validation feedback.
|
||||
@@ -97,4 +70,4 @@ Configuration and props:
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
|
||||
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-events`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// 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]);
|
||||
});
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
// 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 +1,4 @@
|
||||
import { availableParallelism } from "node:os";
|
||||
import "./build-scripts/gulp/index.mjs";
|
||||
|
||||
process.env.UV_THREADPOOL_SIZE = availableParallelism();
|
||||
|
||||
+2
-2
@@ -103,6 +103,7 @@
|
||||
"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",
|
||||
@@ -144,7 +145,6 @@
|
||||
"@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",
|
||||
@@ -164,7 +164,7 @@
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.3",
|
||||
"@types/luxon": "3.7.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
|
||||
@@ -294,26 +294,3 @@ 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,7 +15,6 @@ 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,
|
||||
formatCallyMonthYear,
|
||||
formatDateMonthYear,
|
||||
formatDateMonth,
|
||||
formatDateYear,
|
||||
formatISODateOnly,
|
||||
} from "../../common/datetime/format_date";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
@@ -60,16 +60,13 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
})
|
||||
private _hassConfig!: HassConfig;
|
||||
|
||||
/** used to show month and year in calendar-range header */
|
||||
@state() private _pickerMonthYear?: string;
|
||||
/** used to show month in calendar-range header */
|
||||
@state() private _pickerMonth?: string;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** used to show year in calendar-date header */
|
||||
@state() private _pickerYear?: string;
|
||||
|
||||
/** used for today to navigate focus in calendar-range */
|
||||
@state() private _focusDate?: string;
|
||||
|
||||
@state() private _dateValue?: string;
|
||||
@@ -95,7 +92,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
this._hassConfig
|
||||
)
|
||||
: undefined;
|
||||
this._pickerMonthYear = formatDateMonthYear(
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
@@ -165,7 +167,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
slot="previous"
|
||||
></ha-icon-button-prev>
|
||||
<div class="heading" slot="heading">
|
||||
<span class="month-year">${this._pickerMonthYear}</span>
|
||||
<span class="month-year"
|
||||
>${this._pickerMonth} ${this._pickerYear}</span
|
||||
>
|
||||
<ha-icon-button
|
||||
@click=${this._focusToday}
|
||||
.path=${mdiCalendarToday}
|
||||
@@ -229,7 +233,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerMonthYear = formatDateMonthYear(
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
@@ -302,12 +311,19 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _focusChanged(
|
||||
ev: HASSDomEvent<Date> &
|
||||
HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
|
||||
) {
|
||||
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
|
||||
this._focusDate = ev.target.focusedDate;
|
||||
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 _handleChange(
|
||||
@@ -315,7 +331,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
) {
|
||||
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
|
||||
this._dateValue = dateElement.value;
|
||||
this._focusDate = dateElement.focusedDate;
|
||||
this._focusDate = undefined;
|
||||
}
|
||||
|
||||
private _clickDateRangeChip(
|
||||
|
||||
@@ -6,8 +6,7 @@ import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import {
|
||||
formatCallyMonthYear,
|
||||
formatDateMonthYear,
|
||||
formatDateMonth,
|
||||
formatDateShort,
|
||||
formatDateYear,
|
||||
formatISODateOnly,
|
||||
@@ -61,16 +60,13 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
dateString: string;
|
||||
};
|
||||
|
||||
/** used to show month and year in calendar-date header */
|
||||
@state() private _pickerMonthYear?: string;
|
||||
/** used to show month in calendar-date header */
|
||||
@state() private _pickerMonth?: string;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** used to show year in calendar-date header */
|
||||
@state() private _pickerYear?: string;
|
||||
|
||||
/** used for today to navigate focus in cally-calendar-date */
|
||||
@state() private _focusDate?: string;
|
||||
|
||||
public connectedCallback() {
|
||||
@@ -79,7 +75,12 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
if (this.params) {
|
||||
const date = valueToDate(this.params.value);
|
||||
|
||||
this._pickerMonthYear = formatDateMonthYear(
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
@@ -87,7 +88,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
|
||||
this._value = this.params.value
|
||||
? {
|
||||
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
|
||||
year: this._pickerYear,
|
||||
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
|
||||
dateString: formatISODateOnly(
|
||||
date,
|
||||
@@ -143,7 +144,9 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
slot="previous"
|
||||
></ha-icon-button-prev>
|
||||
<div class="heading" slot="heading">
|
||||
<span class="month-year">${this._pickerMonthYear}</span>
|
||||
<span class="month-year"
|
||||
>${this._pickerMonth} ${this._pickerYear}</span
|
||||
>
|
||||
<ha-icon-button
|
||||
@click=${this._setToday}
|
||||
.path=${mdiCalendarToday}
|
||||
@@ -186,7 +189,12 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
|
||||
if (setFocusDay) {
|
||||
this._focusDate = this._value.dateString;
|
||||
this._pickerMonthYear = formatDateMonthYear(
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
@@ -194,11 +202,19 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
}
|
||||
}
|
||||
|
||||
private _focusChanged(
|
||||
ev: HASSDomEvent<Date> & HASSDomTargetEvent<CalendarDate>
|
||||
) {
|
||||
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
|
||||
this._focusDate = ev.target.focusedDate;
|
||||
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 _clear() {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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,11 +12,9 @@ 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,
|
||||
@@ -24,11 +22,6 @@ 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;
|
||||
@@ -94,9 +87,7 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
|
||||
schema: readonly HaFormSchema[],
|
||||
displayActions: string[]
|
||||
): HaFormSchema[] =>
|
||||
schema
|
||||
.filter((item) => displayActions.includes(item.name))
|
||||
.flatMap((item) => [DIVIDER, item])
|
||||
schema.filter((item) => displayActions.includes(item.name))
|
||||
);
|
||||
|
||||
public render(): TemplateResult {
|
||||
@@ -137,7 +128,6 @@ 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,7 +20,6 @@ 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,7 +4,6 @@ import type { HaDurationData } from "../ha-duration-input";
|
||||
|
||||
export type HaFormSchema =
|
||||
| HaFormConstantSchema
|
||||
| HaFormDividerSchema
|
||||
| HaFormStringSchema
|
||||
| HaFormIntegerSchema
|
||||
| HaFormFloatSchema
|
||||
@@ -98,10 +97,6 @@ export interface HaFormConstantSchema extends HaFormBaseSchema {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface HaFormDividerSchema extends HaFormBaseSchema {
|
||||
type: "divider";
|
||||
}
|
||||
|
||||
export interface HaFormIntegerSchema extends HaFormBaseSchema {
|
||||
type: "integer";
|
||||
default?: HaFormIntegerData;
|
||||
|
||||
@@ -187,7 +187,6 @@ export class HaSelectSelector extends LitElement {
|
||||
.idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
.label=${label}
|
||||
.title=${label}
|
||||
selected
|
||||
>
|
||||
${
|
||||
|
||||
@@ -317,9 +317,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
color:
|
||||
automation.state === UNAVAILABLE
|
||||
? "var(--error-color)"
|
||||
: automation.state === "on"
|
||||
? "var(--state-active-color)"
|
||||
: "unset",
|
||||
: "unset",
|
||||
})}
|
||||
></ha-state-icon>`,
|
||||
},
|
||||
@@ -348,17 +346,19 @@ 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) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
template: (automation) =>
|
||||
narrow
|
||||
? automation.formatted_state
|
||||
: html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -68,7 +68,6 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
header="Home Assistant Cloud"
|
||||
>
|
||||
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
|
||||
|
||||
@@ -106,6 +106,7 @@ class HaPanelConfig extends HassRouterPage {
|
||||
integrations: {
|
||||
tag: "ha-config-integrations",
|
||||
load: () => import("./integrations/ha-config-integrations"),
|
||||
waitForReady: true,
|
||||
},
|
||||
labels: {
|
||||
tag: "ha-config-labels",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mdiFilterVariant, mdiPlus } from "@mdi/js";
|
||||
import { consume } from "@lit/context";
|
||||
import type { IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
@@ -54,6 +55,10 @@ import type { ImprovDiscoveredDevice } from "../../../external_app/external_mess
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-tabs-subpage";
|
||||
import type { HassTabsSubpage } from "../../../layouts/hass-tabs-subpage";
|
||||
import {
|
||||
childPanelReadyContext,
|
||||
type RegisterChildPanelReady,
|
||||
} from "../../../layouts/panel-ready";
|
||||
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
@@ -168,6 +173,17 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
|
||||
@query("hass-tabs-subpage") private _tabsSubpage?: HassTabsSubpage;
|
||||
|
||||
private _resolveInitialRender?: () => void;
|
||||
|
||||
private _initialRenderComplete = new Promise<void>((resolve) => {
|
||||
this._resolveInitialRender = resolve;
|
||||
});
|
||||
|
||||
private _childReadyRegistered = false;
|
||||
|
||||
@consume({ context: childPanelReadyContext, subscribe: true })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener(
|
||||
@@ -388,6 +404,10 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
super.updated(changed);
|
||||
if (!this._childReadyRegistered && this._registerChildPanelReady) {
|
||||
this._registerChildPanelReady(this._initialRenderComplete);
|
||||
this._childReadyRegistered = true;
|
||||
}
|
||||
if (changed.has("route")) {
|
||||
this._handleRouteChanged();
|
||||
}
|
||||
@@ -415,6 +435,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
}
|
||||
|
||||
if (this.configEntries && this.configEntriesInProgress) {
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
|
||||
const activeElement = deepActiveElement();
|
||||
|
||||
if (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { DataEntryFlowProgress } from "../../../data/data_entry_flow";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import { ChildPanelReady } from "../../../layouts/panel-ready";
|
||||
import type { RouterOptions } from "../../../layouts/hass-router-page";
|
||||
import { HassRouterPage } from "../../../layouts/hass-router-page";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
@@ -70,6 +71,11 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
|
||||
|
||||
private _loadTranslationsPromise?: Promise<LocalizeFunc>;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
new ChildPanelReady(this);
|
||||
}
|
||||
|
||||
public hassSubscribe() {
|
||||
return [
|
||||
subscribeConfigEntries(
|
||||
|
||||
@@ -135,10 +135,6 @@ export class HuiShortcutBadgeEditor
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
name: "double_tap_action",
|
||||
selector: {
|
||||
|
||||
@@ -168,10 +168,6 @@ export class HuiShortcutCardEditor
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
name: "double_tap_action",
|
||||
selector: {
|
||||
|
||||
@@ -199,10 +199,6 @@ export class HuiTileCardEditor
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
name: "icon_tap_action",
|
||||
selector: {
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* @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,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCallyMonthYear,
|
||||
formatDate,
|
||||
formatDateWeekdayDay,
|
||||
formatDateShort,
|
||||
@@ -264,43 +263,4 @@ 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月");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+82
-81
@@ -4,7 +4,7 @@
|
||||
* Run with:
|
||||
* yarn test:e2e:app
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
appSidebar,
|
||||
appSidebarConfig,
|
||||
@@ -161,65 +161,100 @@ test.describe("Quick search", () => {
|
||||
|
||||
defineRouteSmokeTests(appRouteSmokeGroups);
|
||||
|
||||
const assertInitialReadiness = async (
|
||||
page: Page,
|
||||
options: {
|
||||
test("keeps the launch screen until initial panel content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cases: {
|
||||
name: string;
|
||||
path: string;
|
||||
loadingSelector: string;
|
||||
resolver:
|
||||
"rejectMediaBrowse" | "resolveCalendarRegistry" | "resolveMediaBrowse";
|
||||
readySelector: string;
|
||||
}
|
||||
) => {
|
||||
await goToPanel(page, options.path);
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator(options.loadingSelector)).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
|
||||
await page.evaluate((resolver) => window[resolver]?.(), options.resolver);
|
||||
|
||||
await expect(page.locator(options.readySelector)).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
};
|
||||
|
||||
test.describe("Initial readiness", () => {
|
||||
test("keeps the launch screen until calendar content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertInitialReadiness(page, {
|
||||
resolvers: (
|
||||
| "rejectMediaBrowse"
|
||||
| "resolveCalendarRegistry"
|
||||
| "resolveConfigEntries"
|
||||
| "resolveConfigEntriesInProgress"
|
||||
| "resolveGeneratedDashboard"
|
||||
| "resolveLovelaceConfig"
|
||||
| "resolveMediaBrowse"
|
||||
)[];
|
||||
}[] = [
|
||||
{
|
||||
name: "calendar",
|
||||
path: "/?scenario=delayed-calendar#/calendar",
|
||||
loadingSelector: "ha-panel-calendar ha-spinner",
|
||||
resolver: "resolveCalendarRegistry",
|
||||
readySelector: "ha-full-calendar",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the launch screen until media content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertInitialReadiness(page, {
|
||||
resolvers: ["resolveCalendarRegistry"],
|
||||
},
|
||||
{
|
||||
name: "media browser",
|
||||
path: "/?scenario=delayed-media-browse#/media-browser/browser",
|
||||
loadingSelector: "ha-media-player-browse > ha-spinner",
|
||||
resolver: "resolveMediaBrowse",
|
||||
readySelector: "ha-media-player-browse .no-items",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the launch screen until a media error renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertInitialReadiness(page, {
|
||||
resolvers: ["resolveMediaBrowse"],
|
||||
},
|
||||
{
|
||||
name: "integrations",
|
||||
path: "/?scenario=delayed-integrations#/config/integrations",
|
||||
loadingSelector: "ha-config-integrations-dashboard hass-loading-screen",
|
||||
readySelector: "ha-config-integrations-dashboard hass-tabs-subpage",
|
||||
resolvers: ["resolveConfigEntries", "resolveConfigEntriesInProgress"],
|
||||
},
|
||||
{
|
||||
name: "media browser error",
|
||||
path: "/?scenario=delayed-media-browse-error#/media-browser/browser",
|
||||
loadingSelector: "ha-media-player-browse > ha-spinner",
|
||||
resolver: "rejectMediaBrowse",
|
||||
readySelector: "ha-media-player-browse ha-alert",
|
||||
resolvers: ["rejectMediaBrowse"],
|
||||
},
|
||||
{
|
||||
name: "generated dashboard",
|
||||
path: "/?scenario=delayed-generated-dashboard#/climate",
|
||||
loadingSelector: "#ha-launch-screen",
|
||||
readySelector: "hui-view",
|
||||
resolvers: ["resolveGeneratedDashboard"],
|
||||
},
|
||||
{
|
||||
name: "Lovelace dashboard",
|
||||
path: "/?scenario=delayed-lovelace#/lovelace",
|
||||
loadingSelector: "#ha-launch-screen",
|
||||
readySelector: "hui-card",
|
||||
resolvers: ["resolveLovelaceConfig"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const readinessCase of cases) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await test.step(readinessCase.name, async () => {
|
||||
await goToPanel(page, readinessCase.path);
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
const loadingScreen = page.locator(readinessCase.loadingSelector);
|
||||
const readyContent = page.locator(readinessCase.readySelector).first();
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(loadingScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(readyContent).not.toBeAttached();
|
||||
|
||||
await readinessCase.resolvers.reduce(
|
||||
async (previousResolver, resolver, index) => {
|
||||
await previousResolver;
|
||||
await page.evaluate((resolverName) => {
|
||||
window[resolverName]?.();
|
||||
}, resolver);
|
||||
|
||||
if (index < readinessCase.resolvers.length - 1) {
|
||||
await expect(launchScreen).toBeAttached();
|
||||
await expect(loadingScreen).toBeAttached();
|
||||
await expect(readyContent).not.toBeAttached();
|
||||
}
|
||||
},
|
||||
Promise.resolve()
|
||||
);
|
||||
|
||||
await expect(readyContent).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -227,40 +262,6 @@ test.describe("Initial readiness", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Lovelace dashboard", () => {
|
||||
test("keeps the launch screen until generated content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=delayed-generated-dashboard#/climate");
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator("hui-view")).not.toBeAttached();
|
||||
|
||||
await page.evaluate(() => window.resolveGeneratedDashboard?.());
|
||||
|
||||
await expect(page.locator("hui-view")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
|
||||
test("keeps the launch screen until initial content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=delayed-lovelace#/lovelace");
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator("hui-card")).not.toBeAttached();
|
||||
|
||||
await page.evaluate(() => window.resolveLovelaceConfig?.());
|
||||
|
||||
await expect(page.locator("hui-card").first()).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
|
||||
test("renders cards", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
// At least one card should appear
|
||||
|
||||
@@ -94,6 +94,8 @@ declare global {
|
||||
__mockHass: MockHomeAssistant;
|
||||
rejectMediaBrowse?: () => void;
|
||||
resolveCalendarRegistry?: () => void;
|
||||
resolveConfigEntries?: () => void;
|
||||
resolveConfigEntriesInProgress?: () => void;
|
||||
resolveGeneratedDashboard?: () => void;
|
||||
resolveLovelaceConfig?: () => void;
|
||||
resolveMediaBrowse?: () => void;
|
||||
|
||||
@@ -185,6 +185,25 @@ const delayedCalendarScenario: Scenario = (hass) => {
|
||||
hass.mockWS("config/entity_registry/list", () => registryPromise);
|
||||
};
|
||||
|
||||
const delayedIntegrationsScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
hass.mockWS(
|
||||
"config_entries/subscribe",
|
||||
(_msg, _currentHass, onChange?: (updates: unknown[]) => void) => {
|
||||
window.resolveConfigEntries = () => onChange?.([]);
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"config_entries/flow/subscribe",
|
||||
(_msg, _currentHass, onChange?: (updates: unknown[]) => void) => {
|
||||
window.resolveConfigEntriesInProgress = () => onChange?.([]);
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const delayedMediaBrowseScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
@@ -230,6 +249,7 @@ export const scenarios: Record<string, Scenario> = {
|
||||
"custom-theme": customThemeScenario,
|
||||
"delayed-calendar": delayedCalendarScenario,
|
||||
"delayed-generated-dashboard": delayedGeneratedDashboardScenario,
|
||||
"delayed-integrations": delayedIntegrationsScenario,
|
||||
"delayed-media-browse": delayedMediaBrowseScenario,
|
||||
"delayed-media-browse-error": delayedMediaBrowseErrorScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
|
||||
@@ -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:
|
||||
@@ -5641,10 +5641,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/luxon@npm:3.7.3":
|
||||
version: 3.7.3
|
||||
resolution: "@types/luxon@npm:3.7.3"
|
||||
checksum: 10/f097bd3f7c47ae766928e0fa496ec13fe4d72c42b29afa4bed457775ce4fb4d8574ccccc0c974bf8915d1e6a7e226fa6efb989905786c4c9c1714754f74e8a17
|
||||
"@types/luxon@npm:3.7.2":
|
||||
version: 3.7.2
|
||||
resolution: "@types/luxon@npm:3.7.2"
|
||||
checksum: 10/6634ebaceeec84d39cbb456d4db358481bc2ba7d05df47890a4f2d235100c29a52f621e1401f016747093093fa86c117bef01446199e0b1af3bcdcee47a57b1f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -6744,6 +6744,13 @@ __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"
|
||||
@@ -7321,7 +7328,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
|
||||
@@ -9766,6 +9773,19 @@ __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"
|
||||
@@ -9899,7 +9919,6 @@ __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"
|
||||
@@ -9937,7 +9956,7 @@ __metadata:
|
||||
"@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.3"
|
||||
"@types/luxon": "npm:3.7.2"
|
||||
"@types/qrcode": "npm:1.5.6"
|
||||
"@types/sortablejs": "npm:1.15.9"
|
||||
"@types/tar": "npm:7.0.87"
|
||||
@@ -9982,6 +10001,7 @@ __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"
|
||||
@@ -12764,6 +12784,15 @@ __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"
|
||||
@@ -12977,7 +13006,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-stream@npm:2 || 3, readable-stream@npm:^3.4.0":
|
||||
"readable-stream@npm:2 || 3, readable-stream@npm:3, readable-stream@npm:^3.4.0":
|
||||
version: 3.6.2
|
||||
resolution: "readable-stream@npm:3.6.2"
|
||||
dependencies:
|
||||
@@ -14165,6 +14194,15 @@ __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"
|
||||
@@ -14571,6 +14609,15 @@ __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"
|
||||
|
||||
Reference in New Issue
Block a user