Compare commits

...
Author SHA1 Message Date
noksideandGitHub fcca87b368 Fix editing nested multiple object selector items (#53709)
* Fix editing nested multiple object selectors

* Fix nested form dialog close and focus restoration

Cancel all pending nested form levels when the physical dialog closes, and restore focus to the opener after nested submit or cancel.

Add regression coverage for both behaviors.

* Fix selector object test types
2026-08-24 09:41:58 +03:00
puddlyandGitHub 2eb3b025d8 Add a serial panel (#53699)
* WIP: Initial attempt

* Arrange into sections

* Clean up UI

* Add descriptions

* Align with Core changes

* Address review feedback and add an info dialog

* Combine websocket commands into one

* Drop unnecessary info dialog rows

* Add a status bar

* Expose `resolved_device` as well
2026-08-24 06:58:23 +02:00
DawnWangandGitHub 43628c5d69 Fix error badge creation on cold load (#53752) 2026-08-24 04:53:53 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0f52c00821 Update dependency echarts-extension-chart2music to v0.1.1 (#53753)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 06:48:24 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
20f70cde7a Bump the codeql-action group across 1 directory with 2 updates (#53744)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 09:55:54 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b38d7bcb26 Update dependency hls.js to v1.7.1 (#53741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-22 22:20:30 +02:00
Petar PetrovandGitHub 0572d71699 Replace a remembered yesterday energy period when picking now (#53735) 2026-08-22 17:36:40 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
bcd13fcaf7 Update vitest monorepo to v4.1.11 (#53732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 21:35:37 +02:00
17 changed files with 1736 additions and 139 deletions
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:javascript-typescript"
+4 -4
View File
@@ -101,10 +101,10 @@
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.0",
"hls.js": "1.7.1",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
@@ -170,7 +170,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "4.1.10",
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.8",
@@ -215,7 +215,7 @@
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
+30
View File
@@ -2,13 +2,37 @@ import type { HomeAssistant } from "../types";
export interface SerialPort {
device: string;
resolved_device: string | null;
serial_number: string | null;
manufacturer: string | null;
description: string | null;
interface_description?: string | null;
interface_num?: number | null;
vid?: string;
pid?: string;
bcd_device?: number | null;
matching_integrations: string[];
// Configured ports that are not currently connected are absent from a scan
present: boolean;
}
export interface SerialPortConsumer {
kind: "config_entry" | "app";
title: string;
active: boolean;
domain: string | null;
config_entry_id: string | null;
slug: string | null;
}
export interface SerialPortDiscoveryFlow {
flow_id: string;
domain: string;
}
export interface SerialPortUsage extends SerialPort {
consumers: SerialPortConsumer[];
discovery_flows: SerialPortDiscoveryFlow[];
}
export const scanUSBDevices = (hass: HomeAssistant) =>
@@ -16,3 +40,9 @@ export const scanUSBDevices = (hass: HomeAssistant) =>
export const listSerialPorts = (hass: HomeAssistant) =>
hass.callWS<SerialPort[]>({ type: "usb/list_serial_ports" });
export const listSerialPortsWithUsage = (hass: HomeAssistant) =>
hass.callWS<SerialPortUsage[]>({
type: "usb/list_serial_ports",
include_usage: true,
});
+178 -62
View File
@@ -1,29 +1,39 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import deepClone from "deep-clone-simple";
import { deepActiveElement } from "../../common/dom/deep-active-element";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-form/ha-form";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-form/ha-form";
import type { HaDialog } from "../../components/ha-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
interface StackEntry {
params: FormDialogParams;
initialData: FormDialogData;
data: FormDialogData;
nestedField?: string;
scrollTop: number;
focusTarget?: Element;
error?: Record<string, string>;
}
@customElement("dialog-form")
export class DialogForm
extends DirtyStateProviderMixin<FormDialogData>()(LitElement)
extends DirtyStateProviderMixin<FormDialogData[]>()(LitElement)
implements HassDialog<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -32,6 +42,8 @@ export class DialogForm
@state() private _data: FormDialogData = {};
private _initialData: FormDialogData = {};
@state() private _open = false;
@state() private _closeState?: "canceled" | "submitted";
@@ -40,14 +52,19 @@ export class DialogForm
@state() private _error?: Record<string, string>;
@query("ha-form") private _form?: HaForm;
@query("ha-dialog") private _dialog?: HaDialog;
@query("ha-form:not([hidden])") private _form?: HaForm;
@queryAll("ha-form") private _forms!: NodeListOf<HaForm>;
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
this._initialData = deepClone(this._data);
this._open = true;
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
this._resetDirtyTracking();
}
public closeDialog(): boolean {
@@ -55,54 +72,119 @@ export class DialogForm
return true;
}
private _initialDirtyState(): FormDialogData[] {
return [
...this._stack.map((entry) => entry.initialData),
this._initialData,
];
}
private _currentDirtyState(): FormDialogData[] {
return [...this._stack.map((entry) => entry.data), this._data];
}
private _resetDirtyTracking(): void {
this._initDirtyTracking({ type: "deep" }, this._initialDirtyState());
this._updateDirtyState(this._currentDirtyState());
}
private _handleNestedShowDialog = (
ev: HASSDomEvent<ShowDialogParams<unknown>>
) => {
if (ev.detail.dialogTag !== "dialog-form") {
if (
ev.detail.dialogTag !== "dialog-form" ||
ev.currentTarget !== this._form
) {
return;
}
ev.stopPropagation();
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
const nested = ev.detail.dialogParams as FormDialogParams;
if (!nested.submit || !nested.cancel) {
return;
}
ev.stopPropagation();
const focusTarget = deepActiveElement();
this._stack = [
...this._stack,
{
params: this._params!,
initialData: this._initialData,
data: this._data,
nestedField: origin?.name,
scrollTop: this._dialog?.bodyContainer.scrollTop ?? 0,
focusTarget: focusTarget ?? undefined,
error: this._error,
},
];
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested?.data || {};
this._data = nested.data || {};
this._initialData = deepClone(this._data);
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
this._resetDirtyTracking();
};
private _popStack(): string | undefined {
private _popStack(): StackEntry | undefined {
if (!this._stack.length) {
return undefined;
}
const prev = this._stack[this._stack.length - 1];
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._initialData = prev.initialData;
this._data = prev.data;
this._error = prev.error;
this._initDirtyTracking({ type: "deep" }, this._data);
return prev.nestedField;
this._resetDirtyTracking();
return prev;
}
private async _restoreFocusAndScroll(
scrollTop: number,
expectedParams: FormDialogParams,
focusTarget?: Element
): Promise<void> {
await this.updateComplete;
await this._form?.updateComplete;
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
if (!this._open || this._params !== expectedParams || !this._dialog) {
return;
}
if (focusTarget instanceof HTMLElement && focusTarget.isConnected) {
focusTarget.focus();
}
this._dialog.bodyContainer.scrollTop = scrollTop;
}
private _dialogClosed(): void {
if (!this._closeState) {
this._params?.cancel?.();
for (let index = this._stack.length - 1; index >= 0; index--) {
this._stack[index].params.cancel?.();
}
}
if (this._closeState !== "submitted") {
this._discardDirtyStateChanges();
}
this._closeState = undefined;
this._stack = [];
this._params = undefined;
this._initialData = {};
this._data = {};
this._open = false;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -114,53 +196,69 @@ export class DialogForm
return;
}
this._closeState = "submitted";
const submit = this._params?.submit;
const data = this._data;
const nestedField = this._popStack();
const stackEntry = this._popStack();
submit?.(data);
if (!nestedField) {
if (!stackEntry) {
this._closeState = "submitted";
submit?.(data);
this._markDirtyStateClean();
this.closeDialog();
return;
}
const schemaField = this._params?.schema.find(
(f) => "selector" in f && f.name === nestedField
submit!(data);
void this._restoreFocusAndScroll(
stackEntry.scrollTop,
stackEntry.params,
stackEntry.focusTarget
);
const isMultiple =
schemaField &&
"selector" in schemaField &&
"object" in schemaField.selector &&
schemaField.selector.object?.multiple === true;
const current = this._data[nestedField];
const newValue = isMultiple
? [...(Array.isArray(current) ? current : []), data]
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
this._error = undefined;
this._updateDirtyState(this._data);
}
private _cancel(): void {
this._closeState = "canceled";
const cancel = this._params?.cancel;
const nestedField = this._popStack();
const stackEntry = this._popStack();
cancel?.();
if (!nestedField) {
if (!stackEntry) {
this._closeState = "canceled";
cancel?.();
this.closeDialog();
return;
}
cancel!();
void this._restoreFocusAndScroll(
stackEntry.scrollTop,
stackEntry.params,
stackEntry.focusTarget
);
}
private _valueChanged(ev: CustomEvent): void {
this._data = ev.detail.value;
this._error = undefined;
this._updateDirtyState(this._data);
const levelIndex = Array.from(this._forms).indexOf(
ev.currentTarget as HaForm
);
if (levelIndex === -1) {
return;
}
const data = ev.detail.value as FormDialogData;
if (levelIndex === this._stack.length) {
this._data = data;
this._error = undefined;
this._updateDirtyState(this._currentDirtyState());
return;
}
if (levelIndex < this._stack.length) {
this._stack = this._stack.map((entry, index) =>
index === levelIndex ? { ...entry, data, error: undefined } : entry
);
this._updateDirtyState(this._currentDirtyState());
}
}
protected render() {
@@ -168,35 +266,53 @@ export class DialogForm
return nothing;
}
const params = this._params;
const levels = [
...this._stack,
{
params,
initialData: this._initialData,
data: this._data,
error: this._error,
},
];
return html`
<ha-dialog
.open=${this._open}
header-title=${this._params.title}
header-title=${params.title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-form
autofocus
.hass=${this.hass}
.computeLabel=${this._params.computeLabel}
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
.error=${this._error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
${levels.map((level, index) => {
const isActive = index === levels.length - 1;
return html`
<ha-form
?hidden=${!isActive}
?autofocus=${isActive}
.hass=${this.hass}
.computeLabel=${level.params.computeLabel}
.computeHelper=${level.params.computeHelper}
.data=${level.data}
.schema=${level.params.schema}
.error=${level.error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
`;
})}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._cancel}
>
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
${params.cancelText || this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
${this._params.submitText || this.hass.localize("ui.common.save")}
${params.submitText || this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
+9
View File
@@ -3,6 +3,7 @@ import {
mdiBackupRestore,
mdiBadgeAccountHorizontal,
mdiBluetooth,
mdiCableData,
mdiCellphoneCog,
mdiCog,
mdiDatabase,
@@ -168,6 +169,14 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "bluetooth",
adminOnly: true,
},
{
path: "/config/serial",
iconPath: mdiCableData,
iconColor: "#607D8B",
component: "usb",
translationKey: "serial",
adminOnly: true,
},
{
path: "/config/infrared",
iconPath: mdiRemote,
+5
View File
@@ -212,6 +212,11 @@ class HaPanelConfig extends HassRouterPage {
load: () =>
import("./integrations/integration-panels/infrared/infrared-config-dashboard-router"),
},
serial: {
tag: "serial-config-dashboard",
load: () =>
import("./integrations/integration-panels/serial/serial-config-dashboard"),
},
dhcp: {
tag: "dhcp-config-panel",
load: () =>
@@ -0,0 +1,154 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
@customElement("dialog-serial-port-info")
class DialogSerialPortInfo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: SerialPortInfoDialogParams;
@state() private _open = false;
public showDialog(params: SerialPortInfoDialogParams): void {
this._params = params;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private async _copyToClipboard(): Promise<void> {
await copyToClipboard(JSON.stringify(this._params!.port, null, 2));
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
private _fields(): [string, string][] {
const port = this._params!.port;
const localize = this.hass.localize;
const fields: [string, string | null | undefined][] = [
[localize("ui.panel.config.serial.fields.device"), port.device],
[
localize("ui.panel.config.serial.fields.resolved_device"),
port.resolved_device === port.device ? undefined : port.resolved_device,
],
[localize("ui.panel.config.serial.fields.description"), port.description],
[
localize("ui.panel.config.serial.fields.manufacturer"),
port.manufacturer,
],
[
localize("ui.panel.config.serial.fields.interface_description"),
port.interface_description,
],
[
localize("ui.panel.config.serial.fields.interface_number"),
port.interface_num?.toString(),
],
[
localize("ui.panel.config.serial.fields.serial_number"),
port.serial_number,
],
[localize("ui.panel.config.serial.fields.vid"), port.vid],
[localize("ui.panel.config.serial.fields.pid"), port.pid],
[
localize("ui.panel.config.serial.fields.bcd_device"),
port.bcd_device == null
? undefined
: port.bcd_device.toString(16).padStart(4, "0").toUpperCase(),
],
];
return fields.filter(([, value]) => value) as [string, string][];
}
protected render(): TemplateResult | typeof nothing {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.serial.port_information"
)}
@closed=${this._dialogClosed}
>
<table>
<tbody>
${this._fields().map(
([label, value]) => html`
<tr>
<th>${label}</th>
<td>${value}</td>
</tr>
`
)}
</tbody>
</table>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._copyToClipboard}
>
${this.hass.localize("ui.common.copy")}
</ha-button>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
static readonly styles: CSSResultGroup = css`
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: start;
vertical-align: top;
white-space: nowrap;
padding-inline-end: var(--ha-space-4);
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
td {
width: 100%;
word-break: break-all;
}
tr:not(:first-child) th,
tr:not(:first-child) td {
padding-top: var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-serial-port-info": DialogSerialPortInfo;
}
}
@@ -0,0 +1,643 @@
import {
mdiAlertCircleOutline,
mdiCableData,
mdiCheck,
mdiConnection,
mdiInformationOutline,
mdiMemory,
mdiPowerPlugOff,
mdiPuzzle,
mdiRefresh,
mdiUsb,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import { domainToName } from "../../../../../data/integration";
import type {
SerialPort,
SerialPortConsumer,
SerialPortDiscoveryFlow,
SerialPortUsage,
} from "../../../../../data/usb";
import { listSerialPortsWithUsage } from "../../../../../data/usb";
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
import { mdiEsphomeLogo } from "../../../../../resources/esphome-logo-svg";
import { showSerialPortInfoDialog } from "./show-dialog-serial-port-info";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
const ESPHOME_HASS_SCHEME = "esphome-hass://";
type SerialPortType =
"serial_proxy" | "integration" | "usb" | "embedded" | "unnamed";
const TYPE_ICONS: Record<SerialPortType, string> = {
serial_proxy: mdiEsphomeLogo,
integration: mdiConnection,
usb: mdiUsb,
embedded: mdiMemory,
unnamed: mdiMemory,
};
const getPortType = (port: SerialPort): SerialPortType => {
if (port.device.startsWith(ESPHOME_HASS_SCHEME)) {
return "serial_proxy";
}
if (port.device.includes("://")) {
return "integration";
}
if (port.vid || port.pid) {
return "usb";
}
if (port.description || port.manufacturer) {
return "embedded";
}
return "unnamed";
};
interface PortListItem {
icon: string;
primary: string;
serialNumber?: string;
matchingIntegrations: string[];
consumers: SerialPortConsumer[];
discoveryFlows: SerialPortDiscoveryFlow[];
port: SerialPortUsage;
}
@customElement("serial-config-dashboard")
export class SerialConfigDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _ports?: SerialPortUsage[];
@state() private _error?: string;
protected firstUpdated(): void {
this._fetchPorts();
}
private async _fetchPorts(): Promise<void> {
try {
this._ports = await listSerialPortsWithUsage(this.hass);
this._error = undefined;
} catch (err: any) {
this._error = err.message;
}
}
private _portListItem(
port: SerialPortUsage,
localize: HomeAssistant["localize"]
): PortListItem {
const type = getPortType(port);
const productManufacturer =
port.description && port.manufacturer
? `${port.description}${port.manufacturer}`
: port.description || port.manufacturer;
let primary: string;
if (
port.interface_description &&
port.interface_description !== port.description
) {
primary = port.interface_description;
} else {
primary = productManufacturer || port.device;
}
return {
icon: port.present ? TYPE_ICONS[type] : mdiPowerPlugOff,
primary,
// The full port details are shown in the info dialog
serialNumber: port.serial_number
? localize("ui.panel.config.serial.serial_number", {
serial_number: port.serial_number,
})
: undefined,
matchingIntegrations: port.matching_integrations,
consumers: port.consumers,
discoveryFlows: port.discovery_flows,
port,
};
}
private _sortedPorts = memoizeOne(
(
ports: SerialPortUsage[],
localize: HomeAssistant["localize"],
language: string
): {
available: PortListItem[];
connected: PortListItem[];
disconnected: PortListItem[];
} => {
const available: PortListItem[] = [];
const connected: PortListItem[] = [];
const disconnected: PortListItem[] = [];
for (const port of ports) {
let bucket: PortListItem[];
if (!port.present) {
bucket = disconnected;
} else if (port.consumers.length) {
bucket = connected;
} else {
bucket = available;
}
bucket.push(this._portListItem(port, localize));
}
const byPrimary = (a: PortListItem, b: PortListItem) =>
caseInsensitiveStringCompare(a.primary, b.primary, language);
available.sort(byPrimary);
connected.sort(byPrimary);
disconnected.sort(byPrimary);
return { available, connected, disconnected };
}
);
private _consumerName(consumer: SerialPortConsumer): string {
const name =
consumer.title ||
(consumer.domain
? domainToName(this.hass.localize, consumer.domain)
: consumer.slug!);
return consumer.active
? name
: this.hass.localize("ui.panel.config.serial.consumer_not_running", {
name,
});
}
private _renderConsumer(consumer: SerialPortConsumer): TemplateResult {
const href =
consumer.kind === "config_entry"
? `/config/integrations/integration/${consumer.domain}#config_entry=${consumer.config_entry_id}`
: `/config/app/${consumer.slug}/info`;
return html`
<ha-md-list-item type="link" href=${href} class="consumer">
${
consumer.kind === "config_entry"
? html`<img
slot="start"
.src=${brandsUrl(
{
domain: consumer.domain!,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${consumer.domain!}
/>`
: html`<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>`
}
<div slot="headline">${this._consumerName(consumer)}</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
private _renderDiscoveryFlow(flow: SerialPortDiscoveryFlow): TemplateResult {
return html`
<ha-md-list-item
type="button"
class="consumer"
.flowId=${flow.flow_id}
@click=${this._continueFlow}
>
<img
slot="start"
.src=${brandsUrl(
{
domain: flow.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${flow.domain}
/>
<div slot="headline">
${this.hass.localize("ui.panel.config.serial.discovered_by", {
integration: domainToName(this.hass.localize, flow.domain),
})}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
private _continueFlow(ev: Event): void {
showConfigFlowDialog(this, {
continueFlowId: (ev.currentTarget as any).flowId,
dialogClosedCallback: () => {
this._fetchPorts();
},
});
}
private _renderPortItem(item: PortListItem): TemplateResult {
const discoveredDomains = new Set(
item.discoveryFlows.map((flow) => flow.domain)
);
const remainingIntegrations = item.matchingIntegrations.filter(
(domain) => !discoveredDomains.has(domain)
);
const matchingIntegrations =
!item.consumers.length && remainingIntegrations.length
? remainingIntegrations
.map((domain) => domainToName(this.hass.localize, domain))
.join(", ")
: undefined;
return html`
<ha-md-list-item class="port">
<ha-svg-icon
slot="start"
class=${item.port.present ? "" : "disconnected"}
.path=${item.icon}
></ha-svg-icon>
<div slot="headline">${item.primary}</div>
${
item.serialNumber
? html`<div slot="supporting-text">${item.serialNumber}</div>`
: nothing
}
${
matchingIntegrations
? html`<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.serial.can_be_used_with",
{
integrations: matchingIntegrations,
}
)}
</div>`
: nothing
}
${
item.port.present
? html`<ha-icon-button
slot="end"
.label=${this.hass.localize(
"ui.panel.config.serial.port_information"
)}
.path=${mdiInformationOutline}
.port=${item.port}
@click=${this._showPortInfo}
></ha-icon-button>`
: nothing
}
</ha-md-list-item>
${item.consumers.map((consumer) => this._renderConsumer(consumer))}
${item.discoveryFlows.map((flow) => this._renderDiscoveryFlow(flow))}
`;
}
private _showPortInfo(ev: Event): void {
showSerialPortInfoDialog(this, {
port: (ev.currentTarget as any).port,
});
}
private _renderPortsCard(
header: string,
description: string,
items: PortListItem[]
): TemplateResult {
return html`
<ha-card class="ports">
<div class="card-header">${header}</div>
<div class="card-content">
<div class="description">${description}</div>
<ha-md-list>
${items.map((item) => this._renderPortItem(item))}
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderStatusCard(
inUse: number,
available: number,
disconnected: number
): TemplateResult {
const status = disconnected ? "disconnected" : "ok";
let summary = this.hass.localize("ui.panel.config.serial.status_summary", {
in_use: inUse,
total: inUse + available,
});
if (disconnected) {
summary += ` · ${this.hass.localize(
"ui.panel.config.serial.status_summary_disconnected",
{ count: disconnected }
)}`;
}
return html`
<ha-card class="status">
<div class="card-content">
<div class="heading">
<div class="icon ${status}">
<ha-svg-icon
.path=${disconnected ? mdiAlertCircleOutline : mdiCheck}
></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize(`ui.panel.config.serial.status_${status}`)}
<br />
<small>${summary}</small>
</div>
<ha-svg-icon class="logo" .path=${mdiCableData}></ha-svg-icon>
</div>
</div>
</ha-card>
`;
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.serial.title")}
back-path="/config"
>
<ha-icon-button
slot="toolbar-icon"
.label=${this.hass.localize("ui.common.refresh")}
.path=${mdiRefresh}
@click=${this._handleRefresh}
></ha-icon-button>
<div class="container">${this._renderContent()}</div>
</hass-subpage>
`;
}
private _renderContent(): TemplateResult {
if (this._error !== undefined) {
return html`
<ha-alert
alert-type="error"
.title=${this.hass.localize("ui.panel.config.serial.loading_error")}
>
${this._error}
</ha-alert>
`;
}
if (!this._ports) {
return html`
<div class="loading">
<ha-spinner></ha-spinner>
</div>
`;
}
const { available, connected, disconnected } = this._sortedPorts(
this._ports,
this.hass.localize,
this.hass.locale.language
);
if (!available.length && !connected.length && !disconnected.length) {
return html`
<ha-card>
<div class="card-content">
<div class="empty">
${this.hass.localize("ui.panel.config.serial.no_ports")}
</div>
</div>
</ha-card>
`;
}
return html`
${this._renderStatusCard(
connected.length,
available.length,
disconnected.length
)}
${
connected.length
? this._renderPortsCard(
this.hass.localize("ui.panel.config.serial.connected"),
this.hass.localize(
"ui.panel.config.serial.connected_description"
),
connected
)
: nothing
}
${
available.length
? this._renderPortsCard(
this.hass.localize("ui.panel.config.serial.available"),
this.hass.localize(
"ui.panel.config.serial.available_description"
),
available
)
: nothing
}
${
disconnected.length
? this._renderPortsCard(
this.hass.localize("ui.panel.config.serial.disconnected"),
this.hass.localize(
"ui.panel.config.serial.disconnected_description"
),
disconnected
)
: nothing
}
`;
}
private _handleRefresh(): void {
this._fetchPorts();
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card,
ha-alert {
display: block;
margin: 0px auto var(--ha-space-4);
max-width: 600px;
}
ha-card:first-child {
margin-top: var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-12) 0;
}
ha-card.ports {
overflow: hidden;
}
ha-card.ports .card-content {
padding: 0;
}
ha-card.ports .card-header {
padding-bottom: var(--ha-space-2);
}
.status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.status div.heading .logo {
margin-inline-start: auto;
--mdc-icon-size: 40px;
}
.status div.heading .icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: var(--ha-space-10);
height: var(--ha-space-10);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--primary-color);
}
.status div.heading .icon.ok {
--icon-color: var(--success-color);
}
.status div.heading .icon.disconnected {
--icon-color: var(--warning-color);
}
.status div.heading .icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color);
opacity: 0.2;
}
.status div.heading .icon ha-svg-icon {
color: var(--icon-color);
width: var(--ha-space-6);
height: var(--ha-space-6);
}
.status div.heading .details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.status small {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.25px;
color: var(--secondary-text-color);
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-one-line-container-height: 0;
--md-list-item-two-line-container-height: 0;
--md-list-item-three-line-container-height: 0;
}
ha-md-list-item.port:not(:first-child) {
border-top: 1px solid var(--divider-color);
margin-top: var(--ha-space-2);
--md-list-item-top-space: var(--ha-space-4);
}
ha-md-list-item .disconnected {
color: var(--disabled-text-color);
}
ha-md-list-item.consumer {
--md-list-item-leading-space: var(--ha-space-14);
}
ha-md-list-item.consumer img[slot="start"] {
width: 24px;
height: 24px;
}
.description {
padding: 0 var(--ha-space-4) var(--ha-space-2);
color: var(--secondary-text-color);
}
.empty {
padding: var(--ha-space-4);
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"serial-config-dashboard": SerialConfigDashboard;
}
}
@@ -0,0 +1,20 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { SerialPortUsage } from "../../../../../data/usb";
export interface SerialPortInfoDialogParams {
port: SerialPortUsage;
}
export const loadSerialPortInfoDialog = () =>
import("./dialog-serial-port-info");
export const showSerialPortInfoDialog = (
element: HTMLElement,
serialPortInfoDialogParams: SerialPortInfoDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-serial-port-info",
dialogImport: loadSerialPortInfoDialog,
dialogParams: serialPortInfoDialogParams,
});
};
@@ -675,6 +675,16 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
}
this._updateCollectionPeriod();
// "yesterday" is the only preset whose range never includes today, making
// it the only remembered default that keeps reopening in the past.
const storageKey = getEnergyDefaultPeriodStorageKey(
this.hass,
this.collectionKey
);
if (localStorage.getItem(storageKey) === "yesterday") {
localStorage.setItem(storageKey, "today");
}
}
private _pickPrevious() {
@@ -1,5 +1,6 @@
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import "../badges/hui-entity-badge";
import "../badges/hui-error-badge";
import {
createLovelaceElement,
getLovelaceElementClass,
@@ -1,5 +1,6 @@
import "../heading-badges/hui-button-heading-badge";
import "../heading-badges/hui-entity-heading-badge";
import "../heading-badges/hui-error-heading-badge";
import {
createLovelaceElement,
+37
View File
@@ -1615,6 +1615,7 @@
"thread": "[%key:ui::panel::config::dashboard::thread::main%]",
"bluetooth": "[%key:ui::panel::config::dashboard::bluetooth::main%]",
"radio_frequency": "[%key:ui::panel::config::dashboard::radio_frequency::main%]",
"serial": "[%key:ui::panel::config::dashboard::serial::main%]",
"knx": "[%key:ui::panel::config::dashboard::knx::main%]",
"insteon": "[%key:ui::panel::config::dashboard::insteon::main%]",
"voice-assistants": "[%key:ui::panel::config::dashboard::voice_assistants::main%]",
@@ -2754,6 +2755,10 @@
"main": "Radio frequency",
"secondary": "Control radio-based devices."
},
"serial": {
"main": "Serial",
"secondary": "Serial ports and the integrations and apps using them"
},
"knx": {
"main": "KNX",
"secondary": "Building automation standard"
@@ -7520,6 +7525,38 @@
"last_used": "Last used",
"devices_navigation": "Devices"
},
"serial": {
"title": "Serial",
"status_ok": "All ports connected",
"status_disconnected": "Ports disconnected",
"status_summary": "{in_use}/{total} connected ports in use",
"status_summary_disconnected": "{count} disconnected",
"connected": "Connected",
"connected_description": "These ports are used by at least one integration or app.",
"available": "Available",
"available_description": "These ports are connected but not used by any integration or app.",
"disconnected": "Disconnected",
"disconnected_description": "These ports are used by an integration or app but are currently not connected.",
"no_ports": "No serial ports found",
"loading_error": "Failed to load serial ports",
"serial_number": "S/N: {serial_number}",
"consumer_not_running": "{name} (not running)",
"can_be_used_with": "Can be used with {integrations}",
"discovered_by": "Discovered by {integration}, set it up",
"port_information": "Port information",
"fields": {
"device": "Device",
"resolved_device": "Resolved device",
"description": "Description",
"manufacturer": "Manufacturer",
"interface_description": "Interface description",
"interface_number": "Interface number",
"serial_number": "Serial number",
"vid": "Vendor ID",
"pid": "Product ID",
"bcd_device": "Device revision"
}
},
"dhcp": {
"title": "DHCP discovery",
"mac_address": "MAC address",
@@ -0,0 +1,154 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../../src/types";
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
import "../../../src/components/ha-selector/ha-selector-object";
vi.mock("../../../src/components/ha-input-helper-text", () => {
customElements.define("ha-input-helper-text", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-md-list", () => {
customElements.define("ha-md-list", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-md-list-item", () => {
customElements.define("ha-md-list-item", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-sortable", () => {
customElements.define("ha-sortable", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-yaml-editor", () => {
customElements.define("ha-yaml-editor", class extends HTMLElement {});
return {};
});
const hass = {
localize: (key: string) => key,
locale: "en-US",
floors: {},
areas: {},
devices: {},
states: {},
formatEntityName: () => "",
} as unknown as HomeAssistant;
const selectorConfig = {
object: {
multiple: true,
fields: {
name: { selector: { text: {} } },
},
},
};
const getInternals = (selector: HaObjectSelector) =>
selector as unknown as Record<string, unknown>;
const mountSelector = async (value: Record<string, string>[]) => {
const selector = document.createElement(
"ha-selector-object"
) as HaObjectSelector;
selector.hass = hass;
selector.selector = selectorConfig;
selector.value = value;
document.body.append(selector);
await selector.updateComplete;
return selector;
};
const resolveFormDialog = async (
selector: HaObjectSelector,
action: "_addItem" | "_editItem",
result: Record<string, string> | null,
item?: Record<string, string>,
index?: number
) => {
let params: FormDialogParams | undefined;
const dialogShown = new Promise<void>((resolve) => {
selector.addEventListener(
"show-dialog",
(event) => {
params = event.detail.dialogParams as FormDialogParams;
if (result === null) {
params.cancel!();
} else {
params.submit!(result);
}
resolve();
},
{ once: true }
);
});
const event = {
stopPropagation: vi.fn(),
currentTarget: { item, index },
};
const operation = (
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
)(event);
await dialogShown;
await operation;
return params!;
};
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("ha-selector-object form dialog flow", () => {
it("appends an item through the real Add flow", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", { name: "B" });
expect(valueChanged).toHaveBeenCalledWith(
expect.objectContaining({
detail: { value: [first, { name: "B" }] },
})
);
});
it("replaces an item at its original index through the real Edit flow", async () => {
const first = { name: "A" };
const second = { name: "B" };
const selector = await mountSelector([first, second]);
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(
selector,
"_editItem",
{ name: "B updated" },
second,
1
);
const value = valueChanged.mock.calls[0][0].detail.value;
expect(value).toEqual([first, { name: "B updated" }]);
expect(value).toHaveLength(2);
});
it("leaves the array unchanged when Add or Edit is canceled", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", null);
await resolveFormDialog(selector, "_editItem", null, first, 0);
expect(valueChanged).not.toHaveBeenCalled();
});
});
+370
View File
@@ -0,0 +1,370 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deepActiveElement } from "../../../src/common/dom/deep-active-element";
import type {
FormDialogData,
FormDialogParams,
} from "../../../src/dialogs/form/show-form-dialog";
import type { DialogForm } from "../../../src/dialogs/form/dialog-form";
import "../../../src/dialogs/form/dialog-form";
vi.mock("../../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-dialog", () => {
customElements.define(
"ha-dialog",
class extends HTMLElement {
public bodyContainer = document.createElement("div");
}
);
return {};
});
vi.mock("../../../src/components/ha-dialog-footer", () => {
customElements.define("ha-dialog-footer", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-form/ha-form", () => {
customElements.define(
"ha-form",
class extends HTMLElement {
public reportValidity = vi.fn(() => true);
}
);
return {};
});
const getInternals = (dialog: DialogForm) =>
dialog as unknown as Record<string, unknown>;
const getForms = (dialog: DialogForm): HTMLElement[] =>
Array.from(dialog.shadowRoot!.querySelectorAll("ha-form"));
const outerParams = (data: FormDialogData = {}): FormDialogParams => ({
title: "Outer",
schema: [{ name: "value", selector: { text: {} } }],
data,
submit: vi.fn(),
cancel: vi.fn(),
});
const nestedParams = (data: FormDialogData = {}): FormDialogParams => ({
title: "Nested",
schema: [{ name: "value", selector: { text: {} } }],
data,
submit: vi.fn(),
cancel: vi.fn(),
});
const hass = {
localize: (key: string) => key,
} as never;
const openDialog = async (params = outerParams()) => {
const dialog = document.createElement("dialog-form") as DialogForm;
dialog.hass = hass;
document.body.append(dialog);
await dialog.showDialog(params);
await dialog.updateComplete;
return dialog;
};
const showNestedDialog = async (
dialog: DialogForm,
form: Element,
params: FormDialogParams,
dialogTag = "dialog-form",
origin: Element = form
) => {
origin.dispatchEvent(
new CustomEvent("show-dialog", {
bubbles: true,
composed: true,
detail: { dialogTag, dialogParams: params },
})
);
await dialog.updateComplete;
};
const submit = (dialog: DialogForm) =>
(getInternals(dialog)["_submit"] as () => void)();
const cancel = (dialog: DialogForm) =>
(getInternals(dialog)["_cancel"] as () => void)();
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("dialog-form mounted nested forms", () => {
it("keeps parent forms mounted while nested", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nestedParams());
const forms = getForms(dialog);
expect(forms).toHaveLength(2);
expect(forms[0].hidden).toBe(true);
expect(forms[1].hidden).toBe(false);
expect(forms[0].hasAttribute("autofocus")).toBe(false);
expect(forms[1].hasAttribute("autofocus")).toBe(true);
});
it("returns to the parent after nested submit", async () => {
const dialog = await openDialog();
const nested = nestedParams({ value: "nested" });
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nested);
submit(dialog);
await dialog.updateComplete;
expect(nested.submit).toHaveBeenCalledWith({ value: "nested" });
expect(getInternals(dialog)["_open"]).toBe(true);
expect(getInternals(dialog)["_stack"]).toHaveLength(0);
expect(getForms(dialog)[0].hidden).toBe(false);
});
it.each(["submit", "cancel"] as const)(
"restores focus to the opener after nested %s",
async (action) => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const opener = document.createElement("button");
parent.append(opener);
opener.focus();
await showNestedDialog(dialog, parent, nestedParams());
const child = getForms(dialog)[1];
const childFocusTarget = document.createElement("button");
child.append(childFocusTarget);
childFocusTarget.focus();
expect(deepActiveElement()).toBe(childFocusTarget);
if (action === "submit") {
submit(dialog);
} else {
cancel(dialog);
}
await vi.waitUntil(() => deepActiveElement() === opener);
}
);
it("keeps the parent open after a custom object selector nested save", async () => {
const dialog = await openDialog();
const nested = {
...nestedParams({ items: [] }),
schema: [
{
name: "items",
selector: {
object: {
multiple: true,
fields: { name: { selector: { text: {} } } },
},
},
},
],
} satisfies FormDialogParams;
const descendant = document.createElement("div");
getForms(dialog)[0].append(descendant);
await showNestedDialog(
dialog,
getForms(dialog)[0],
nested,
"dialog-form",
descendant
);
submit(dialog);
await dialog.updateComplete;
expect(nested.submit).toHaveBeenCalledWith({ items: [] });
expect(getInternals(dialog)["_open"]).toBe(true);
expect(getInternals(dialog)["_stack"]).toHaveLength(0);
expect(getForms(dialog)[0].hidden).toBe(false);
});
it("returns to the parent after nested cancel", async () => {
const parentData = { value: "parent" };
const dialog = await openDialog(outerParams(parentData));
const nested = nestedParams({ value: "nested" });
await showNestedDialog(dialog, getForms(dialog)[0], nested);
cancel(dialog);
await dialog.updateComplete;
expect(nested.cancel).toHaveBeenCalledOnce();
expect(getInternals(dialog)["_open"]).toBe(true);
expect((getInternals(dialog)["_data"] as FormDialogData).value).toBe(
"parent"
);
expect(getForms(dialog)[0].hidden).toBe(false);
});
it("routes hidden parent value changes to its stack entry", async () => {
const dialog = await openDialog(outerParams({ value: "original" }));
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nestedParams());
parent.dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "updated" } },
})
);
expect(
(getInternals(dialog)["_stack"] as Record<string, unknown>[])[0].data
).toEqual({ value: "updated" });
cancel(dialog);
expect((getInternals(dialog)["_data"] as FormDialogData).value).toBe(
"updated"
);
});
it("keeps multiple nested levels mounted and pops them in order", async () => {
const dialog = await openDialog();
await showNestedDialog(dialog, getForms(dialog)[0], nestedParams());
await showNestedDialog(dialog, getForms(dialog)[1], nestedParams());
expect(getForms(dialog)).toHaveLength(3);
expect(getForms(dialog).map((form) => form.hidden)).toEqual([
true,
true,
false,
]);
cancel(dialog);
await dialog.updateComplete;
expect(getForms(dialog).map((form) => form.hidden)).toEqual([true, false]);
cancel(dialog);
await dialog.updateComplete;
expect(getForms(dialog).map((form) => form.hidden)).toEqual([false]);
});
it("accepts active show-dialog events only", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const nested = nestedParams();
await showNestedDialog(dialog, parent, nested);
const child = getForms(dialog)[1];
await showNestedDialog(dialog, parent, nestedParams());
expect(getInternals(dialog)["_stack"]).toHaveLength(1);
await showNestedDialog(dialog, child, nestedParams(), "not-dialog-form");
expect(getInternals(dialog)["_stack"]).toHaveLength(1);
});
it("cancels all pending levels when physically closed", async () => {
const cancelOrder: string[] = [];
const root = outerParams();
const nested = nestedParams();
const grandchild = nestedParams();
root.cancel = vi.fn(() => cancelOrder.push("root"));
nested.cancel = vi.fn(() => cancelOrder.push("nested"));
grandchild.cancel = vi.fn(() => cancelOrder.push("grandchild"));
const dialog = await openDialog(root);
getForms(dialog)[0].dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "dirty" } },
})
);
expect(dialog.isDirtyState).toBe(true);
await showNestedDialog(dialog, getForms(dialog)[0], nested);
await showNestedDialog(dialog, getForms(dialog)[1], grandchild);
(getInternals(dialog)["_dialogClosed"] as () => void)();
expect(cancelOrder).toEqual(["grandchild", "nested", "root"]);
expect(grandchild.cancel).toHaveBeenCalledOnce();
expect(nested.cancel).toHaveBeenCalledOnce();
expect(root.cancel).toHaveBeenCalledOnce();
expect(getInternals(dialog)["_stack"]).toHaveLength(0);
expect(getInternals(dialog)["_params"]).toBeUndefined();
expect(getInternals(dialog)["_data"]).toEqual({});
expect(getInternals(dialog)["_initialData"]).toEqual({});
expect(getInternals(dialog)["_open"]).toBe(false);
expect(dialog.isDirtyState).toBe(false);
});
it("tracks dirty state across nested levels", async () => {
const dialog = await openDialog();
expect(dialog.isDirtyState).toBe(false);
getForms(dialog)[0].dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "changed" } },
})
);
expect(dialog.isDirtyState).toBe(true);
await showNestedDialog(dialog, getForms(dialog)[0], nestedParams());
cancel(dialog);
expect(dialog.isDirtyState).toBe(true);
const cleanDialog = await openDialog();
getForms(cleanDialog)[0].dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "changed" } },
})
);
expect(cleanDialog.isDirtyState).toBe(true);
cancel(cleanDialog);
(getInternals(cleanDialog)["_dialogClosed"] as () => void)();
expect(cleanDialog.isDirtyState).toBe(false);
const cleanNestedDialog = await openDialog();
await showNestedDialog(
cleanNestedDialog,
getForms(cleanNestedDialog)[0],
nestedParams()
);
cancel(cleanNestedDialog);
expect(cleanNestedDialog.isDirtyState).toBe(false);
submit(cleanNestedDialog);
expect(cleanNestedDialog.isDirtyState).toBe(false);
submit(dialog);
expect(dialog.isDirtyState).toBe(false);
});
it("validates the active form before submitting", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const nested = nestedParams();
await showNestedDialog(dialog, parent, nested);
const child = getForms(dialog)[1];
const parentReportValidity = vi.fn(() => true);
const childReportValidity = vi.fn(() => false);
Object.defineProperty(parent, "reportValidity", {
value: parentReportValidity,
});
Object.defineProperty(child, "reportValidity", {
value: childReportValidity,
});
submit(dialog);
expect(parentReportValidity).not.toHaveBeenCalled();
expect(childReportValidity).toHaveBeenCalledOnce();
expect(nested.submit).not.toHaveBeenCalled();
expect(getInternals(dialog)["_open"]).toBe(true);
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import type { HuiErrorBadge } from "../../../../src/panels/lovelace/badges/hui-error-badge";
import { createBadgeElement } from "../../../../src/panels/lovelace/create-element/create-badge-element";
import { createHeadingBadgeElement } from "../../../../src/panels/lovelace/create-element/create-heading-badge-element";
import type { HuiErrorHeadingBadge } from "../../../../src/panels/lovelace/heading-badges/hui-error-heading-badge";
vi.hoisted(() => {
Object.assign(globalThis, {
__STATIC_PATH__: "/",
__HASS_URL__: "",
__BUILD__: "modern",
__VERSION__: "test",
__BACKWARDS_COMPAT__: false,
__SUPERVISOR__: false,
__NAMESPACE__: "frontend",
});
});
describe("error badge factories", () => {
it("creates a configured error badge on the first call", async () => {
const element = createBadgeElement({
type: "error",
error: "test error",
}) as HuiErrorBadge;
document.body.append(element);
await element.updateComplete;
expect(element.localName).toBe("hui-error-badge");
expect(element.shadowRoot?.textContent).toContain("test error");
element.remove();
});
it("creates a configured error heading badge on the first call", async () => {
const element = createHeadingBadgeElement({
type: "error",
error: "test heading error",
}) as HuiErrorHeadingBadge;
document.body.append(element);
await element.updateComplete;
expect(element.localName).toBe("hui-error-heading-badge");
expect(element.shadowRoot?.textContent).toContain("test heading error");
element.remove();
});
});
+71 -71
View File
@@ -6452,12 +6452,12 @@ __metadata:
languageName: node
linkType: hard
"@vitest/coverage-v8@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/coverage-v8@npm:4.1.10"
"@vitest/coverage-v8@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/coverage-v8@npm:4.1.11"
dependencies:
"@bcoe/v8-coverage": "npm:^1.0.2"
"@vitest/utils": "npm:4.1.10"
"@vitest/utils": "npm:4.1.11"
ast-v8-to-istanbul: "npm:^1.0.0"
istanbul-lib-coverage: "npm:^3.2.2"
istanbul-lib-report: "npm:^3.0.1"
@@ -6467,34 +6467,34 @@ __metadata:
std-env: "npm:^4.0.0-rc.1"
tinyrainbow: "npm:^3.1.0"
peerDependencies:
"@vitest/browser": 4.1.10
vitest: 4.1.10
"@vitest/browser": 4.1.11
vitest: 4.1.11
peerDependenciesMeta:
"@vitest/browser":
optional: true
checksum: 10/e593f5205a65d10f200e68a99e720d7a9f5e9be65d8160e4f0b6b5f1a7a87f0453c03e79fd1c022dbd7fb26a22657ca4d9410ae27b36da90d41d7ca04f681ab1
checksum: 10/b6171ec592e0017c3b10954a9400b10af0becf944a0533af01b002a4cc35b6e562a353b4837451a44b73c5561de358c23a2c135d4e1e79bc9041ebb241a68440
languageName: node
linkType: hard
"@vitest/expect@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/expect@npm:4.1.10"
"@vitest/expect@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/expect@npm:4.1.11"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
"@types/chai": "npm:^5.2.2"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
chai: "npm:^6.2.2"
tinyrainbow: "npm:^3.1.0"
checksum: 10/487fcad404a68968a54ae5fb9d099f12170cd793420a04b34a5606516317090c50a8303ab687c70166ee181864e3e138941d4a96d0405434dcd37696b3105350
checksum: 10/9bfcfe5ad926ab58beea1c700dc057f17422f14516506f8fc12c9881ed3e81d4c2faadb768042c8497fdee7007e201c1bd3e7d2e91157dbb47fb5c07c4c02aaa
languageName: node
linkType: hard
"@vitest/mocker@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/mocker@npm:4.1.10"
"@vitest/mocker@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/mocker@npm:4.1.11"
dependencies:
"@vitest/spy": "npm:4.1.10"
"@vitest/spy": "npm:4.1.11"
estree-walker: "npm:^3.0.3"
magic-string: "npm:^0.30.21"
peerDependencies:
@@ -6505,56 +6505,56 @@ __metadata:
optional: true
vite:
optional: true
checksum: 10/ae9645d1bcdad3ab7de7182feb4f1c9148a5ff97cef19581eec9257112aace94889eee9a1ad12e40ce59453ac05f52453b5fdb49ff76a31af8ccdbaaa4471ef3
checksum: 10/00b6e1266d8403194b49313e3a9a1af0dff2c773f4b2df11f4955fa0f244fd4b59484cd23381dfef8af30298e2651c1aaa42b439fdbc871bb4bb911de38a9509
languageName: node
linkType: hard
"@vitest/pretty-format@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/pretty-format@npm:4.1.10"
"@vitest/pretty-format@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/pretty-format@npm:4.1.11"
dependencies:
tinyrainbow: "npm:^3.1.0"
checksum: 10/e4f6907143ab0e40dda29d70b17027586c92921d622091321f10512e660b3995dcee7aa56e17b750b72560f295e25f96035372348415f18ebfd39b66a55b4704
checksum: 10/2dfc2f20dbe1c4dbea33ec42e85a8b5648aa6585521bea47573406f5cefb81cd3b86b71f981c4e3d69946e77252cb42a700416c1dc656bb0478cb2932c953cdc
languageName: node
linkType: hard
"@vitest/runner@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/runner@npm:4.1.10"
"@vitest/runner@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/runner@npm:4.1.11"
dependencies:
"@vitest/utils": "npm:4.1.10"
"@vitest/utils": "npm:4.1.11"
pathe: "npm:^2.0.3"
checksum: 10/2c962cb13af0880990036808a35679b7ac6657c8f542490234c2faa6ffd2ab080ac6bf21b487c64d84aa635cfb37b49eb679098c2003a100dfc6c4d5e87bf055
checksum: 10/5247df824fa28b458ba0102592dfec50707982193b62076db941fbe5d7c88fb7067e68a33c194db0092bbe35459cfbeaed33b3c667e5f02192c18baeb4f56239
languageName: node
linkType: hard
"@vitest/snapshot@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/snapshot@npm:4.1.10"
"@vitest/snapshot@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/snapshot@npm:4.1.11"
dependencies:
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3"
checksum: 10/7940d83ffd2fbebf9a04ea31e196b7e8bf981093ec739950959fe8dd29caa33c80823780fb4b1063d9459c44a0a8d8b2748c00dfb6941becd7404e6d687eea01
checksum: 10/5d096373fb4b102f65ff884844a18c2d2e7d88caf68a64a842a245573311b2d531ca5a94ebf7e4fe39324e71a78670f74c949d4ec2cad3764c3f4c272b84d982
languageName: node
linkType: hard
"@vitest/spy@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/spy@npm:4.1.10"
checksum: 10/7c1b79a95474338e0659f0f2e43be4df1ef7939ff5b37b044954e0287582947803bd417508f44a7f244809672309d9b3dd67660b704ec3fe7f323cc958ae47a3
"@vitest/spy@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/spy@npm:4.1.11"
checksum: 10/d49a7ed7501080e5f817d61250a169a46fcc7901887e4985a1e08705ce79aea8d1edcffd74f4dc6669ea1bc3d717a39354ce89c67188d81a63dc439d42f195f6
languageName: node
linkType: hard
"@vitest/utils@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/utils@npm:4.1.10"
"@vitest/utils@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/utils@npm:4.1.11"
dependencies:
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.11"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
checksum: 10/95484aad55c7b00bbcd4963e27cbb86fe207620a6093973d68da9d0a06bad37c388d84c9ab43d5f35d88e46c8f376a5592d9c54c025c958361160f4802bb25ee
checksum: 10/f05381e12d0926db7b01bfaae9a577fa664d36b96c23df185e4b0f6dad3a9fb59ac00931613da53b4511ee6ab473a14ac500c72c5ec5e9b3c3042875051f20c4
languageName: node
linkType: hard
@@ -8442,14 +8442,14 @@ __metadata:
languageName: node
linkType: hard
"echarts-extension-chart2music@npm:0.1.0":
version: 0.1.0
resolution: "echarts-extension-chart2music@npm:0.1.0"
"echarts-extension-chart2music@npm:0.1.1":
version: 0.1.1
resolution: "echarts-extension-chart2music@npm:0.1.1"
dependencies:
chart2music: "npm:^1.20.0"
peerDependencies:
echarts: ">=5.0.0 <7"
checksum: 10/55a82c770355228b2a899007e488e5b60171cbaf5d83363a298c4214d9e4a5df1afd1c7805495f42f2ad6edd7c062c8b366f1f8ba7b1a902f2db7eb47fbfcccf
checksum: 10/73b728977be42188b48fd2590021a85a948a17a05b3de86438f5344835d2c22eefbab64892fa4cf8fbb8b08890d1aff8c631a9b316043d0250f619d3873912d4
languageName: node
linkType: hard
@@ -9957,10 +9957,10 @@ __metadata:
languageName: node
linkType: hard
"hls.js@npm:1.7.0":
version: 1.7.0
resolution: "hls.js@npm:1.7.0"
checksum: 10/8bb97ba86b72a3be057bd0036b01df83adfe7dc10c76a5e177b24bae3f380e19d4180e061d8140b3b826e10c9f61a7b230e10019dcb81619ab2fc41e4d292ed9
"hls.js@npm:1.7.1":
version: 1.7.1
resolution: "hls.js@npm:1.7.1"
checksum: 10/5bf1c5ba1cbb82a4274f55afe62c361137bad7a60e6c7a3cfd881ca47a6883357b660c080982a7393c840bcd710c6ca97ad12094a2db72d0c0dbe1cc9e48edb1
languageName: node
linkType: hard
@@ -10047,7 +10047,7 @@ __metadata:
"@types/tar": "npm:7.0.87"
"@typescript/native": "npm:[email protected]"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.10"
"@vitest/coverage-v8": "npm:4.1.11"
"@vvo/tzdb": "npm:6.198.0"
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
"@webcomponents/webcomponentsjs": "npm:2.8.0"
@@ -10068,7 +10068,7 @@ __metadata:
del: "npm:8.0.1"
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.8.1"
eslint-config-prettier: "npm:10.1.8"
@@ -10087,7 +10087,7 @@ __metadata:
gulp: "npm:5.0.1"
gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.1.0"
hls.js: "npm:1.7.0"
hls.js: "npm:1.7.1"
home-assistant-js-websocket: "npm:9.6.0"
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"
@@ -10134,7 +10134,7 @@ __metadata:
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.67.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.10"
vitest: "npm:4.1.11"
webpack-stats-plugin: "npm:1.1.3"
webpackbar: "npm:7.0.0"
weekstart: "npm:2.0.0"
@@ -15470,17 +15470,17 @@ __metadata:
languageName: node
linkType: hard
"vitest@npm:4.1.10":
version: 4.1.10
resolution: "vitest@npm:4.1.10"
"vitest@npm:4.1.11":
version: 4.1.11
resolution: "vitest@npm:4.1.11"
dependencies:
"@vitest/expect": "npm:4.1.10"
"@vitest/mocker": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/runner": "npm:4.1.10"
"@vitest/snapshot": "npm:4.1.10"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
"@vitest/expect": "npm:4.1.11"
"@vitest/mocker": "npm:4.1.11"
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/runner": "npm:4.1.11"
"@vitest/snapshot": "npm:4.1.11"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
es-module-lexer: "npm:^2.0.0"
expect-type: "npm:^1.3.0"
magic-string: "npm:^0.30.21"
@@ -15498,12 +15498,12 @@ __metadata:
"@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 4.1.10
"@vitest/browser-preview": 4.1.10
"@vitest/browser-webdriverio": 4.1.10
"@vitest/coverage-istanbul": 4.1.10
"@vitest/coverage-v8": 4.1.10
"@vitest/ui": 4.1.10
"@vitest/browser-playwright": 4.1.11
"@vitest/browser-preview": 4.1.11
"@vitest/browser-webdriverio": 4.1.11
"@vitest/coverage-istanbul": 4.1.11
"@vitest/coverage-v8": 4.1.11
"@vitest/ui": 4.1.11
happy-dom: "*"
jsdom: "*"
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -15534,7 +15534,7 @@ __metadata:
optional: false
bin:
vitest: ./vitest.mjs
checksum: 10/020843460fe696c23be2a363634dde4daf54625f1c443c24066ba3f87c478b0ccfdd5124343ba30eb092f54902ffea09f4bed0af4a16a9ee805e494ee2dce34e
checksum: 10/054f1e25d90d911693b0b93c5b85a7c3105775aa3e39c3279c7d3e7af719e2d94070a898d6d74292445366c1215bb91d07063989ac0965973f0c5ae22ad3b06b
languageName: node
linkType: hard