mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-30 02:07:29 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5c4986afa | |||
| ae8d9667d2 | |||
| 60148ea54e |
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
"app": 561513,
|
||||
"core": 54473,
|
||||
"authorize": 544272,
|
||||
"onboarding": 647136
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
"app": 790323,
|
||||
"core": 237208,
|
||||
"authorize": 765464,
|
||||
"onboarding": 918679
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,42 +12,18 @@
|
||||
const remapping = require("@ampproject/remapping");
|
||||
|
||||
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
|
||||
// Also map to cache loader promises per environment (e.g., 'modern', 'legacy')
|
||||
const loaderInitPromises = new Map();
|
||||
const initLoader = (browserslistEnv) => {
|
||||
if (!loaderInitPromises.has(browserslistEnv)) {
|
||||
loaderInitPromises.set(
|
||||
browserslistEnv,
|
||||
Promise.all([
|
||||
import("minify-literals"),
|
||||
import("browserslist"),
|
||||
import("lightningcss"),
|
||||
]).then(([minifyModule, browserslistModule, lightningcssModule]) => {
|
||||
const browserslist = browserslistModule.default;
|
||||
const { browserslistToTargets } = lightningcssModule;
|
||||
const rawTargets = browserslist(null, { env: browserslistEnv });
|
||||
if (!rawTargets.length) {
|
||||
throw new Error(
|
||||
`No browsers resolved for browserslist environment "${browserslistEnv}"`
|
||||
);
|
||||
}
|
||||
const lightningcssTargets = browserslistToTargets(rawTargets);
|
||||
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
};
|
||||
})
|
||||
);
|
||||
let minifyPromise;
|
||||
const getMinifier = () => {
|
||||
if (!minifyPromise) {
|
||||
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
|
||||
}
|
||||
return loaderInitPromises.get(browserslistEnv);
|
||||
return minifyPromise;
|
||||
};
|
||||
|
||||
// HTML options mirror the previous babel-plugin-template-html-minifier config
|
||||
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
|
||||
// css`` templates and inline <style> is handled by minify-literals'
|
||||
// lightningcss. We pass in the targets from browserslist so lightningcss
|
||||
// can minify CSS appropriately for the build environment.
|
||||
// css`` templates and inline <style> is handled by minify-literals' lightningcss
|
||||
// default.
|
||||
//
|
||||
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
|
||||
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
|
||||
@@ -64,16 +40,11 @@ const htmlOptions = {
|
||||
|
||||
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const callback = this.async();
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
getMinifier()
|
||||
.then((minifyHTMLLiterals) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then((result) => {
|
||||
|
||||
@@ -96,11 +96,6 @@ const createRspackConfig = ({
|
||||
__dirname,
|
||||
"minify-template-literals-loader.cjs"
|
||||
),
|
||||
options: {
|
||||
browserslistEnv: latestBuild
|
||||
? "modern"
|
||||
: `legacy${info.issuerLayer === "sw" ? "-sw" : ""}`,
|
||||
},
|
||||
},
|
||||
!latestBuild &&
|
||||
info.resource.startsWith(
|
||||
|
||||
+3
-3
@@ -186,7 +186,7 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.8.0",
|
||||
"globals": "17.7.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
@@ -224,12 +224,12 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.8.0",
|
||||
"globals": "17.7.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
"node": "24.18.1"
|
||||
"node": "24.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260729.0"
|
||||
version = "20260624.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -1,29 +1,8 @@
|
||||
let supported: boolean | undefined;
|
||||
|
||||
const detect = (): boolean => {
|
||||
if (
|
||||
!globalThis.ElementInternals ||
|
||||
!globalThis.HTMLElement?.prototype.attachInternals
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Native internals keep their WebIDL brand even when `attachInternals` is
|
||||
// wrapped (e.g. by `@webcomponents/scoped-custom-element-registry`, which
|
||||
// broke the previous `[native code]` source check in the app bundle).
|
||||
// `element-internals-polyfill` swaps in a plain class, which has no brand,
|
||||
// and must not count as native: login on legacy browsers relies on
|
||||
// validation being skipped there (#51338).
|
||||
return (
|
||||
Object.prototype.toString.call(globalThis.ElementInternals.prototype) ===
|
||||
"[object ElementInternals]"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Indicates whether the current browser has native ElementInternals support.
|
||||
* Probed on first use so importing this module has no side effects.
|
||||
*/
|
||||
export const supportsNativeElementInternals = (): boolean => {
|
||||
supported ??= detect();
|
||||
return supported;
|
||||
};
|
||||
export const nativeElementInternalsSupported =
|
||||
Boolean(globalThis.ElementInternals) &&
|
||||
globalThis.HTMLElement?.prototype.attachInternals
|
||||
?.toString()
|
||||
.includes("[native code]");
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
|
||||
/**
|
||||
* Checks that a path resolves to the origin the frontend is served from, so it
|
||||
* is safe to use as a link target. Rejects URIs that carry their own scheme,
|
||||
* like `javascript:`, and URLs pointing at another origin. Resolves against the
|
||||
* main window, because that is where `navigate()` applies the path.
|
||||
*/
|
||||
const isSameOriginPath = (path: string): boolean => {
|
||||
try {
|
||||
const { origin } = mainWindow.location;
|
||||
return new URL(path, origin).origin === origin;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the path if it is safe to navigate to, `undefined` otherwise. Use for
|
||||
* paths that can be influenced by a URL parameter or by dashboard config before
|
||||
* they end up in an `href` or in `navigate()`.
|
||||
*/
|
||||
export const sanitizeNavigationPath = (
|
||||
path: string | null | undefined
|
||||
): string | undefined =>
|
||||
path != null && isSameOriginPath(path) ? path : undefined;
|
||||
@@ -6,11 +6,8 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../../dialogs/make-dialog-manager";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-dialog";
|
||||
import "../ha-svg-icon";
|
||||
import "../item/ha-list-item-button";
|
||||
@@ -26,21 +23,11 @@ export class DialogDeviceReplaced
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
private async _loadConfigEntries(): Promise<void> {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
configEntries.map((entry) => [entry.entry_id, entry])
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -68,23 +55,15 @@ export class DialogDeviceReplaced
|
||||
candidates: string[],
|
||||
primaryId: string | null,
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
configEntryLookup: Record<string, ConfigEntry> | undefined
|
||||
areas: HomeAssistant["areas"]
|
||||
) =>
|
||||
candidates.map((deviceId) => {
|
||||
const device = devices[deviceId];
|
||||
const area = device ? getDeviceArea(device, areas) : undefined;
|
||||
const configEntry = device?.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
: undefined;
|
||||
return {
|
||||
deviceId,
|
||||
name: device ? computeDeviceName(device) : deviceId,
|
||||
area: area ? computeAreaName(area) : undefined,
|
||||
domain: configEntry?.domain,
|
||||
domainName: configEntry
|
||||
? domainToName(this.hass.localize, configEntry.domain)
|
||||
: undefined,
|
||||
secondary: area ? computeAreaName(area) : undefined,
|
||||
isPrimary: deviceId === primaryId,
|
||||
};
|
||||
})
|
||||
@@ -113,54 +92,31 @@ export class DialogDeviceReplaced
|
||||
this._params.candidates,
|
||||
this._params.primaryId,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this._configEntryLookup
|
||||
).map((item) => {
|
||||
const supportingText = [
|
||||
item.area,
|
||||
item.domainName,
|
||||
item.isPrimary
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.recommended"
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
return html`
|
||||
this.hass.areas
|
||||
).map(
|
||||
(item) => html`
|
||||
<ha-list-item-button .deviceId=${item.deviceId}>
|
||||
${
|
||||
item.domain
|
||||
? html`<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiDevices}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<span slot="headline">${item.name}</span>
|
||||
${
|
||||
supportingText
|
||||
? html`<span slot="supporting-text"
|
||||
>${supportingText}</span
|
||||
>`
|
||||
item.secondary || item.isPrimary
|
||||
? html`<span slot="supporting-text">
|
||||
${[
|
||||
item.secondary,
|
||||
item.isPrimary
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.recommended"
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ")}
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
})}
|
||||
`
|
||||
)}
|
||||
</ha-list-base>
|
||||
</ha-dialog>
|
||||
`;
|
||||
@@ -176,10 +132,6 @@ export class DialogDeviceReplaced
|
||||
padding: 0 var(--ha-space-6) var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { subscribeLabFeature } from "../data/labs";
|
||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
@@ -82,14 +81,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
|
||||
class="snowflake ${
|
||||
this.narrow && flake.id >= 30 ? "hide-narrow" : ""
|
||||
}"
|
||||
style=${styleMap({
|
||||
left: `${flake.left}%`,
|
||||
width: `${flake.size}px`,
|
||||
height: `${flake.size}px`,
|
||||
"animation-duration": `${flake.duration}s`,
|
||||
"animation-delay": `${flake.delay}s`,
|
||||
"--rotation": `${flake.rotation}deg`,
|
||||
})}
|
||||
style="
|
||||
left: ${flake.left}%;
|
||||
width: ${flake.size}px;
|
||||
height: ${flake.size}px;
|
||||
animation-duration: ${flake.duration}s;
|
||||
animation-delay: ${flake.delay}s;
|
||||
--rotation: ${flake.rotation}deg;
|
||||
"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type LitElement, css } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { supportsNativeElementInternals } from "../../common/feature-detect/support-native-element-internals";
|
||||
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
|
||||
import type { Constructor } from "../../types";
|
||||
|
||||
/**
|
||||
@@ -198,7 +198,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
|
||||
}
|
||||
|
||||
public checkValidity(): boolean {
|
||||
return supportsNativeElementInternals()
|
||||
return nativeElementInternalsSupported
|
||||
? (this._formControl?.checkValidity() ?? true)
|
||||
: true;
|
||||
}
|
||||
@@ -211,7 +211,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
|
||||
|
||||
protected _handleInput(): void {
|
||||
this.value = this._formControl?.value ?? undefined;
|
||||
if (this._invalid && this.checkValidity()) {
|
||||
if (this._invalid && this._formControl?.checkValidity()) {
|
||||
this._invalid = false;
|
||||
}
|
||||
}
|
||||
@@ -222,16 +222,12 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
|
||||
|
||||
protected _handleBlur(): void {
|
||||
if (this.autoValidate) {
|
||||
this._invalid = !this.checkValidity();
|
||||
this._invalid = !this._formControl?.checkValidity();
|
||||
}
|
||||
}
|
||||
|
||||
protected _handleInvalid(): void {
|
||||
// Polyfilled internals dispatch `invalid` themselves, so only trust the
|
||||
// event when validity comes from the platform (#51338).
|
||||
if (supportsNativeElementInternals()) {
|
||||
this._invalid = true;
|
||||
}
|
||||
this._invalid = true;
|
||||
}
|
||||
|
||||
protected _renderLabel = memoizeOne((label: string, required: boolean) => {
|
||||
|
||||
@@ -15,6 +15,10 @@ export class HaTileContainer extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public vertical = false;
|
||||
|
||||
/* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */
|
||||
@property({ type: Boolean, attribute: "fixed-info-height" })
|
||||
public fixedInfoHeight = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public interactive = false;
|
||||
|
||||
@@ -34,7 +38,10 @@ export class HaTileContainer extends LitElement {
|
||||
protected render() {
|
||||
const containerOrientationClass =
|
||||
this.featurePosition === "inline" ? "horizontal" : "";
|
||||
const contentClasses = { vertical: this.vertical };
|
||||
const contentClasses = {
|
||||
vertical: this.vertical,
|
||||
"fixed-info-height": this.fixedInfoHeight,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -112,7 +119,15 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
padding: 10px var(--ha-space-2);
|
||||
}
|
||||
.vertical.fixed-info-height {
|
||||
/* pin sizing so every tile in a grid reserves the same height, wrapping or not, secondary or not */
|
||||
gap: 2px;
|
||||
--ha-tile-info-gap: 2px;
|
||||
--ha-tile-info-primary-line-height: var(--ha-space-4);
|
||||
--ha-tile-info-primary-min-height: var(--ha-space-8);
|
||||
--ha-tile-info-min-height: var(--ha-space-12);
|
||||
}
|
||||
.vertical ::slotted([slot="info"]) {
|
||||
width: 100%;
|
||||
|
||||
@@ -15,9 +15,13 @@ import { customElement, property } from "lit/decorators";
|
||||
*
|
||||
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
|
||||
*
|
||||
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
|
||||
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
|
||||
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of `--ha-tile-info-primary-line-clamp`. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
|
||||
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
|
||||
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
|
||||
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
|
||||
* @cssprop --ha-tile-info-primary-line-clamp - The maximum number of lines for the primary text before truncating with an ellipsis. defaults to `1`.
|
||||
* @cssprop --ha-tile-info-primary-letter-spacing - The letter spacing of the primary text. defaults to `0.1px`.
|
||||
* @cssprop --ha-tile-info-primary-color - The color of the primary text. defaults to `var(--primary-text-color)`.
|
||||
* @cssprop --ha-tile-info-secondary-font-size - The font size of the secondary text. defaults to `var(--ha-font-size-s)`.
|
||||
@@ -59,6 +63,8 @@ export class HaTileInfo extends LitElement {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
--tile-info-gap: var(--ha-tile-info-gap, 0);
|
||||
--tile-info-min-height: var(--ha-tile-info-min-height, auto);
|
||||
--tile-info-primary-font-size: var(
|
||||
--ha-tile-info-primary-font-size,
|
||||
var(--ha-font-size-m)
|
||||
@@ -71,6 +77,11 @@ export class HaTileInfo extends LitElement {
|
||||
--ha-tile-info-primary-line-height,
|
||||
var(--ha-line-height-normal)
|
||||
);
|
||||
--tile-info-primary-line-clamp: var(--ha-tile-info-primary-line-clamp, 1);
|
||||
--tile-info-primary-min-height: var(
|
||||
--ha-tile-info-primary-min-height,
|
||||
auto
|
||||
);
|
||||
--tile-info-primary-letter-spacing: var(
|
||||
--ha-tile-info-primary-letter-spacing,
|
||||
0.1px
|
||||
@@ -106,28 +117,45 @@ export class HaTileInfo extends LitElement {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--tile-info-gap);
|
||||
min-height: var(--tile-info-min-height);
|
||||
}
|
||||
span,
|
||||
::slotted(*) {
|
||||
.secondary span,
|
||||
::slotted([slot="secondary"]) {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.primary span,
|
||||
::slotted([slot="primary"]) {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--tile-info-primary-line-clamp);
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
width: 100%;
|
||||
}
|
||||
.primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--tile-info-primary-font-size);
|
||||
font-weight: var(--tile-info-primary-font-weight);
|
||||
line-height: var(--tile-info-primary-line-height);
|
||||
letter-spacing: var(--tile-info-primary-letter-spacing);
|
||||
color: var(--tile-info-primary-color);
|
||||
min-height: var(--tile-info-primary-min-height);
|
||||
}
|
||||
.secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--tile-info-secondary-font-size);
|
||||
font-weight: var(--tile-info-secondary-font-weight);
|
||||
line-height: var(--tile-info-secondary-line-height);
|
||||
letter-spacing: var(--tile-info-secondary-letter-spacing);
|
||||
color: var(--tile-info-secondary-color);
|
||||
width: 100%;
|
||||
}
|
||||
.placeholder {
|
||||
width: 140px;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
@@ -30,18 +29,16 @@ class HassSubpage extends LitElement {
|
||||
@restoreScroll(".content") private _savedScrollPos?: number;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const backPath = sanitizeNavigationPath(this.backPath);
|
||||
|
||||
return html`
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || history.state?.root
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: backPath
|
||||
: this.backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
href=${backPath}
|
||||
href=${this.backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
|
||||
@@ -15,7 +15,6 @@ import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import "../components/ha-svg-icon";
|
||||
@@ -165,19 +164,17 @@ export class HassTabsSubpage extends LitElement {
|
||||
this._narrow,
|
||||
this.localizeFunc || this.hass.localize
|
||||
);
|
||||
const backPath = sanitizeNavigationPath(this.backPath);
|
||||
|
||||
return html`
|
||||
<div class="toolbar ${classMap({ narrow: this._narrow })}">
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || (!backPath && history.state?.root)
|
||||
this.mainPage || (!this.backPath && history.state?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: backPath
|
||||
: this.backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
.href=${this.backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
|
||||
@@ -346,19 +346,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
maxWidth: "82px",
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: narrow,
|
||||
type: "overflow",
|
||||
title: this.hass.localize("ui.panel.config.automation.picker.state"),
|
||||
template: (automation) =>
|
||||
narrow
|
||||
? automation.formatted_state
|
||||
: html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
template: (automation) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-button";
|
||||
@@ -29,11 +29,10 @@ import "./ha-energy-power-config";
|
||||
import {
|
||||
buildPowerExcludeList,
|
||||
getInitialPowerConfig,
|
||||
getPowerHelperEntityId,
|
||||
getPowerTypeFromConfig,
|
||||
isPowerConfigValid,
|
||||
type HaEnergyPowerConfig,
|
||||
type PowerType,
|
||||
} from "./power-config";
|
||||
} from "./ha-energy-power-config";
|
||||
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
|
||||
@@ -68,6 +67,8 @@ export class DialogEnergyBatterySettings
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
@@ -228,10 +229,6 @@ export class DialogEnergyBatterySettings
|
||||
.powerType=${this._powerType}
|
||||
.powerConfig=${this._powerConfig}
|
||||
.excludeList=${this._excludeListPower}
|
||||
.helperEntityId=${getPowerHelperEntityId(
|
||||
this._params.source,
|
||||
this._powerConfig
|
||||
)}
|
||||
.localizeBaseKey=${"ui.panel.config.energy.battery.dialog"}
|
||||
@power-config-changed=${this._handlePowerConfigChanged}
|
||||
></ha-energy-power-config>
|
||||
@@ -298,7 +295,11 @@ export class DialogEnergyBatterySettings
|
||||
}
|
||||
|
||||
// Check power config validity
|
||||
return isPowerConfigValid(this._powerType, this._powerConfig);
|
||||
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _updateMetadata(statId: string) {
|
||||
@@ -373,7 +374,6 @@ export class DialogEnergyBatterySettings
|
||||
if (this._source.capacity === undefined) {
|
||||
delete this._source.capacity;
|
||||
}
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-entity-picker";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
@@ -33,11 +33,10 @@ import "./ha-energy-power-config";
|
||||
import {
|
||||
buildPowerExcludeList,
|
||||
getInitialPowerConfig,
|
||||
getPowerHelperEntityId,
|
||||
getPowerTypeFromConfig,
|
||||
isPowerConfigValid,
|
||||
type HaEnergyPowerConfig,
|
||||
type PowerType,
|
||||
} from "./power-config";
|
||||
} from "./ha-energy-power-config";
|
||||
import type { EnergySettingsGridDialogParams } from "./show-dialogs-energy";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
|
||||
@@ -78,6 +77,8 @@ export class DialogEnergyGridSettings
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
@@ -470,10 +471,6 @@ export class DialogEnergyGridSettings
|
||||
.powerType=${this._powerType}
|
||||
.powerConfig=${this._powerConfig}
|
||||
.excludeList=${this._excludeListPower}
|
||||
.helperEntityId=${getPowerHelperEntityId(
|
||||
this._params.source,
|
||||
this._powerConfig
|
||||
)}
|
||||
.localizeBaseKey=${"ui.panel.config.energy.grid.dialog"}
|
||||
@power-config-changed=${this._handlePowerConfigChanged}
|
||||
></ha-energy-power-config>
|
||||
@@ -511,8 +508,10 @@ export class DialogEnergyGridSettings
|
||||
}
|
||||
|
||||
// Check power config validity (if power is configured)
|
||||
if (hasPower && !isPowerConfigValid(this._powerType, this._powerConfig)) {
|
||||
return false;
|
||||
if (hasPower) {
|
||||
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,24 +1,99 @@
|
||||
import { mdiInformationOutline } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/radio/ha-radio-option";
|
||||
import type { PowerConfig } from "../../../../data/energy";
|
||||
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
|
||||
import { buttonLinkStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { PowerType } from "./power-config";
|
||||
|
||||
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
|
||||
|
||||
const powerUnitClasses = ["power"];
|
||||
|
||||
/**
|
||||
* Extracts the power type from a PowerConfig object.
|
||||
*/
|
||||
export function getPowerTypeFromConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerType {
|
||||
if (powerConfig) {
|
||||
if (powerConfig.stat_rate_inverted) {
|
||||
return "inverted";
|
||||
}
|
||||
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
|
||||
return "two_sensors";
|
||||
}
|
||||
if (powerConfig.stat_rate) {
|
||||
return "standard";
|
||||
}
|
||||
} else if (statRate) {
|
||||
// Legacy format - treat as standard
|
||||
return "standard";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an initial PowerConfig from existing config or legacy stat_rate.
|
||||
*/
|
||||
export function getInitialPowerConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerConfig {
|
||||
if (powerConfig) {
|
||||
return { ...powerConfig };
|
||||
}
|
||||
if (statRate) {
|
||||
return { stat_rate: statRate };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an exclude list for power statistics from existing sources.
|
||||
*/
|
||||
export function buildPowerExcludeList(
|
||||
sources: { stat_rate?: string; power_config?: PowerConfig }[],
|
||||
currentPowerConfig: PowerConfig,
|
||||
currentStatRate?: string
|
||||
): string[] {
|
||||
const powerIds: string[] = [];
|
||||
|
||||
sources.forEach((entry) => {
|
||||
if (entry.stat_rate) powerIds.push(entry.stat_rate);
|
||||
if (entry.power_config) {
|
||||
if (entry.power_config.stat_rate) {
|
||||
powerIds.push(entry.power_config.stat_rate);
|
||||
}
|
||||
if (entry.power_config.stat_rate_inverted) {
|
||||
powerIds.push(entry.power_config.stat_rate_inverted);
|
||||
}
|
||||
if (entry.power_config.stat_rate_from) {
|
||||
powerIds.push(entry.power_config.stat_rate_from);
|
||||
}
|
||||
if (entry.power_config.stat_rate_to) {
|
||||
powerIds.push(entry.power_config.stat_rate_to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentPowerIds = [
|
||||
currentPowerConfig.stat_rate,
|
||||
currentPowerConfig.stat_rate_inverted,
|
||||
currentPowerConfig.stat_rate_from,
|
||||
currentPowerConfig.stat_rate_to,
|
||||
currentStatRate,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return powerIds.filter((id) => !currentPowerIds.includes(id));
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"power-config-changed": { powerType: PowerType; powerConfig: PowerConfig };
|
||||
@@ -35,14 +110,10 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public excludeList?: string[];
|
||||
|
||||
/** Entity id of the power sensor generated for the saved config, if any. */
|
||||
@property({ attribute: false }) public helperEntityId?: string;
|
||||
|
||||
/**
|
||||
* Base key for localization lookups.
|
||||
* Should include keys for: sensor_type, sensor_type_para, type_none, type_standard,
|
||||
* type_inverted, type_two_sensors, power, power_helper, type_inverted_description,
|
||||
* power_from, power_to, helper_sensor_note, helper_sensor_in_use
|
||||
* Should include keys for: sensor_type, type_none, type_standard, type_inverted,
|
||||
* type_two_sensors, power, power_helper, type_inverted_description, power_from, power_to
|
||||
*/
|
||||
@property({ attribute: false }) public localizeBaseKey =
|
||||
"ui.panel.config.energy.battery.dialog";
|
||||
@@ -93,13 +164,11 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
|
||||
)}
|
||||
${this._renderHelperSensorNote("inverted")}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="two_sensors">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
|
||||
)}
|
||||
${this._renderHelperSensorNote("two_sensors")}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
|
||||
@@ -174,50 +243,9 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._renderHelperSensorInUse()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelperSensorNote(powerType: PowerType) {
|
||||
const id = `helper-sensor-note-${powerType}`;
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
id=${id}
|
||||
tabindex="0"
|
||||
class="note-icon"
|
||||
.path=${mdiInformationOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip .for=${id} placement="top">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.helper_sensor_note` as LocalizeKeys
|
||||
)}
|
||||
</ha-tooltip>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelperSensorInUse() {
|
||||
if (!this.helperEntityId) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<p class="helper-sensor-in-use">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.helper_sensor_in_use` as LocalizeKeys,
|
||||
{
|
||||
entity: html`<button class="link" @click=${this._showHelperSensor}>
|
||||
${this.helperEntityId}
|
||||
</button>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
private _showHelperSensor() {
|
||||
fireEvent(this, "hass-more-info", { entityId: this.helperEntityId! });
|
||||
}
|
||||
|
||||
private _handlePowerTypeChanged(ev: Event) {
|
||||
const newPowerType = (ev.currentTarget as HaRadioGroup).value as PowerType;
|
||||
// Clear power config when switching types
|
||||
@@ -261,46 +289,48 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.power-section-label {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.power-section-description {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.note-icon {
|
||||
margin-inline-start: var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.helper-sensor-in-use {
|
||||
margin: var(--ha-space-2) 0 0 0;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.helper-sensor-in-use button.link {
|
||||
color: var(--primary-color);
|
||||
/* entity ids offer no break opportunities of their own */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`,
|
||||
];
|
||||
/**
|
||||
* Validates that the power config is complete for the selected type.
|
||||
*/
|
||||
public isValid(): boolean {
|
||||
switch (this.powerType) {
|
||||
case "none":
|
||||
return true;
|
||||
case "standard":
|
||||
return !!this.powerConfig.stat_rate;
|
||||
case "inverted":
|
||||
return !!this.powerConfig.stat_rate_inverted;
|
||||
case "two_sensors":
|
||||
return (
|
||||
!!this.powerConfig.stat_rate_from && !!this.powerConfig.stat_rate_to
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.power-section-label {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.power-section-description {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { PowerConfig } from "../../../../data/energy";
|
||||
import { deepEqual } from "../../../../common/util/deep-equal";
|
||||
|
||||
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
|
||||
|
||||
/**
|
||||
* Extracts the power type from a PowerConfig object.
|
||||
*/
|
||||
export function getPowerTypeFromConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerType {
|
||||
if (powerConfig) {
|
||||
if (powerConfig.stat_rate_inverted) {
|
||||
return "inverted";
|
||||
}
|
||||
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
|
||||
return "two_sensors";
|
||||
}
|
||||
if (powerConfig.stat_rate) {
|
||||
return "standard";
|
||||
}
|
||||
} else if (statRate) {
|
||||
// Legacy format - treat as standard
|
||||
return "standard";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an initial PowerConfig from existing config or legacy stat_rate.
|
||||
*/
|
||||
export function getInitialPowerConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerConfig {
|
||||
if (powerConfig) {
|
||||
return { ...powerConfig };
|
||||
}
|
||||
if (statRate) {
|
||||
return { stat_rate: statRate };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the power config is complete for the selected power type.
|
||||
*/
|
||||
export function isPowerConfigValid(
|
||||
powerType: PowerType,
|
||||
powerConfig: PowerConfig
|
||||
): boolean {
|
||||
switch (powerType) {
|
||||
case "none":
|
||||
return true;
|
||||
case "standard":
|
||||
return !!powerConfig.stat_rate;
|
||||
case "inverted":
|
||||
return !!powerConfig.stat_rate_inverted;
|
||||
case "two_sensors":
|
||||
return !!powerConfig.stat_rate_from && !!powerConfig.stat_rate_to;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an exclude list for power statistics from existing sources.
|
||||
*/
|
||||
export function buildPowerExcludeList(
|
||||
sources: { stat_rate?: string; power_config?: PowerConfig }[],
|
||||
currentPowerConfig: PowerConfig,
|
||||
currentStatRate?: string
|
||||
): string[] {
|
||||
const powerIds: string[] = [];
|
||||
|
||||
sources.forEach((entry) => {
|
||||
if (entry.stat_rate) powerIds.push(entry.stat_rate);
|
||||
if (entry.power_config) {
|
||||
if (entry.power_config.stat_rate) {
|
||||
powerIds.push(entry.power_config.stat_rate);
|
||||
}
|
||||
if (entry.power_config.stat_rate_inverted) {
|
||||
powerIds.push(entry.power_config.stat_rate_inverted);
|
||||
}
|
||||
if (entry.power_config.stat_rate_from) {
|
||||
powerIds.push(entry.power_config.stat_rate_from);
|
||||
}
|
||||
if (entry.power_config.stat_rate_to) {
|
||||
powerIds.push(entry.power_config.stat_rate_to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentPowerIds = [
|
||||
currentPowerConfig.stat_rate,
|
||||
currentPowerConfig.stat_rate_inverted,
|
||||
currentPowerConfig.stat_rate_from,
|
||||
currentPowerConfig.stat_rate_to,
|
||||
currentStatRate,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return powerIds.filter((id) => !currentPowerIds.includes(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entity id of the power sensor the backend generated for a saved
|
||||
* source, if any. Inverted and two sensor configs get such a helper, its entity
|
||||
* id is stored in `stat_rate`. Returns nothing while the config differs from the
|
||||
* saved one, as the helper doesn't match the edited config yet.
|
||||
*/
|
||||
export function getPowerHelperEntityId(
|
||||
source: { stat_rate?: string; power_config?: PowerConfig } | undefined,
|
||||
currentPowerConfig: PowerConfig
|
||||
): string | undefined {
|
||||
if (!source?.stat_rate || !source.power_config) {
|
||||
return undefined;
|
||||
}
|
||||
const savedPowerType = getPowerTypeFromConfig(source.power_config);
|
||||
if (savedPowerType !== "inverted" && savedPowerType !== "two_sensors") {
|
||||
return undefined;
|
||||
}
|
||||
if (!deepEqual(source.power_config, currentPowerConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
return source.stat_rate;
|
||||
}
|
||||
@@ -397,20 +397,8 @@ class HaConfigHttpForm extends LitElement {
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
this._showNoChanges = false;
|
||||
// Drop empty entries from multi-value fields, and omit the field entirely
|
||||
// once it is empty so the backend applies its default. Otherwise a cleared
|
||||
// "IP address to bind to" would submit [""] / [], which binds to nothing.
|
||||
const config = Object.fromEntries(
|
||||
Object.entries(this._config).map(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const filtered = value.filter(Boolean);
|
||||
return [key, filtered.length ? filtered : undefined];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
) as HttpConfig;
|
||||
try {
|
||||
const result = await saveHttpConfig(this.hass, config);
|
||||
const result = await saveHttpConfig(this.hass, this._config);
|
||||
if (!result.restart) {
|
||||
this._showNoChanges = true;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-icon-button-arrow-prev";
|
||||
import "../../components/ha-menu-button";
|
||||
@@ -162,9 +161,7 @@ class PanelEnergy extends LitElement {
|
||||
.route=${this.route}
|
||||
.panel=${this.panel}
|
||||
.backButton=${this._searchParms.has("historyBack")}
|
||||
.backPath=${
|
||||
sanitizeNavigationPath(this._searchParms.get("backPath")) || "/"
|
||||
}
|
||||
.backPath=${this._searchParms.get("backPath") || "/"}
|
||||
@reload-energy-panel=${this._reloadConfig}
|
||||
>
|
||||
</hui-root>
|
||||
|
||||
@@ -620,6 +620,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
"--tile-color": color,
|
||||
};
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
${
|
||||
@@ -683,6 +686,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
<ha-tile-container
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${Boolean(this._hasCardAction)}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
@@ -699,6 +703,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info
|
||||
slot="info"
|
||||
class=${ifDefined(
|
||||
this._config.vertical && fixedInfoHeight ? "twoline" : undefined
|
||||
)}
|
||||
.primary=${primary}
|
||||
.secondary=${secondary}
|
||||
></ha-tile-info>
|
||||
@@ -818,6 +825,10 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
ha-tile-info.twoline {
|
||||
--ha-tile-info-primary-line-clamp: 2;
|
||||
--ha-tile-info-primary-line-height: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: ShortcutCardConfig;
|
||||
|
||||
private _navInfo = new NavigationPathInfoController(this);
|
||||
@@ -128,10 +130,14 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
|
||||
|
||||
const style = color ? { "--tile-color": color } : {};
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
<ha-tile-container
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${this._hasCardAction}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
|
||||
@@ -81,6 +81,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: TileCardConfig;
|
||||
|
||||
@state() private _featureContext: LovelaceCardFeatureContext = {};
|
||||
@@ -288,11 +290,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
|
||||
const hasImage = Boolean(imageUrl);
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)} class=${classMap({ active })}>
|
||||
<ha-tile-container
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${this._hasCardAction}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
|
||||
@@ -31,7 +31,6 @@ import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
|
||||
import {
|
||||
addSearchParam,
|
||||
extractSearchParamsObject,
|
||||
@@ -894,12 +893,10 @@ class HUIRoot extends LitElement {
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
|
||||
if (backPath) {
|
||||
navigate(backPath, { replace: true });
|
||||
if (curViewConfig?.back_path != null) {
|
||||
navigate(curViewConfig.back_path, { replace: true });
|
||||
} else if (this.backPath) {
|
||||
navigate(this.backPath, { replace: true });
|
||||
} else if (history.length > 1) {
|
||||
goBack();
|
||||
} else if (!views[0].subview) {
|
||||
@@ -921,12 +918,11 @@ class HUIRoot extends LitElement {
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
|
||||
if (backPath) {
|
||||
return backPath;
|
||||
if (curViewConfig?.back_path != null) {
|
||||
return curViewConfig.back_path;
|
||||
}
|
||||
if (this.backPath) {
|
||||
return this.backPath;
|
||||
}
|
||||
return curViewConfig?.subview ? this.route!.prefix : undefined;
|
||||
}
|
||||
|
||||
@@ -4211,9 +4211,7 @@
|
||||
"power_stat": "Power sensor",
|
||||
"power_helper": "Pick a sensor which measures grid power in either of {unit}.",
|
||||
"power_from": "Power imported from grid",
|
||||
"power_to": "Power exported to grid",
|
||||
"helper_sensor_note": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_note%]",
|
||||
"helper_sensor_in_use": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_in_use%]"
|
||||
"power_to": "Power exported to grid"
|
||||
},
|
||||
"flow_dialog": {
|
||||
"from": {
|
||||
@@ -4280,9 +4278,7 @@
|
||||
"type_inverted_description": "Positive values indicate charging, negative values indicate discharging.",
|
||||
"type_two_sensors": "Two sensors",
|
||||
"power_from": "Discharge power",
|
||||
"power_to": "Charge power",
|
||||
"helper_sensor_note": "Home Assistant will create a helper sensor entity for this option.",
|
||||
"helper_sensor_in_use": "Currently using helper {entity}"
|
||||
"power_to": "Charge power"
|
||||
}
|
||||
},
|
||||
"gas": {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalAttachInternals = HTMLElement.prototype.attachInternals;
|
||||
const originalElementInternals = window.ElementInternals;
|
||||
|
||||
// The probe memoizes its result, so re-import the module for each scenario.
|
||||
const loadProbe = async () => {
|
||||
vi.resetModules();
|
||||
const mod =
|
||||
await import("../../../src/common/feature-detect/support-native-element-internals");
|
||||
return mod.supportsNativeElementInternals;
|
||||
};
|
||||
|
||||
describe("supportsNativeElementInternals", () => {
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.attachInternals = originalAttachInternals;
|
||||
window.ElementInternals = originalElementInternals;
|
||||
});
|
||||
|
||||
it("returns true with native ElementInternals", async () => {
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when attachInternals is wrapped by a delegating function", async () => {
|
||||
// Simulates @webcomponents/scoped-custom-element-registry, which the app
|
||||
// bundle loads. Wrapping used to make detection fail (#53337).
|
||||
HTMLElement.prototype.attachInternals = function (this: HTMLElement) {
|
||||
return originalAttachInternals.call(this);
|
||||
};
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when element-internals-polyfill replaces the global", async () => {
|
||||
// The polyfill swaps in its own class, so the mere presence of
|
||||
// window.ElementInternals says nothing about native support (#51338).
|
||||
class PolyfilledElementInternals {
|
||||
public setFormValue = (): void => undefined;
|
||||
|
||||
public setValidity = (): void => undefined;
|
||||
|
||||
public checkValidity = (): boolean => true;
|
||||
|
||||
public reportValidity = (): boolean => true;
|
||||
}
|
||||
window.ElementInternals =
|
||||
PolyfilledElementInternals as unknown as typeof window.ElementInternals;
|
||||
HTMLElement.prototype.attachInternals = () =>
|
||||
new PolyfilledElementInternals() as unknown as ElementInternals;
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without ElementInternals", async () => {
|
||||
window.ElementInternals =
|
||||
undefined as unknown as typeof window.ElementInternals;
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without attachInternals", async () => {
|
||||
HTMLElement.prototype.attachInternals =
|
||||
undefined as unknown as typeof originalAttachInternals;
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
|
||||
it("probes on first use rather than on import", async () => {
|
||||
// Imported while support is present, so an eager probe would cache true.
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
window.ElementInternals =
|
||||
undefined as unknown as typeof window.ElementInternals;
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sanitizeNavigationPath } from "../../../src/common/url/sanitize-navigation-path";
|
||||
|
||||
describe("sanitizeNavigationPath", () => {
|
||||
it("keeps paths on the current origin", () => {
|
||||
expect(sanitizeNavigationPath("/")).toEqual("/");
|
||||
expect(sanitizeNavigationPath("/config/areas")).toEqual("/config/areas");
|
||||
expect(sanitizeNavigationPath("/energy?historyBack=1")).toEqual(
|
||||
"/energy?historyBack=1"
|
||||
);
|
||||
expect(sanitizeNavigationPath("config/areas")).toEqual("config/areas");
|
||||
expect(sanitizeNavigationPath(`${location.origin}/lovelace/0`)).toEqual(
|
||||
`${location.origin}/lovelace/0`
|
||||
);
|
||||
});
|
||||
|
||||
/* eslint-disable no-script-url */
|
||||
it("rejects URIs with their own scheme", () => {
|
||||
expect(sanitizeNavigationPath("javascript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("JavaScript:alert(1)")).toBeUndefined();
|
||||
// the URL parser strips tabs and newlines, just like the browser does for href
|
||||
expect(sanitizeNavigationPath("java\tscript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath(" javascript:alert(1)")).toBeUndefined();
|
||||
expect(
|
||||
sanitizeNavigationPath("data:text/html,<script>alert(1)</script>")
|
||||
).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("vbscript:msgbox(1)")).toBeUndefined();
|
||||
});
|
||||
/* eslint-enable no-script-url */
|
||||
|
||||
it("rejects other origins", () => {
|
||||
expect(sanitizeNavigationPath("https://example.com/")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("//example.com/")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("\\\\example.com/")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects missing values", () => {
|
||||
expect(sanitizeNavigationPath(undefined)).toBeUndefined();
|
||||
expect(sanitizeNavigationPath(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { LitElement } from "lit";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WaInput } from "../../../src/components/input/wa-input-mixin";
|
||||
import { WaInputMixin } from "../../../src/components/input/wa-input-mixin";
|
||||
|
||||
const supportsNative = vi.hoisted(() => ({ value: true }));
|
||||
|
||||
vi.mock(
|
||||
"../../../src/common/feature-detect/support-native-element-internals",
|
||||
() => ({
|
||||
supportsNativeElementInternals: () => supportsNative.value,
|
||||
})
|
||||
);
|
||||
|
||||
// Subclassing gives legitimate access to the mixin's protected members, which
|
||||
// ha-input and ha-textarea reach through their @input/@blur/@wa-invalid bindings.
|
||||
class TestInput extends WaInputMixin(LitElement) {
|
||||
public controlValid = true;
|
||||
|
||||
protected get _formControl(): WaInput {
|
||||
return {
|
||||
value: "",
|
||||
select: () => undefined,
|
||||
setSelectionRange: () => undefined,
|
||||
setRangeText: () => undefined,
|
||||
checkValidity: () => this.controlValid,
|
||||
validationMessage: "Please fill out this field.",
|
||||
};
|
||||
}
|
||||
|
||||
public get isInvalid(): boolean {
|
||||
return this._invalid;
|
||||
}
|
||||
|
||||
public set isInvalid(value: boolean) {
|
||||
this._invalid = value;
|
||||
}
|
||||
|
||||
public handleInput(): void {
|
||||
this._handleInput();
|
||||
}
|
||||
|
||||
public handleBlur(): void {
|
||||
this._handleBlur();
|
||||
}
|
||||
|
||||
public handleInvalid(): void {
|
||||
this._handleInvalid();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-wa-input-mixin", TestInput);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-wa-input-mixin": TestInput;
|
||||
}
|
||||
}
|
||||
|
||||
const createInput = (props: Partial<TestInput> = {}): TestInput => {
|
||||
const el = document.createElement("test-wa-input-mixin");
|
||||
Object.assign(el, props);
|
||||
return el;
|
||||
};
|
||||
|
||||
describe("WaInputMixin validity", () => {
|
||||
beforeEach(() => {
|
||||
supportsNative.value = true;
|
||||
});
|
||||
|
||||
describe("with native element internals", () => {
|
||||
it("marks invalid on blur when auto-validate is set", () => {
|
||||
const el = createInput({ autoValidate: true, controlValid: false });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps valid on blur when the control is valid", () => {
|
||||
const el = createInput({ autoValidate: true, controlValid: true });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores blur without auto-validate", () => {
|
||||
const el = createInput({ autoValidate: false, controlValid: false });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("clears invalid on input once the control is valid", () => {
|
||||
const el = createInput({ controlValid: true });
|
||||
el.isInvalid = true;
|
||||
el.handleInput();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps invalid on input while the control is invalid", () => {
|
||||
const el = createInput({ controlValid: false });
|
||||
el.isInvalid = true;
|
||||
el.handleInput();
|
||||
expect(el.isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it("marks invalid on the invalid event", () => {
|
||||
const el = createInput();
|
||||
el.handleInvalid();
|
||||
expect(el.isInvalid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Polyfilled internals report validity the app cannot trust, so every path
|
||||
// must agree with checkValidity() and leave the field alone (#51338).
|
||||
describe("without native element internals", () => {
|
||||
beforeEach(() => {
|
||||
supportsNative.value = false;
|
||||
});
|
||||
|
||||
it("does not mark invalid on blur", () => {
|
||||
const el = createInput({ autoValidate: true, controlValid: false });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("clears invalid on input", () => {
|
||||
const el = createInput({ controlValid: false });
|
||||
el.isInvalid = true;
|
||||
el.handleInput();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the invalid event", () => {
|
||||
const el = createInput();
|
||||
el.handleInvalid();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("reports valid so submission is never blocked", () => {
|
||||
const el = createInput({ required: true, controlValid: false });
|
||||
expect(el.checkValidity()).toBe(true);
|
||||
expect(el.reportValidity()).toBe(true);
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The real back button pulls in the localize context, which is not provided here.
|
||||
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
|
||||
vi.mock("../../src/components/ha-menu-button", () => ({}));
|
||||
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
|
||||
customElements.define("ha-menu-button", class extends LitElement {});
|
||||
await import("../../src/layouts/hass-subpage");
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
const mount = async (backPath: string) => {
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const element = document.createElement("hass-subpage");
|
||||
element.setAttribute("back-path", backPath);
|
||||
host.append(element);
|
||||
await (element as LitElement).updateComplete;
|
||||
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
});
|
||||
|
||||
describe("hass-subpage back path", () => {
|
||||
it("links to a path on the current origin", async () => {
|
||||
const backButton = await mount("/config/system");
|
||||
expect(backButton!.getAttribute("href")).toEqual("/config/system");
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
it.each(["javascript:alert(1)", "https://example.com/"])(
|
||||
"does not link to %s",
|
||||
async (backPath) => {
|
||||
const backButton = await mount(backPath);
|
||||
expect(backButton!.hasAttribute("href")).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HassTabsSubpage } from "../../src/layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
// The real back button pulls in the localize context, which is not provided here.
|
||||
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
|
||||
vi.mock("../../src/components/ha-menu-button", () => ({}));
|
||||
vi.mock("../../src/components/ha-tab", () => ({}));
|
||||
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
|
||||
customElements.define("ha-menu-button", class extends LitElement {});
|
||||
customElements.define("ha-tab", class extends LitElement {});
|
||||
await import("../../src/layouts/hass-tabs-subpage");
|
||||
|
||||
const hass = {
|
||||
config: { components: [] },
|
||||
language: "en",
|
||||
localize: (key: string) => key,
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
const mount = async (backPath: string) => {
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const element = document.createElement(
|
||||
"hass-tabs-subpage"
|
||||
) as HassTabsSubpage;
|
||||
Object.assign(element, {
|
||||
hass,
|
||||
route: { prefix: "", path: "" },
|
||||
tabs: [],
|
||||
backPath,
|
||||
});
|
||||
host.append(element);
|
||||
await element.updateComplete;
|
||||
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev") as
|
||||
(LitElement & { href?: string }) | null;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
});
|
||||
|
||||
describe("hass-tabs-subpage back path", () => {
|
||||
it("links to a path on the current origin", async () => {
|
||||
const backButton = await mount("/config");
|
||||
expect(backButton!.href).toEqual("/config");
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
it.each(["javascript:alert(1)", "https://example.com/"])(
|
||||
"does not link to %s",
|
||||
async (backPath) => {
|
||||
const backButton = await mount(backPath);
|
||||
expect(backButton!.href).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
|
||||
describe("getPowerHelperEntityId", () => {
|
||||
it("returns the helper for an inverted config", () => {
|
||||
const powerConfig = { stat_rate_inverted: "sensor.battery_power" };
|
||||
assert.strictEqual(
|
||||
getPowerHelperEntityId(
|
||||
{
|
||||
stat_rate: "sensor.battery_power_inverted",
|
||||
power_config: powerConfig,
|
||||
},
|
||||
{ ...powerConfig }
|
||||
),
|
||||
"sensor.battery_power_inverted"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the helper for a two sensor config", () => {
|
||||
const powerConfig = {
|
||||
stat_rate_from: "sensor.discharge",
|
||||
stat_rate_to: "sensor.charge",
|
||||
};
|
||||
assert.strictEqual(
|
||||
getPowerHelperEntityId(
|
||||
{
|
||||
stat_rate: "sensor.energy_battery_discharge_charge_net_power",
|
||||
power_config: powerConfig,
|
||||
},
|
||||
{ ...powerConfig }
|
||||
),
|
||||
"sensor.energy_battery_discharge_charge_net_power"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for a standard config, as no helper is created", () => {
|
||||
const powerConfig = { stat_rate: "sensor.battery_power" };
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId(
|
||||
{ stat_rate: "sensor.battery_power", power_config: powerConfig },
|
||||
{ ...powerConfig }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for a legacy config without power_config", () => {
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId({ stat_rate: "sensor.battery_power" }, {})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing when the config was edited", () => {
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId(
|
||||
{
|
||||
stat_rate: "sensor.battery_power_inverted",
|
||||
power_config: { stat_rate_inverted: "sensor.battery_power" },
|
||||
},
|
||||
{ stat_rate_inverted: "sensor.other_battery_power" }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for an unsaved source", () => {
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId(undefined, {
|
||||
stat_rate_inverted: "sensor.battery_power",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isPowerConfigValid } from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
|
||||
describe("isPowerConfigValid", () => {
|
||||
it("accepts any config when no power sensor is configured", () => {
|
||||
expect(isPowerConfigValid("none", {})).toBe(true);
|
||||
expect(isPowerConfigValid("none", { stat_rate: "sensor.power" })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a rate statistic for the standard type", () => {
|
||||
expect(isPowerConfigValid("standard", {})).toBe(false);
|
||||
expect(isPowerConfigValid("standard", { stat_rate: "" })).toBe(false);
|
||||
expect(isPowerConfigValid("standard", { stat_rate: "sensor.power" })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isPowerConfigValid("standard", { stat_rate_inverted: "sensor.power" })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an inverted rate statistic for the inverted type", () => {
|
||||
expect(isPowerConfigValid("inverted", {})).toBe(false);
|
||||
expect(isPowerConfigValid("inverted", { stat_rate: "sensor.power" })).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
isPowerConfigValid("inverted", { stat_rate_inverted: "sensor.power" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires both statistics for the two sensors type", () => {
|
||||
expect(isPowerConfigValid("two_sensors", {})).toBe(false);
|
||||
expect(
|
||||
isPowerConfigValid("two_sensors", { stat_rate_from: "sensor.power_from" })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPowerConfigValid("two_sensors", { stat_rate_to: "sensor.power_to" })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPowerConfigValid("two_sensors", {
|
||||
stat_rate_from: "sensor.power_from",
|
||||
stat_rate_to: "sensor.power_to",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown power types", () => {
|
||||
expect(
|
||||
isPowerConfigValid("unexpected" as never, { stat_rate: "sensor.power" })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,4 +3,3 @@ global.navigator = (global.navigator ?? {}) as any;
|
||||
|
||||
global.__DEMO__ = false;
|
||||
global.__DEV__ = false;
|
||||
global.__HASS_URL__ = "";
|
||||
|
||||
@@ -9675,10 +9675,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"globals@npm:17.8.0":
|
||||
version: 17.8.0
|
||||
resolution: "globals@npm:17.8.0"
|
||||
checksum: 10/b7b854b2052d2608d1878884bf730c027e17b9e2d194834f48c3280a7f0005b06d5f51dba362d3149cc05eb90d36d990e5c996728ecd3585a43dc3b4a286ed85
|
||||
"globals@npm:17.7.0":
|
||||
version: 17.7.0
|
||||
resolution: "globals@npm:17.7.0"
|
||||
checksum: 10/79304ccc4d2ca167ea15bdb25da346aa34ce3847b18fbd6c3cad182e152505305db3c9722fd5e292c62f6db97a8fa06e0c110a1e7703d7325498e5351d08cab4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10011,7 +10011,7 @@ __metadata:
|
||||
fuse.js: "npm:7.5.0"
|
||||
generate-license-file: "npm:4.2.1"
|
||||
glob: "npm:13.0.6"
|
||||
globals: "npm:17.8.0"
|
||||
globals: "npm:17.7.0"
|
||||
gulp: "npm:5.0.1"
|
||||
gulp-brotli: "npm:3.0.0"
|
||||
gulp-json-transform: "npm:0.5.0"
|
||||
|
||||
Reference in New Issue
Block a user