mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-31 11:26:12 +00:00
Compare commits
12 Commits
fix-issue-53365
...
rc
| Author | SHA1 | Date | |
|---|---|---|---|
| 9743117abf | |||
| 5e012973b2 | |||
| ea659f1b33 | |||
| 539803cb5b | |||
| 944d3332d1 | |||
| 4a267d160f | |||
| 7edb9f8164 | |||
| ff814167c6 | |||
| d08d8dde64 | |||
| 5da441ceed | |||
| 22e646a68a | |||
| d0ab20479f |
@@ -34,16 +34,8 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#03a9f4" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_social_meta.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
@@ -64,12 +56,14 @@
|
||||
padding: 0;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
#ha-launch-screen.removing {
|
||||
@@ -104,7 +98,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260729.0"
|
||||
version = "20260729.2"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
|
||||
|
||||
const HOME_ASSISTANT_SCHEME = "homeassistant://";
|
||||
|
||||
/**
|
||||
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
|
||||
* Only absolute `http:` and `https:` URLs pass, the same check
|
||||
* `ha-attribute-value` already applies to attribute links.
|
||||
*
|
||||
* Use for every URL that reaches the frontend as data — from an integration
|
||||
* manifest, an add-on, a config flow or an entity attribute — so that a
|
||||
* `javascript:` URI can never become a clickable link.
|
||||
*/
|
||||
export const sanitizeHttpUrl = (
|
||||
url: string | null | undefined
|
||||
): string | undefined => {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
return protocol === "http:" || protocol === "https:" ? url : undefined;
|
||||
} catch (_err) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
|
||||
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
|
||||
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
|
||||
|
||||
/**
|
||||
* Turns a `homeassistant://` deep link into an in-app path, or returns
|
||||
* `undefined` when it does not point inside the frontend. Rewriting the scheme
|
||||
* on its own is not enough: `homeassistant:///example.com` would become
|
||||
* `//example.com`, which resolves to another origin.
|
||||
*/
|
||||
export const homeAssistantUrlToPath = (
|
||||
url: string | null | undefined
|
||||
): string | undefined =>
|
||||
isHomeAssistantUrl(url)
|
||||
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Sanitizes a URL that may be either an external link or a `homeassistant://`
|
||||
* deep link, returning something safe to bind to an `href`.
|
||||
*/
|
||||
export const sanitizeLinkUrl = (
|
||||
url: string | null | undefined
|
||||
): string | undefined =>
|
||||
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
|
||||
@@ -6,8 +6,11 @@ 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";
|
||||
@@ -23,11 +26,21 @@ 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 {
|
||||
@@ -55,15 +68,23 @@ export class DialogDeviceReplaced
|
||||
candidates: string[],
|
||||
primaryId: string | null,
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"]
|
||||
areas: HomeAssistant["areas"],
|
||||
configEntryLookup: Record<string, ConfigEntry> | undefined
|
||||
) =>
|
||||
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,
|
||||
secondary: area ? computeAreaName(area) : undefined,
|
||||
area: area ? computeAreaName(area) : undefined,
|
||||
domain: configEntry?.domain,
|
||||
domainName: configEntry
|
||||
? domainToName(this.hass.localize, configEntry.domain)
|
||||
: undefined,
|
||||
isPrimary: deviceId === primaryId,
|
||||
};
|
||||
})
|
||||
@@ -92,31 +113,54 @@ export class DialogDeviceReplaced
|
||||
this._params.candidates,
|
||||
this._params.primaryId,
|
||||
this.hass.devices,
|
||||
this.hass.areas
|
||||
).map(
|
||||
(item) => html`
|
||||
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`
|
||||
<ha-list-item-button .deviceId=${item.deviceId}>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
${
|
||||
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>`
|
||||
}
|
||||
<span slot="headline">${item.name}</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>`
|
||||
supportingText
|
||||
? html`<span slot="supporting-text"
|
||||
>${supportingText}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</ha-list-base>
|
||||
</ha-dialog>
|
||||
`;
|
||||
@@ -132,6 +176,10 @@ 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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: "error-message" }) public errorMessage?: string;
|
||||
|
||||
@property({ type: Boolean }) public invalid = false;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ attribute: "statistic-types" })
|
||||
@@ -526,6 +530,9 @@ export class HaStatisticPicker extends LitElement {
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.invalid=${this.invalid}
|
||||
.errorMessage=${this.errorMessage}
|
||||
.label=${this.label}
|
||||
use-top-label
|
||||
.placeholder=${placeholder}
|
||||
|
||||
@@ -57,7 +57,10 @@ import "./ha-code-editor-completion-items";
|
||||
import type { CompletionItem } from "./ha-code-editor-completion-items";
|
||||
import "./ha-icon";
|
||||
import "./ha-icon-button-toolbar";
|
||||
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
|
||||
import type {
|
||||
HaIconButtonToolbar,
|
||||
HaIconButtonToolbarItem,
|
||||
} from "./ha-icon-button-toolbar";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -115,6 +118,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
@property({ type: Boolean, attribute: "has-test" })
|
||||
public hasTest = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public toolbarItems?: (HaIconButtonToolbarItem | string)[];
|
||||
|
||||
@property({ attribute: false }) public testing = false;
|
||||
|
||||
@property({ type: String }) public placeholder?: string;
|
||||
@@ -351,7 +357,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
changedProps.has("_canCopy") ||
|
||||
changedProps.has("_canUndo") ||
|
||||
changedProps.has("_canRedo") ||
|
||||
changedProps.has("testing")
|
||||
changedProps.has("testing") ||
|
||||
changedProps.has("toolbarItems")
|
||||
) {
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
@@ -529,6 +536,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
this._editorToolbar.items = [
|
||||
...(this.toolbarItems ?? []),
|
||||
...(this.hasTest && !this._isFullscreen
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -304,14 +304,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
private _renderHelper() {
|
||||
const showError = this.invalid && this.errorMessage;
|
||||
const showHelper = !showError && this.helper;
|
||||
|
||||
if (!showError && !showHelper) {
|
||||
if (!showError && !this.helper) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${showError ? this.errorMessage : this.helper}
|
||||
${
|
||||
showError
|
||||
? html`<span class="error">${this.errorMessage}</span> ${
|
||||
this.helper
|
||||
? html`<span class="helper">${this.helper}</span>`
|
||||
: nothing
|
||||
}`
|
||||
: this.helper
|
||||
}
|
||||
</ha-input-helper-text>`;
|
||||
}
|
||||
|
||||
@@ -448,6 +455,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
:host([invalid]) ha-input-helper-text {
|
||||
color: var(--mdc-theme-error, var(--error-color, #b00020));
|
||||
}
|
||||
ha-input-helper-text .error,
|
||||
ha-input-helper-text .helper {
|
||||
display: block;
|
||||
}
|
||||
ha-input-helper-text .helper {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
|
||||
@@ -488,6 +488,11 @@ export class HaServiceControl extends LitElement {
|
||||
)) ||
|
||||
serviceData?.description;
|
||||
|
||||
const documentationLink =
|
||||
this._manifest?.is_built_in && this._value?.action
|
||||
? documentationUrl(this.hass, `/actions/${this._value.action}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
const targetSelector =
|
||||
serviceData && "target" in serviceData
|
||||
? this._targetSelector(
|
||||
@@ -514,16 +519,9 @@ export class HaServiceControl extends LitElement {
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : ""}
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html` <a
|
||||
href=${
|
||||
this._manifest.is_built_in && this._value?.action
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/actions/${this._value.action}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-split-panel")
|
||||
export class HaSplitPanel extends SplitPanel {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
SplitPanel.styles,
|
||||
css`
|
||||
:host {
|
||||
--divider-width: var(--ha-split-panel-divider-width, 2px);
|
||||
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
|
||||
--min: var(--ha-split-panel-min, 0);
|
||||
--max: var(--ha-split-panel-max, 100%);
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: var(--divider-color);
|
||||
transition: background-color var(--ha-animation-duration-fast)
|
||||
ease-out;
|
||||
}
|
||||
|
||||
/* Grip affordance so the divider reads as draggable. The divider
|
||||
already centers its children via flexbox, so keep this in flow.
|
||||
Consumers slotting their own divider handle can hide it with
|
||||
--ha-split-panel-grip-display: none. */
|
||||
.divider::before {
|
||||
content: "";
|
||||
width: 2px;
|
||||
height: var(--ha-space-8);
|
||||
display: var(--ha-split-panel-grip-display, block);
|
||||
border-radius: var(--ha-border-radius-pill, 9999px);
|
||||
background-color: var(--secondary-text-color);
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
/* In vertical orientation the divider is horizontal, so the grip pill
|
||||
lies flat instead of standing upright. */
|
||||
:host([orientation="vertical"]) .divider::before {
|
||||
width: var(--ha-space-8);
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:host(:not([disabled])) .divider:hover {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
:host(:not([disabled])) .divider:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:host(:not([disabled])) .divider:focus-visible {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-split-panel": HaSplitPanel;
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
browseMediaPlayer,
|
||||
BROWSER_PLAYER,
|
||||
MediaClassBrowserSettings,
|
||||
searchMediaPlayer,
|
||||
} from "../../data/media-player";
|
||||
import {
|
||||
browseLocalMediaPlayer,
|
||||
isManualMediaSourceContentId,
|
||||
isMediaSourceContentId,
|
||||
MANUAL_MEDIA_SOURCE_PREFIX,
|
||||
searchMedia,
|
||||
} from "../../data/media_source";
|
||||
@@ -869,13 +871,31 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
const mediaFilterClasses = this._mediaClassFilter.length
|
||||
? this._mediaClassFilter
|
||||
: undefined;
|
||||
// A player's tree can embed media sources, which resolve their own searches;
|
||||
// everything else in it uses integration specific ids only the entity knows.
|
||||
const searchEntityId =
|
||||
this.entityId &&
|
||||
this.entityId !== BROWSER_PLAYER &&
|
||||
!isMediaSourceContentId(navigateId.media_content_id ?? "")
|
||||
? this.entityId
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const { result } = await searchMedia(
|
||||
this.hass,
|
||||
navigateId.media_content_id,
|
||||
searchQuery,
|
||||
mediaFilterClasses
|
||||
);
|
||||
const { result } = searchEntityId
|
||||
? await searchMediaPlayer(
|
||||
this.hass,
|
||||
searchEntityId,
|
||||
searchQuery,
|
||||
navigateId.media_content_id,
|
||||
navigateId.media_content_type,
|
||||
mediaFilterClasses
|
||||
)
|
||||
: await searchMedia(
|
||||
this.hass,
|
||||
navigateId.media_content_id,
|
||||
searchQuery,
|
||||
mediaFilterClasses
|
||||
);
|
||||
// Ignore the response if a newer search started or we navigated away
|
||||
if (requestId !== this._searchRequestId) {
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+27
-5
@@ -1,6 +1,7 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -28,7 +29,7 @@ export interface IntegrationManifest {
|
||||
domain: string;
|
||||
name: string;
|
||||
config_flow: boolean;
|
||||
documentation: string;
|
||||
documentation?: string;
|
||||
issue_tracker?: string;
|
||||
dependencies?: string[];
|
||||
after_dependencies?: string[];
|
||||
@@ -78,11 +79,27 @@ export enum LogSeverity {
|
||||
|
||||
export type IntegrationLogPersistance = "none" | "once" | "permanent";
|
||||
|
||||
/**
|
||||
* A custom integration supplies its own manifest, so its URLs are untrusted
|
||||
* input. Strip them here, where manifests enter the frontend, so no consumer can
|
||||
* turn one into a link that runs script.
|
||||
*/
|
||||
const sanitizeManifest = <T extends IntegrationManifest | undefined>(
|
||||
manifest: T
|
||||
): T =>
|
||||
manifest
|
||||
? ({
|
||||
...manifest,
|
||||
documentation: sanitizeHttpUrl(manifest.documentation),
|
||||
issue_tracker: sanitizeHttpUrl(manifest.issue_tracker),
|
||||
} as T)
|
||||
: manifest;
|
||||
|
||||
export const integrationIssuesUrl = (
|
||||
domain: string,
|
||||
manifest: IntegrationManifest
|
||||
) =>
|
||||
manifest.issue_tracker ||
|
||||
sanitizeHttpUrl(manifest.issue_tracker) ||
|
||||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
|
||||
|
||||
export const domainToName = (
|
||||
@@ -101,7 +118,9 @@ export const fetchIntegrationManifests = (
|
||||
if (integrations) {
|
||||
params.integrations = integrations;
|
||||
}
|
||||
return hass.callWS<IntegrationManifest[]>(params);
|
||||
return hass
|
||||
.callWS<IntegrationManifest[]>(params)
|
||||
.then((manifests) => manifests.map(sanitizeManifest));
|
||||
};
|
||||
|
||||
export const fetchIntegrationManifestsCollection = async (
|
||||
@@ -113,7 +132,7 @@ export const fetchIntegrationManifestsCollection = async (
|
||||
});
|
||||
const manifests: DomainManifestLookup = {};
|
||||
for (const manifest of fetched) {
|
||||
manifests[manifest.domain] = manifest;
|
||||
manifests[manifest.domain] = sanitizeManifest(manifest);
|
||||
}
|
||||
setValue(manifests);
|
||||
// One-time fetch — nothing to unsubscribe from
|
||||
@@ -125,7 +144,10 @@ export const fetchIntegrationManifestsCollection = async (
|
||||
export const fetchIntegrationManifest = (
|
||||
hass: HomeAssistant,
|
||||
integration: string
|
||||
) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration });
|
||||
) =>
|
||||
hass
|
||||
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
|
||||
.then(sanitizeManifest);
|
||||
|
||||
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
|
||||
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
|
||||
|
||||
@@ -208,6 +208,29 @@ export const browseMediaPlayer = (
|
||||
media_content_type: mediaContentType,
|
||||
});
|
||||
|
||||
export interface SearchMediaResult {
|
||||
result: MediaPlayerItem[];
|
||||
}
|
||||
|
||||
export const searchMediaPlayer = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string,
|
||||
searchQuery: string,
|
||||
mediaContentId?: string,
|
||||
mediaContentType?: string,
|
||||
mediaFilterClasses?: string[]
|
||||
): Promise<SearchMediaResult> =>
|
||||
hass.callWS<SearchMediaResult>({
|
||||
type: "media_player/search_media",
|
||||
entity_id: entityId,
|
||||
search_query: searchQuery,
|
||||
// the backend requires these two to be passed together, and JSON
|
||||
// serialization drops them both when the current item is the root
|
||||
media_content_id: mediaContentId,
|
||||
media_content_type: mediaContentType,
|
||||
media_filter_classes: mediaFilterClasses,
|
||||
});
|
||||
|
||||
export const getCurrentProgress = (stateObj: MediaPlayerEntity): number => {
|
||||
let progress = stateObj.attributes.media_position!;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { MediaPlayerItem } from "./media-player";
|
||||
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
|
||||
|
||||
export interface ResolvedMediaSource {
|
||||
url: string;
|
||||
@@ -24,10 +24,6 @@ export const browseLocalMediaPlayer = (
|
||||
media_content_id: mediaContentId,
|
||||
});
|
||||
|
||||
export interface SearchMediaResult {
|
||||
result: MediaPlayerItem[];
|
||||
}
|
||||
|
||||
export const searchMedia = (
|
||||
hass: HomeAssistant,
|
||||
mediaContentId: string | undefined,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
@@ -337,6 +338,13 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
this._params.manifest?.is_built_in) ||
|
||||
!!this._params.manifest?.documentation;
|
||||
|
||||
const documentationLink = this._params.manifest?.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._params.manifest.domain}`
|
||||
)
|
||||
: this._params.manifest?.documentation;
|
||||
|
||||
const dialogTitle = this._getDialogTitle();
|
||||
const dialogSubtitle = this._getDialogSubtitle();
|
||||
|
||||
@@ -368,19 +376,15 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showDocumentationLink && !this._loading && this._step
|
||||
showDocumentationLink &&
|
||||
documentationLink &&
|
||||
!this._loading &&
|
||||
this._step
|
||||
? html`
|
||||
<a
|
||||
slot="headerActionItems"
|
||||
class="help"
|
||||
href=${
|
||||
this._params.manifest!.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._params.manifest!.domain}`
|
||||
)
|
||||
: this._params.manifest!.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
@@ -542,21 +546,29 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
case "external":
|
||||
case "external": {
|
||||
const externalUrl = sanitizeHttpUrl(this._step.url);
|
||||
return html`
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
href=${this._step.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.external_step.open_site"
|
||||
)}
|
||||
</ha-button>
|
||||
${
|
||||
externalUrl
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
href=${externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.external_step.open_site"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
}
|
||||
case "create_entry": {
|
||||
const devices = this._devices(
|
||||
this._params!.flowConfig.showDevices,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
|
||||
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { FlowConfig } from "./show-dialog-data-entry-flow";
|
||||
@@ -24,7 +25,11 @@ class StepFlowExternal extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
window.open(this.step.url);
|
||||
// Opened without user interaction, so only ever follow an http(s) URL
|
||||
const url = sanitizeHttpUrl(this.step.url);
|
||||
if (url) {
|
||||
window.open(url);
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { consumeLocalize } from "../../../common/decorators/consume-context-entr
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -232,6 +233,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
}
|
||||
|
||||
const createBackupTexts = this._computeCreateBackupTexts();
|
||||
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
@@ -283,14 +285,10 @@ class MoreInfoUpdate extends LitElement {
|
||||
</div>
|
||||
|
||||
${
|
||||
this.stateObj.attributes.release_url
|
||||
releaseUrl
|
||||
? html`<div class="row">
|
||||
<div class="key">
|
||||
<a
|
||||
href=${this.stateObj.attributes.release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<a href=${releaseUrl} target="_blank" rel="noreferrer">
|
||||
${this._localize(
|
||||
"ui.dialogs.more_info_control.update.release_announcement"
|
||||
)}
|
||||
|
||||
@@ -18,38 +18,15 @@
|
||||
<meta name="referrer" content="same-origin" />
|
||||
<meta name="theme-color" content="{{ theme_color }}" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_style_base.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
::view-transition-group(launch-screen) {
|
||||
animation-duration: var(--ha-animation-duration-normal, 250ms);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
::view-transition-old(launch-screen) {
|
||||
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
height: 100vh;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -61,7 +38,8 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
view-transition-name: launch-screen;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
z-index: 100;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
@@ -98,7 +76,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-button";
|
||||
|
||||
@@ -59,7 +60,7 @@ export class HaInitPage extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: html`<p>
|
||||
: html`<p class=${classMap({ "loading-text": !this.migration })}>
|
||||
${
|
||||
this.migration
|
||||
? html`<span class="migration-text"
|
||||
@@ -68,7 +69,7 @@ export class HaInitPage extends LitElement {
|
||||
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
|
||||
}</span
|
||||
>`
|
||||
: this.localize?.("ui.init.loading") || "Loading data"
|
||||
: this.localize?.("ui.init.loading") || "Loading..."
|
||||
}
|
||||
</p>`;
|
||||
}
|
||||
@@ -120,6 +121,9 @@ export class HaInitPage extends LitElement {
|
||||
.migration-text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.loading-text {
|
||||
opacity: 0.66;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
|
||||
const selectedDomainName = this._params.selectedDomain
|
||||
? domainToName(this.hass.localize, this._domain!)
|
||||
: "";
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this._domain}`)
|
||||
: this._manifest?.documentation;
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
@@ -139,17 +142,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
|
||||
}
|
||||
)}
|
||||
${
|
||||
this._manifest?.is_built_in ||
|
||||
this._manifest?.documentation
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._domain}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -42,6 +42,7 @@ import { computeDomain } from "../../../../../common/entity/compute_domain";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
|
||||
import type { LocalizeKeys } from "../../../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import "../../../../../components/chips/ha-assist-chip";
|
||||
import "../../../../../components/chips/ha-chip-set";
|
||||
@@ -549,7 +550,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
"ui.panel.config.apps.dashboard.visit_app_page",
|
||||
{
|
||||
name: html`<a
|
||||
href=${this._currentAddon.url!}
|
||||
href=${ifDefined(sanitizeHttpUrl(this._currentAddon.url))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${getAppDisplayName(
|
||||
@@ -1107,7 +1108,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
|
||||
private get _pathWebui(): string | null {
|
||||
const addon = this._currentAddon as HassioAddonDetails;
|
||||
return addon.webui!.replace("[HOST]", document.location.hostname);
|
||||
return (
|
||||
sanitizeHttpUrl(
|
||||
addon.webui!.replace("[HOST]", document.location.hostname)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private get _computeShowWebUI(): boolean | "" | null {
|
||||
|
||||
@@ -185,20 +185,17 @@ export class HaPlatformCondition extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/conditions/${this.condition.condition}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : nothing}
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/conditions/${this.condition.condition}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
|
||||
@@ -180,20 +180,17 @@ export class HaPlatformTrigger extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/triggers/${this.trigger.trigger}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : nothing}
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/triggers/${this.trigger.trigger}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
|
||||
@@ -96,6 +96,10 @@ import "../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { isHelperDomain } from "../helpers/const";
|
||||
import {
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
} from "../../../common/url/sanitize-http-url";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
@@ -1263,12 +1267,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
const deviceActions: DeviceAction[] = [];
|
||||
|
||||
const configurationUrlIsHomeAssistant =
|
||||
device.configuration_url?.startsWith("homeassistant://") || false;
|
||||
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
|
||||
device.configuration_url
|
||||
);
|
||||
|
||||
const configurationUrl = configurationUrlIsHomeAssistant
|
||||
? device.configuration_url?.replace("homeassistant://", "/")
|
||||
: device.configuration_url;
|
||||
const configurationUrl = sanitizeLinkUrl(device.configuration_url);
|
||||
|
||||
if (configurationUrl) {
|
||||
deviceActions.push({
|
||||
|
||||
@@ -61,6 +61,10 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private get _requiredError(): string {
|
||||
return this.hass.localize("ui.common.error_required");
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<p class="power-section-label">
|
||||
@@ -119,6 +123,9 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
|
||||
{ unit: this._powerUnits?.join(", ") || "" }
|
||||
)}
|
||||
required
|
||||
.invalid=${!this.powerConfig.stat_rate}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
`
|
||||
: nothing
|
||||
@@ -138,11 +145,16 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
.helper=${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
|
||||
)}
|
||||
required
|
||||
.invalid=${!this.powerConfig.stat_rate_inverted}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
// These two exclude each other, so they keep their clear button
|
||||
// (and therefore no required marker) to stay swappable.
|
||||
this.powerType === "two_sensors"
|
||||
? html`
|
||||
<ha-statistic-picker
|
||||
@@ -157,6 +169,8 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
this.powerConfig.stat_rate_to,
|
||||
].filter((id): id is string => Boolean(id))}
|
||||
@value-changed=${this._fromPowerChanged}
|
||||
.invalid=${!this.powerConfig.stat_rate_from}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -170,6 +184,8 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
this.powerConfig.stat_rate_from,
|
||||
].filter((id): id is string => Boolean(id))}
|
||||
@value-changed=${this._toPowerChanged}
|
||||
.invalid=${!this.powerConfig.stat_rate_to}
|
||||
.errorMessage=${this._requiredError}
|
||||
></ha-statistic-picker>
|
||||
`
|
||||
: nothing
|
||||
|
||||
@@ -8,6 +8,7 @@ import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { round } from "../../../common/number/round";
|
||||
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/chart/ha-chart-base";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -228,7 +229,7 @@ class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
|
||||
) as ConfigEntry[];
|
||||
boardId = boardData.board!.hassio_board_id;
|
||||
boardName = boardData.name;
|
||||
documentationURL = boardData.url;
|
||||
documentationURL = sanitizeHttpUrl(boardData.url);
|
||||
imageURL = hardwareBrandsUrl(
|
||||
{
|
||||
category: "boards",
|
||||
|
||||
@@ -300,6 +300,7 @@ class HaScheduleForm extends LitElement {
|
||||
const newValue = { ...this._item };
|
||||
|
||||
const endFormatted = formatTime24h(end, this.hass.locale, this.hass.config);
|
||||
newValue[day] = [...newValue[day]];
|
||||
newValue[day][index] = {
|
||||
...newValue[day][index],
|
||||
from: value.from,
|
||||
@@ -337,6 +338,7 @@ class HaScheduleForm extends LitElement {
|
||||
};
|
||||
|
||||
if (newDay === day) {
|
||||
newValue[day] = [...newValue[day]];
|
||||
newValue[day][index] = event;
|
||||
} else {
|
||||
newValue[day].splice(index, 1);
|
||||
|
||||
@@ -10,6 +10,10 @@ import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import {
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
} from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
@@ -46,6 +50,15 @@ export class HaConfigFlowCard extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const attention = ATTENTION_SOURCES.includes(this.flow.context.source);
|
||||
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
|
||||
this.flow.context.configuration_url
|
||||
);
|
||||
const configurationUrl = sanitizeLinkUrl(
|
||||
this.flow.context.configuration_url
|
||||
);
|
||||
const documentationLink = this.manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this.manifest.domain}`)
|
||||
: this.manifest?.documentation;
|
||||
return html`
|
||||
<ha-integration-action-card
|
||||
class=${classMap({
|
||||
@@ -78,7 +91,7 @@ export class HaConfigFlowCard extends LitElement {
|
||||
)}
|
||||
</ha-button>
|
||||
${
|
||||
this.flow.context.configuration_url || this.manifest || attention
|
||||
configurationUrl || documentationLink || attention
|
||||
? html`<ha-dropdown
|
||||
slot="header-button"
|
||||
placement="bottom-end"
|
||||
@@ -90,19 +103,12 @@ export class HaConfigFlowCard extends LitElement {
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
${
|
||||
this.flow.context.configuration_url
|
||||
configurationUrl
|
||||
? html`<a
|
||||
href=${this.flow.context.configuration_url.replace(
|
||||
/^homeassistant:\/\//,
|
||||
"/"
|
||||
)}
|
||||
href=${configurationUrl}
|
||||
rel="noreferrer"
|
||||
target=${
|
||||
this.flow.context.configuration_url.startsWith(
|
||||
"homeassistant://"
|
||||
)
|
||||
? "_self"
|
||||
: "_blank"
|
||||
configurationUrlIsHomeAssistant ? "_self" : "_blank"
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item>
|
||||
@@ -122,16 +128,9 @@ export class HaConfigFlowCard extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.manifest
|
||||
documentationLink
|
||||
? html`<a
|
||||
href=${
|
||||
this.manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this.manifest.domain}`
|
||||
)
|
||||
: this.manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -368,21 +368,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
this.domain
|
||||
);
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
|
||||
${
|
||||
this._manifest
|
||||
documentationLink
|
||||
? html`
|
||||
<a
|
||||
slot="toolbar-icon"
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../../../common/entity/compute_device_name";
|
||||
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
|
||||
import { groupBy } from "../../../../../common/util/group-by";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
|
||||
@@ -154,8 +155,9 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
device_database: html`<a
|
||||
rel="noreferrer noopener"
|
||||
href=${
|
||||
this._nodeMetadata?.device_database_url ||
|
||||
"https://devices.zwave-js.io"
|
||||
sanitizeHttpUrl(
|
||||
this._nodeMetadata?.device_database_url
|
||||
) || "https://devices.zwave-js.io"
|
||||
}
|
||||
target="_blank"
|
||||
>${this.hass.localize(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -199,6 +200,9 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
|
||||
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
|
||||
|
||||
const feedbackUrl = sanitizeHttpUrl(preview_feature.feedback_url);
|
||||
const reportIssueUrl = sanitizeHttpUrl(preview_feature.report_issue_url);
|
||||
|
||||
// Build description with learn more link if available
|
||||
const descriptionWithLink = preview_feature.learn_more_url
|
||||
? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})`
|
||||
@@ -237,11 +241,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
<div class="card-actions">
|
||||
<div>
|
||||
${
|
||||
preview_feature.feedback_url
|
||||
feedbackUrl
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href=${preview_feature.feedback_url}
|
||||
href=${feedbackUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -253,11 +257,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
preview_feature.report_issue_url
|
||||
reportIssueUrl
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href=${preview_feature.report_issue_url}
|
||||
href=${reportIssueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -25,6 +25,15 @@ import { showToast } from "../../../util/toast";
|
||||
import type { SystemLogDetailDialogParams } from "./show-dialog-system-log-detail";
|
||||
import { formatSystemLogTime } from "./util";
|
||||
|
||||
/** Compares the host, so a URL that merely contains ours does not pass. */
|
||||
const isOfficialDocumentationUrl = (url: string): boolean => {
|
||||
try {
|
||||
return new URL(url).hostname === "www.home-assistant.io";
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@customElement("dialog-system-log-detail")
|
||||
class DialogSystemLogDetail extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -76,7 +85,12 @@ class DialogSystemLogDetail extends LitElement {
|
||||
this._manifest &&
|
||||
(this._manifest.is_built_in ||
|
||||
// Custom components with our official docs should not link to our docs
|
||||
!this._manifest.documentation.includes("://www.home-assistant.io"));
|
||||
(!!this._manifest.documentation &&
|
||||
!isOfficialDocumentationUrl(this._manifest.documentation)));
|
||||
|
||||
const documentationLink = this._manifest?.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
|
||||
: this._manifest?.documentation;
|
||||
|
||||
const title = this.hass.localize("ui.panel.config.logs.details", {
|
||||
level: html`<span class=${item.level}
|
||||
@@ -124,18 +138,12 @@ class DialogSystemLogDetail extends LitElement {
|
||||
${
|
||||
!this._manifest ||
|
||||
// Can happen with custom integrations
|
||||
!showDocumentation
|
||||
!showDocumentation ||
|
||||
!documentationLink
|
||||
? ""
|
||||
: html`
|
||||
(<a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
href=${documentationLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
|
||||
@@ -397,8 +397,20 @@ 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, this._config);
|
||||
const result = await saveHttpConfig(this.hass, config);
|
||||
if (!result.restart) {
|
||||
this._showNoChanges = true;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
|
||||
import {
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
} from "../../../common/url/sanitize-http-url";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-button";
|
||||
@@ -52,8 +56,10 @@ class DialogRepairsIssue extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const learnMoreUrlIsHomeAssistant =
|
||||
this._issue.learn_more_url?.startsWith("homeassistant://") || false;
|
||||
const learnMoreUrlIsHomeAssistant = isHomeAssistantUrl(
|
||||
this._issue.learn_more_url
|
||||
);
|
||||
const learnMoreUrl = sanitizeLinkUrl(this._issue.learn_more_url);
|
||||
|
||||
const dialogTitle =
|
||||
this.hass.localize(
|
||||
@@ -127,20 +133,13 @@ class DialogRepairsIssue extends LitElement {
|
||||
}
|
||||
</ha-button>
|
||||
${
|
||||
this._issue.learn_more_url
|
||||
learnMoreUrl
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
appearance="filled"
|
||||
rel="noopener noreferrer"
|
||||
href=${
|
||||
learnMoreUrlIsHomeAssistant
|
||||
? this._issue.learn_more_url.replace(
|
||||
"homeassistant://",
|
||||
"/"
|
||||
)
|
||||
: this._issue.learn_more_url
|
||||
}
|
||||
href=${learnMoreUrl}
|
||||
.target=${learnMoreUrlIsHomeAssistant ? "" : "_blank"}
|
||||
@click=${
|
||||
learnMoreUrlIsHomeAssistant ? this.closeDialog : undefined
|
||||
|
||||
@@ -5,6 +5,8 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { formatDateTime } from "../../../common/datetime/format_date_time";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import { sanitizeNavigationPath } from "../../../common/url/sanitize-navigation-path";
|
||||
import { copyToClipboard } from "../../../common/util/copy-clipboard";
|
||||
import { subscribePollingCollection } from "../../../common/util/subscribe-polling";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -335,14 +337,15 @@ class DialogSystemInformation extends LitElement {
|
||||
if (info.type === "pending") {
|
||||
value = html` <ha-spinner size="small"></ha-spinner> `;
|
||||
} else if (info.type === "failed") {
|
||||
const moreInfoUrl = sanitizeHttpUrl(info.more_info);
|
||||
value = html`
|
||||
<span class="error">${info.error}</span>${
|
||||
!info.more_info
|
||||
!moreInfoUrl
|
||||
? ""
|
||||
: html`
|
||||
–
|
||||
<a
|
||||
href=${info.more_info}
|
||||
href=${moreInfoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
@@ -378,18 +381,22 @@ class DialogSystemInformation extends LitElement {
|
||||
`);
|
||||
}
|
||||
if (domain !== "homeassistant") {
|
||||
// No target, so an in-app path is also a valid destination here
|
||||
const manageUrl =
|
||||
sanitizeHttpUrl(domainInfo.manage_url) ??
|
||||
sanitizeNavigationPath(domainInfo.manage_url);
|
||||
sections.push(html`
|
||||
<div class="card-header">
|
||||
<h3>${domainToName(this.hass.localize, domain)}</h3>
|
||||
${
|
||||
!domainInfo.manage_url
|
||||
!manageUrl
|
||||
? ""
|
||||
: html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
class="manage"
|
||||
href=${domainInfo.manage_url}
|
||||
href=${manageUrl}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.info.system_health.manage"
|
||||
|
||||
@@ -43,11 +43,15 @@ class IntegrationsStartupTime extends LitElement {
|
||||
<ha-md-list>
|
||||
${this._setups?.map((setup) => {
|
||||
const manifest = this._manifests && this._manifests[setup.domain];
|
||||
const docLink = manifest
|
||||
? manifest.is_built_in
|
||||
? documentationUrl(this.hass, `/integrations/${manifest.domain}`)
|
||||
: manifest.documentation
|
||||
: "";
|
||||
const docLink =
|
||||
(manifest
|
||||
? manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${manifest.domain}`
|
||||
)
|
||||
: manifest.documentation
|
||||
: "") || "";
|
||||
|
||||
const setupSeconds = setup.seconds?.toFixed(2);
|
||||
return html`
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dump, JSON_SCHEMA, load } from "js-yaml";
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
isTemplate,
|
||||
} from "../../../../common/string/has-template";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../../common/url/sanitize-http-url";
|
||||
import { sanitizeNavigationPath } from "../../../../common/url/sanitize-navigation-path";
|
||||
import { extractSearchParam } from "../../../../common/url/search-params";
|
||||
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
|
||||
import type { HaProgressButton } from "../../../../components/buttons/ha-progress-button";
|
||||
@@ -562,7 +565,10 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
`
|
||||
: html`
|
||||
<a
|
||||
href=${resolved.url}
|
||||
href=${ifDefined(
|
||||
sanitizeHttpUrl(resolved.url) ??
|
||||
sanitizeNavigationPath(resolved.url)
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
><ha-button>
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import {
|
||||
mdiRestore,
|
||||
mdiTrashCanOutline,
|
||||
mdiViewSplitHorizontal,
|
||||
mdiViewSplitVertical,
|
||||
} from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-code-editor";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import type { HaIconButtonToolbarItem } from "../../../../components/ha-icon-button-toolbar";
|
||||
import "../../../../components/ha-label";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-split-panel";
|
||||
import type { HaSplitPanel } from "../../../../components/ha-split-panel";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tip";
|
||||
import type { RenderTemplateResult } from "../../../../data/ws-templates";
|
||||
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
|
||||
@@ -50,11 +60,18 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
|
||||
{ key: "docs_functions", path: "/template-functions/" },
|
||||
];
|
||||
|
||||
const STORAGE_KEY_TEMPLATE = "panel-dev-template-template";
|
||||
const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position";
|
||||
const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation";
|
||||
const DEFAULT_SPLIT_POSITION = 50;
|
||||
|
||||
type SplitOrientation = "horizontal" | "vertical";
|
||||
|
||||
@customElement("tools-template")
|
||||
class HaPanelDevTemplate extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@@ -66,9 +83,9 @@ class HaPanelDevTemplate extends LitElement {
|
||||
|
||||
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
|
||||
|
||||
@state() private _descriptionExpanded = false;
|
||||
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
|
||||
|
||||
@query("ha-tip") private _editorTip?: HTMLElement;
|
||||
@state() private _splitOrientation: SplitOrientation = "horizontal";
|
||||
|
||||
private _template = "";
|
||||
|
||||
@@ -78,8 +95,6 @@ class HaPanelDevTemplate extends LitElement {
|
||||
// its late-arriving results discarded.
|
||||
private _subscribeRequestId = 0;
|
||||
|
||||
private _tipResizeObserver?: ResizeObserver;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._template && !this._unsubRenderTemplate) {
|
||||
@@ -90,18 +105,25 @@ class HaPanelDevTemplate extends LitElement {
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeTemplate();
|
||||
this._tipResizeObserver?.disconnect();
|
||||
this._tipResizeObserver = undefined;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
if (localStorage && localStorage["panel-dev-template-template"]) {
|
||||
this._template = localStorage["panel-dev-template-template"];
|
||||
if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) {
|
||||
this._template = localStorage[STORAGE_KEY_TEMPLATE];
|
||||
} else {
|
||||
this._template = DEMO_TEMPLATE;
|
||||
}
|
||||
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
|
||||
if (storedPosition) {
|
||||
const parsed = parseFloat(storedPosition);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
|
||||
this._splitPosition = parsed;
|
||||
}
|
||||
}
|
||||
if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") {
|
||||
this._splitOrientation = "vertical";
|
||||
}
|
||||
this._subscribeTemplate();
|
||||
this._observeTipHeight();
|
||||
this._inited = true;
|
||||
}
|
||||
|
||||
@@ -114,15 +136,20 @@ class HaPanelDevTemplate extends LitElement {
|
||||
: "dict"
|
||||
: type;
|
||||
|
||||
const editorCard = this._renderEditorCard();
|
||||
const resultCard = this._renderResultCard(type, resultType);
|
||||
|
||||
// On narrow viewports side-by-side is too cramped, so force the (still
|
||||
// resizable) stacked layout and hide the orientation toggle.
|
||||
const orientation = this.narrow ? "vertical" : this._splitOrientation;
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<div class="about">
|
||||
<ha-expansion-panel
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.about"
|
||||
)}
|
||||
outlined
|
||||
.expanded=${this._descriptionExpanded}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div class="description">
|
||||
<p>
|
||||
@@ -164,92 +191,159 @@ class HaPanelDevTemplate extends LitElement {
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
<div
|
||||
class="content ${classMap({
|
||||
layout: !this.narrow,
|
||||
horizontal: !this.narrow,
|
||||
})}"
|
||||
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
|
||||
>
|
||||
<ha-card
|
||||
class="edit-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.editor"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
.value=${this._template}
|
||||
.error=${this._error}
|
||||
autofocus
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
@value-changed=${this._templateChanged}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._restoreDemo}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.reset"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._clear}>
|
||||
${this.hass.localize("ui.common.clear")}
|
||||
</ha-button>
|
||||
</div>
|
||||
<ha-tip>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.keyboard_tip",
|
||||
{
|
||||
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
|
||||
}
|
||||
)}
|
||||
</ha-tip>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
class="render-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result"
|
||||
)}
|
||||
>
|
||||
<div class="card-content ha-scrollbar">
|
||||
${
|
||||
this._rendering
|
||||
? html`<ha-spinner
|
||||
class="render-spinner"
|
||||
size="small"
|
||||
></ha-spinner>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert
|
||||
alert-type=${this._errorLevel?.toLowerCase() || "error"}
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._templateResult
|
||||
? html`<pre
|
||||
class="rendered ${classMap({
|
||||
[resultType]: resultType,
|
||||
})}"
|
||||
>
|
||||
<ha-split-panel
|
||||
class="panes ${orientation === "vertical" ? "vertical" : ""}"
|
||||
.position=${this._splitPosition}
|
||||
.orientation=${orientation}
|
||||
snap="50%"
|
||||
@wa-reposition=${this._splitRepositioned}
|
||||
>
|
||||
<div slot="start" class="pane">${editorCard}</div>
|
||||
<div slot="end" class="pane">${resultCard}</div>
|
||||
${this.narrow ? nothing : this._renderOrientationToggle()}
|
||||
</ha-split-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOrientationToggle() {
|
||||
const label = this.hass.localize(
|
||||
this._splitOrientation === "vertical"
|
||||
? "ui.panel.config.tools.tabs.templates.layout_side_by_side"
|
||||
: "ui.panel.config.tools.tabs.templates.layout_stacked"
|
||||
);
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
slot="divider"
|
||||
class="divider-toggle"
|
||||
.title=${label}
|
||||
aria-label=${label}
|
||||
@mousedown=${stopPropagation}
|
||||
@touchstart=${stopPropagation}
|
||||
@click=${this._toggleOrientation}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
this._splitOrientation === "vertical"
|
||||
? mdiViewSplitVertical
|
||||
: mdiViewSplitHorizontal
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Reset/clear live in the editor toolbar next to the built-in undo/redo,
|
||||
// copy, search and fullscreen buttons; the trailing divider separates them.
|
||||
private _editorToolbarItems = memoizeOne(
|
||||
(
|
||||
localize: HomeAssistant["localize"]
|
||||
): (HaIconButtonToolbarItem | string)[] => [
|
||||
{
|
||||
id: "restore-demo",
|
||||
label: localize("ui.panel.config.tools.tabs.templates.reset"),
|
||||
path: mdiRestore,
|
||||
action: () => this._restoreDemo(),
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
label: localize("ui.common.clear"),
|
||||
path: mdiTrashCanOutline,
|
||||
action: () => this._clear(),
|
||||
},
|
||||
"divider",
|
||||
]
|
||||
);
|
||||
|
||||
private _renderEditorCard() {
|
||||
return html`
|
||||
<ha-card
|
||||
class="edit-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.editor"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
.value=${this._template}
|
||||
.error=${this._error}
|
||||
.toolbarItems=${this._editorToolbarItems(this.hass.localize)}
|
||||
autofocus
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
@value-changed=${this._templateChanged}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
${
|
||||
this.narrow
|
||||
? nothing
|
||||
: html`
|
||||
<ha-tip>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.keyboard_tip",
|
||||
{
|
||||
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
|
||||
}
|
||||
)}
|
||||
</ha-tip>
|
||||
`
|
||||
}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderResultCard(type: string, resultType: string) {
|
||||
const showEmptyState =
|
||||
!this._error && !this._rendering && !this._template?.trim();
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class="render-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result"
|
||||
)}
|
||||
>
|
||||
<div class="card-content ha-scrollbar">
|
||||
${
|
||||
this._rendering
|
||||
? html`<ha-spinner
|
||||
class="render-spinner"
|
||||
size="small"
|
||||
></ha-spinner>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert
|
||||
alert-type=${this._errorLevel?.toLowerCase() || "error"}
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showEmptyState
|
||||
? html`<div class="empty">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_placeholder"
|
||||
)}
|
||||
</div>`
|
||||
: this._templateResult
|
||||
? html`
|
||||
<ha-label dense>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
</ha-label>
|
||||
<pre class="rendered">
|
||||
${
|
||||
type === "object"
|
||||
? JSON.stringify(this._templateResult.result, null, 2)
|
||||
: this._templateResult.result
|
||||
}</pre>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
</p>
|
||||
${
|
||||
this._templateResult.listeners.time
|
||||
? html`
|
||||
@@ -316,109 +410,179 @@ ${
|
||||
)}
|
||||
</span>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _observeTipHeight() {
|
||||
if (!this._editorTip || this._tipResizeObserver) {
|
||||
return;
|
||||
}
|
||||
this._tipResizeObserver = new ResizeObserver((entries) => {
|
||||
const height =
|
||||
entries[0]?.borderBoxSize?.[0]?.blockSize ??
|
||||
entries[0]?.contentRect.height;
|
||||
if (height) {
|
||||
this.style.setProperty("--tip-height", `${height}px`);
|
||||
}
|
||||
});
|
||||
this._tipResizeObserver.observe(this._editorTip);
|
||||
private _splitRepositioned(ev: Event) {
|
||||
this._splitPosition = (ev.target as HaSplitPanel).position;
|
||||
this._storeSplitPosition();
|
||||
}
|
||||
|
||||
private _expandedChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
|
||||
) {
|
||||
this._descriptionExpanded = ev.detail.expanded;
|
||||
private _toggleOrientation() {
|
||||
this._splitOrientation =
|
||||
this._splitOrientation === "vertical" ? "horizontal" : "vertical";
|
||||
if (this._inited) {
|
||||
localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation;
|
||||
}
|
||||
}
|
||||
|
||||
private _storeSplitPosition = debounce(
|
||||
() => {
|
||||
if (!this._inited) {
|
||||
return;
|
||||
}
|
||||
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
|
||||
},
|
||||
500,
|
||||
false
|
||||
);
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-4);
|
||||
.about {
|
||||
flex: none;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.content:has(ha-expansion-panel) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content.horizontal {
|
||||
--panel-header-height: calc(
|
||||
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
|
||||
var(--ha-font-size-m) + 1px + 2px
|
||||
);
|
||||
--description-pane-height: calc(
|
||||
var(--ha-space-4) + 48px +
|
||||
(
|
||||
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
|
||||
var(--ha-space-1) * 2
|
||||
) *
|
||||
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
|
||||
);
|
||||
--card-header-height: calc(
|
||||
var(--ha-space-3) + var(--ha-space-4) +
|
||||
var(--ha-line-height-expanded) *
|
||||
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
|
||||
);
|
||||
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
|
||||
--tip-height-minimal: calc(
|
||||
var(--mdc-icon-size, 24px) + var(--ha-space-4)
|
||||
);
|
||||
--edit-pane-height: calc(
|
||||
100vh - var(--panel-header-height) - var(
|
||||
--description-pane-height
|
||||
) - var(--ha-space-4) *
|
||||
2
|
||||
);
|
||||
--code-mirror-max-height: calc(
|
||||
var(--edit-pane-height) - var(--card-header-height) +
|
||||
var(--ha-space-2) - var(--card-actions-height) - var(
|
||||
--tip-height,
|
||||
var(--tip-height-minimal)
|
||||
) - var(--ha-space-4) - var(--ha-card-border-width, 1px) *
|
||||
2
|
||||
);
|
||||
.about a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-space-4);
|
||||
--ha-split-panel-min: 20%;
|
||||
--ha-split-panel-max: 80%;
|
||||
--ha-split-panel-divider-hit-area: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* On wide viewports we slot our own handle (the orientation toggle)
|
||||
into the divider, so hide the default grip. On narrow there is no
|
||||
toggle, so keep the default grip as the resize affordance. */
|
||||
:host(:not([narrow])) .panes {
|
||||
--ha-split-panel-grip-display: none;
|
||||
}
|
||||
|
||||
/* Orientation toggle that lives on the divider. Clicking it toggles
|
||||
orientation; resizing is done by dragging the divider elsewhere. */
|
||||
.divider-toggle {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 50%;
|
||||
background-color: var(--card-background-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
--mdc-icon-size: 16px;
|
||||
transition:
|
||||
color var(--ha-animation-duration-fast) ease-out,
|
||||
border-color var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.divider-toggle:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.divider-toggle:focus-visible {
|
||||
outline: none;
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
contain: size;
|
||||
}
|
||||
|
||||
.pane[slot="start"] {
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.pane[slot="end"] {
|
||||
padding-inline-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.panes.vertical .pane[slot="start"] {
|
||||
padding-inline-end: 0;
|
||||
padding-block-end: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.panes.vertical .pane[slot="end"] {
|
||||
padding-inline-start: 0;
|
||||
padding-block-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.pane ha-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-pane .card-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.edit-pane ha-code-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
--code-mirror-height: 100%;
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.edit-pane {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.edit-pane a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.content.horizontal > * {
|
||||
width: 50%;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.render-spinner {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
@@ -428,10 +592,24 @@ ${
|
||||
}
|
||||
|
||||
ha-alert {
|
||||
margin-bottom: var(--ha-space-2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.render-pane ha-label {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
padding: var(--ha-space-4);
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.rendered {
|
||||
font-family: var(--ha-font-family-code);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
@@ -439,6 +617,7 @@ ${
|
||||
clear: both;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--secondary-background-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding: var(--ha-space-2);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
@@ -447,7 +626,7 @@ ${
|
||||
|
||||
p,
|
||||
ul {
|
||||
margin-block-end: 0;
|
||||
margin-block: 0;
|
||||
}
|
||||
.description > p {
|
||||
margin-block-start: 0;
|
||||
@@ -468,26 +647,6 @@ ${
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane .card-content {
|
||||
overflow: auto;
|
||||
max-height: calc(
|
||||
var(--code-mirror-max-height) +
|
||||
47px - var(--ha-card-border-radius, var(--ha-border-radius-lg))
|
||||
);
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane {
|
||||
overflow: hidden;
|
||||
padding-bottom: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
}
|
||||
|
||||
.all_listeners {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
@@ -507,19 +666,6 @@ ${
|
||||
background-color: var(--secondary-background-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media all and (max-width: 870px) {
|
||||
.content ha-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
}
|
||||
.card-actions > ha-button:last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -615,7 +761,7 @@ ${
|
||||
if (!this._inited) {
|
||||
return;
|
||||
}
|
||||
localStorage["panel-dev-template-template"] = this._template;
|
||||
localStorage[STORAGE_KEY_TEMPLATE] = this._template;
|
||||
}
|
||||
|
||||
private async _restoreDemo() {
|
||||
@@ -631,7 +777,7 @@ ${
|
||||
}
|
||||
this._template = DEMO_TEMPLATE;
|
||||
this._subscribeTemplate();
|
||||
delete localStorage["panel-dev-template-template"];
|
||||
delete localStorage[STORAGE_KEY_TEMPLATE];
|
||||
}
|
||||
|
||||
private async _clear() {
|
||||
@@ -647,12 +793,8 @@ ${
|
||||
}
|
||||
this._unsubscribeTemplate();
|
||||
this._template = "";
|
||||
// Reset to empty result. Setting to 'undefined' results in a different visual
|
||||
// behaviour compared to manually emptying the template input box.
|
||||
this._templateResult = {
|
||||
result: "",
|
||||
listeners: { all: false, entities: [], domains: [], time: false },
|
||||
};
|
||||
// An empty template shows the placeholder empty state.
|
||||
this._templateResult = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
@@ -15,6 +16,19 @@ class HaPanelIframe extends LitElement {
|
||||
@property({ attribute: false }) panel!: PanelInfo<{ url: string }>;
|
||||
|
||||
render() {
|
||||
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
|
||||
// the frontend's own origin
|
||||
if (sanitizeUrl(this.panel.config.url) === "about:blank") {
|
||||
return html`
|
||||
<hass-error-screen
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
error="Unable to load iframes with this URL."
|
||||
rootnav
|
||||
></hass-error-screen>
|
||||
`;
|
||||
}
|
||||
|
||||
if (
|
||||
location.protocol === "https:" &&
|
||||
new URL(this.panel.config.url, location.toString()).protocol !== "https:"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
@@ -52,6 +53,12 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
|
||||
throw new Error("URL required");
|
||||
}
|
||||
|
||||
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
|
||||
// the frontend's own origin
|
||||
if (sanitizeUrl(config.url) === "about:blank") {
|
||||
throw new Error("Invalid URL");
|
||||
}
|
||||
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { forwardHaptic } from "../../../data/haptics";
|
||||
@@ -133,7 +134,7 @@ export const handleAction = async (
|
||||
break;
|
||||
case "url": {
|
||||
if (actionConfig.url_path) {
|
||||
window.open(actionConfig.url_path);
|
||||
window.open(sanitizeUrl(actionConfig.url_path));
|
||||
} else {
|
||||
showToast(node, {
|
||||
message: hass.localize("ui.panel.lovelace.cards.actions.no_url"),
|
||||
|
||||
@@ -54,7 +54,7 @@ export class HuiNumericInputCardFeatureEditor
|
||||
}
|
||||
|
||||
const data: NumericInputCardFeatureConfig = {
|
||||
style: "buttons",
|
||||
style: "slider",
|
||||
...this._config,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import {
|
||||
getCustomBadgeEntry,
|
||||
getCustomCardEntry,
|
||||
@@ -38,7 +39,9 @@ export const getCardDocumentationURL = (
|
||||
type: string
|
||||
): string | undefined => {
|
||||
if (isCustomType(type)) {
|
||||
return getCustomCardEntry(stripCustomPrefix(type))?.documentationURL;
|
||||
return sanitizeHttpUrl(
|
||||
getCustomCardEntry(stripCustomPrefix(type))?.documentationURL
|
||||
);
|
||||
}
|
||||
|
||||
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_CARD_URLS[type] || type}`;
|
||||
@@ -49,7 +52,9 @@ export const getBadgeDocumentationURL = (
|
||||
type: string
|
||||
): string | undefined => {
|
||||
if (isCustomType(type)) {
|
||||
return getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL;
|
||||
return sanitizeHttpUrl(
|
||||
getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL
|
||||
);
|
||||
}
|
||||
|
||||
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_BADGE_URLS[type] || "badges"}`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
@@ -16,6 +17,11 @@ class HuiWeblinkRow extends LitElement implements LovelaceRow {
|
||||
throw new Error("URL required");
|
||||
}
|
||||
|
||||
// Reject schemes that would run script in the frontend's origin
|
||||
if (sanitizeUrl(config.url) === "about:blank") {
|
||||
throw new Error("Invalid URL");
|
||||
}
|
||||
|
||||
this._config = {
|
||||
icon: "mdi:link",
|
||||
name: config.url,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { subscribeThemePreferences, saveThemePreferences } from "../data/theme";
|
||||
import { subscribeThemes } from "../data/ws-themes";
|
||||
import type { Constructor, HomeAssistant } from "../types";
|
||||
import { updateLaunchScreenLogo } from "../util/launch-screen";
|
||||
import { storeState } from "../util/ha-pref-storage";
|
||||
import type { HassBaseEl } from "./hass-base-mixin";
|
||||
|
||||
@@ -145,6 +146,8 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
||||
true
|
||||
);
|
||||
|
||||
updateLaunchScreenLogo(darkMode);
|
||||
|
||||
if (darkMode !== this.hass.themes.darkMode) {
|
||||
this._updateHass({
|
||||
themes: { ...this.hass.themes!, darkMode },
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"ui": {
|
||||
"init": {
|
||||
"loading": "Loading data",
|
||||
"loading": "Loading...",
|
||||
"migration": "Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient.",
|
||||
"project_from": "A project from the",
|
||||
"error": {
|
||||
@@ -3955,6 +3955,9 @@
|
||||
"about": "About templates",
|
||||
"editor": "Template editor",
|
||||
"result": "Result",
|
||||
"result_placeholder": "Your template result will appear here.",
|
||||
"layout_stacked": "Drag to resize, click for stacked view",
|
||||
"layout_side_by_side": "Drag to resize, click for side-by-side view",
|
||||
"reset": "Reset to demo template",
|
||||
"confirm_reset": "Do you want to reset your current template back to the demo template?",
|
||||
"confirm_clear": "Do you want to clear your current template?",
|
||||
|
||||
+24
-18
@@ -1,12 +1,11 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { render } from "lit";
|
||||
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
|
||||
import { withViewTransition } from "../common/util/view-transition";
|
||||
|
||||
let removalInitiated = false;
|
||||
|
||||
/**
|
||||
* Removes the launch screen with a fade-out view transition.
|
||||
* Removes the launch screen with a CSS fade-out transition.
|
||||
*
|
||||
* @param instant - Removes the launch screen without animation. Used when the
|
||||
* external app covers the frontend with its own splash screen until the
|
||||
@@ -26,23 +25,16 @@ export const removeLaunchScreen = (instant = false): boolean => {
|
||||
return true;
|
||||
}
|
||||
|
||||
withViewTransition((viewTransitionAvailable) => {
|
||||
if (viewTransitionAvailable) {
|
||||
launchScreenElement.classList.add("removing");
|
||||
const durationFromCss = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--ha-animation-duration-normal")
|
||||
.trim();
|
||||
setTimeout(
|
||||
() => {
|
||||
launchScreenElement.parentElement?.removeChild(launchScreenElement);
|
||||
return;
|
||||
}
|
||||
|
||||
launchScreenElement.classList.add("removing");
|
||||
const durationFromCss = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--ha-animation-duration-normal")
|
||||
.trim();
|
||||
setTimeout(
|
||||
() => {
|
||||
launchScreenElement.parentElement?.removeChild(launchScreenElement);
|
||||
},
|
||||
parseAnimationDuration(durationFromCss || "250ms")
|
||||
);
|
||||
});
|
||||
},
|
||||
parseAnimationDuration(durationFromCss || "250ms")
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -57,6 +49,20 @@ export const renderLaunchScreenContent = (
|
||||
updateLaunchScreenAttribution(attribution);
|
||||
};
|
||||
|
||||
/**
|
||||
* Switches the launch screen OHF logo to the variant matching the applied
|
||||
* theme. The `<picture>` element initially picks a variant based on the system
|
||||
* color scheme, which can differ from the theme the frontend ends up applying.
|
||||
*/
|
||||
export const updateLaunchScreenLogo = (darkMode: boolean) => {
|
||||
const logoSourceElement = document.querySelector<HTMLSourceElement>(
|
||||
"#ha-launch-screen .ohf-logo source"
|
||||
);
|
||||
if (logoSourceElement) {
|
||||
logoSourceElement.media = darkMode ? "all" : "not all";
|
||||
}
|
||||
};
|
||||
|
||||
export const updateLaunchScreenAttribution = (attribution: string) => {
|
||||
const attributionElement = document.getElementById(
|
||||
"ha-launch-screen-attribution"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
homeAssistantUrlToPath,
|
||||
isHomeAssistantUrl,
|
||||
sanitizeLinkUrl,
|
||||
sanitizeHttpUrl,
|
||||
} from "../../../src/common/url/sanitize-http-url";
|
||||
|
||||
describe("sanitizeHttpUrl", () => {
|
||||
it("keeps http and https URLs", () => {
|
||||
expect(
|
||||
sanitizeHttpUrl("https://www.home-assistant.io/integrations/hue")
|
||||
).toEqual("https://www.home-assistant.io/integrations/hue");
|
||||
expect(sanitizeHttpUrl("http://192.168.1.5:8080/setup")).toEqual(
|
||||
"http://192.168.1.5:8080/setup"
|
||||
);
|
||||
});
|
||||
|
||||
/* eslint-disable no-script-url */
|
||||
it("rejects URIs that can execute script", () => {
|
||||
expect(sanitizeHttpUrl("javascript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeHttpUrl("JavaScript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeHttpUrl("java\tscript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeHttpUrl(" javascript:alert(1)")).toBeUndefined();
|
||||
expect(
|
||||
sanitizeHttpUrl("data:text/html,<script>alert(1)</script>")
|
||||
).toBeUndefined();
|
||||
expect(sanitizeHttpUrl("vbscript:msgbox(1)")).toBeUndefined();
|
||||
});
|
||||
/* eslint-enable no-script-url */
|
||||
|
||||
it("rejects other schemes and unparseable values", () => {
|
||||
expect(sanitizeHttpUrl("homeassistant://config/system")).toBeUndefined();
|
||||
expect(sanitizeHttpUrl("file:///etc/passwd")).toBeUndefined();
|
||||
expect(sanitizeHttpUrl("about:blank")).toBeUndefined();
|
||||
expect(sanitizeHttpUrl("/config/system")).toBeUndefined();
|
||||
expect(sanitizeHttpUrl("not a url")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects missing values", () => {
|
||||
expect(sanitizeHttpUrl(undefined)).toBeUndefined();
|
||||
expect(sanitizeHttpUrl(null)).toBeUndefined();
|
||||
expect(sanitizeHttpUrl("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHomeAssistantUrl", () => {
|
||||
it("detects the Home Assistant scheme", () => {
|
||||
expect(isHomeAssistantUrl("homeassistant://config/system")).toBe(true);
|
||||
expect(isHomeAssistantUrl("https://www.home-assistant.io/")).toBe(false);
|
||||
expect(isHomeAssistantUrl(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("homeAssistantUrlToPath", () => {
|
||||
it("rewrites a deep link to an in-app path", () => {
|
||||
expect(homeAssistantUrlToPath("homeassistant://config/system")).toEqual(
|
||||
"/config/system"
|
||||
);
|
||||
expect(homeAssistantUrlToPath("homeassistant://config/network")).toEqual(
|
||||
"/config/network"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a deep link that leaves the frontend", () => {
|
||||
// A plain scheme rewrite would turn this into "//example.com".
|
||||
expect(
|
||||
homeAssistantUrlToPath("homeassistant:///example.com")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
homeAssistantUrlToPath("homeassistant:///\\example.com")
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects anything that is not a deep link", () => {
|
||||
expect(homeAssistantUrlToPath("https://example.com/")).toBeUndefined();
|
||||
expect(homeAssistantUrlToPath(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeLinkUrl", () => {
|
||||
it("handles both external links and deep links", () => {
|
||||
expect(sanitizeLinkUrl("https://example.com/docs")).toEqual(
|
||||
"https://example.com/docs"
|
||||
);
|
||||
expect(sanitizeLinkUrl("homeassistant://config/system")).toEqual(
|
||||
"/config/system"
|
||||
);
|
||||
// eslint-disable-next-line no-script-url
|
||||
expect(sanitizeLinkUrl("javascript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeLinkUrl("homeassistant:///example.com")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationManifest } from "../../src/data/integration";
|
||||
import {
|
||||
fetchIntegrationManifest,
|
||||
fetchIntegrationManifests,
|
||||
integrationIssuesUrl,
|
||||
} from "../../src/data/integration";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
const manifest = (
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest =>
|
||||
({
|
||||
domain: "evil",
|
||||
name: "Evil",
|
||||
is_built_in: false,
|
||||
config_flow: false,
|
||||
iot_class: "local_polling",
|
||||
documentation: "https://example.com/docs",
|
||||
...overrides,
|
||||
}) as IntegrationManifest;
|
||||
|
||||
const hassWith = (result: unknown) =>
|
||||
({ callWS: vi.fn().mockResolvedValue(result) }) as unknown as HomeAssistant;
|
||||
|
||||
// A custom integration ships its own manifest, so these URLs are untrusted.
|
||||
/* eslint-disable no-script-url */
|
||||
const UNSAFE_URL = "javascript:alert(1)";
|
||||
|
||||
describe("integration manifests", () => {
|
||||
it("strips unsafe URLs from a fetched list", async () => {
|
||||
const hass = hassWith([
|
||||
manifest({ documentation: UNSAFE_URL, issue_tracker: UNSAFE_URL }),
|
||||
]);
|
||||
|
||||
const [fetched] = await fetchIntegrationManifests(hass);
|
||||
|
||||
expect(fetched.documentation).toBeUndefined();
|
||||
expect(fetched.issue_tracker).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips unsafe URLs from a single fetched manifest", async () => {
|
||||
const hass = hassWith(manifest({ documentation: UNSAFE_URL }));
|
||||
|
||||
expect((await fetchIntegrationManifest(hass, "evil"))!.documentation).toBe(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps http and https URLs", async () => {
|
||||
const hass = hassWith([
|
||||
manifest({
|
||||
documentation: "https://example.com/docs",
|
||||
issue_tracker: "http://example.com/issues",
|
||||
}),
|
||||
]);
|
||||
|
||||
const [fetched] = await fetchIntegrationManifests(hass);
|
||||
|
||||
expect(fetched.documentation).toEqual("https://example.com/docs");
|
||||
expect(fetched.issue_tracker).toEqual("http://example.com/issues");
|
||||
});
|
||||
|
||||
it("does not mutate the received manifest", async () => {
|
||||
const received = manifest({ documentation: UNSAFE_URL });
|
||||
const hass = hassWith([received]);
|
||||
|
||||
await fetchIntegrationManifests(hass);
|
||||
|
||||
expect(received.documentation).toEqual(UNSAFE_URL);
|
||||
});
|
||||
|
||||
it("falls back to the core issue tracker for an unsafe issue_tracker", () => {
|
||||
expect(
|
||||
integrationIssuesUrl("evil", manifest({ issue_tracker: UNSAFE_URL }))
|
||||
).toContain("https://github.com/home-assistant/core/issues");
|
||||
});
|
||||
});
|
||||
/* eslint-enable no-script-url */
|
||||
@@ -1,5 +1,12 @@
|
||||
import { render } from "lit";
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
import "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
|
||||
import type { HaEnergyPowerConfig } from "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
|
||||
import type { PowerConfig } from "../../../../src/data/energy";
|
||||
import {
|
||||
getPowerHelperEntityId,
|
||||
type PowerType,
|
||||
} from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
|
||||
describe("getPowerHelperEntityId", () => {
|
||||
it("returns the helper for an inverted config", () => {
|
||||
@@ -69,3 +76,100 @@ describe("getPowerHelperEntityId", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Renders the template directly so the async unit lookup in willUpdate is
|
||||
// skipped. localize echoes the key back.
|
||||
const renderPickers = (powerType: PowerType, powerConfig: PowerConfig) => {
|
||||
const el = document.createElement(
|
||||
"ha-energy-power-config"
|
||||
) as HaEnergyPowerConfig;
|
||||
el.hass = { localize: (key: string) => key } as any;
|
||||
el.powerType = powerType;
|
||||
el.powerConfig = powerConfig;
|
||||
|
||||
const container = document.createElement("div");
|
||||
render((el as any).render(), container);
|
||||
|
||||
return [...container.querySelectorAll("ha-statistic-picker")].map(
|
||||
(picker: any) => ({
|
||||
required: picker.required,
|
||||
invalid: picker.invalid,
|
||||
errorMessage: picker.errorMessage,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
describe("ha-energy-power-config required power statistic", () => {
|
||||
it("renders no picker when no power sensor is configured", () => {
|
||||
assert.lengthOf(renderPickers("none", {}), 0);
|
||||
});
|
||||
|
||||
it("marks an empty standard statistic as required and invalid", () => {
|
||||
assert.deepEqual(renderPickers("standard", {}), [
|
||||
{
|
||||
required: true,
|
||||
invalid: true,
|
||||
errorMessage: "ui.common.error_required",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the statistic required but valid once it is set", () => {
|
||||
const [picker] = renderPickers("standard", { stat_rate: "sensor.power" });
|
||||
assert.isTrue(picker.required);
|
||||
assert.isFalse(picker.invalid);
|
||||
});
|
||||
|
||||
it("marks an empty inverted statistic as required and invalid", () => {
|
||||
const [picker] = renderPickers("inverted", {});
|
||||
assert.isTrue(picker.required);
|
||||
assert.isTrue(picker.invalid);
|
||||
});
|
||||
|
||||
it("does not flag the inverted statistic when it is set", () => {
|
||||
const [picker] = renderPickers("inverted", {
|
||||
stat_rate_inverted: "sensor.power",
|
||||
});
|
||||
assert.isFalse(picker.invalid);
|
||||
});
|
||||
|
||||
it("flags both two sensor statistics while they are empty", () => {
|
||||
const pickers = renderPickers("two_sensors", {});
|
||||
assert.lengthOf(pickers, 2);
|
||||
assert.deepEqual(
|
||||
pickers.map((p) => p.invalid),
|
||||
[true, true]
|
||||
);
|
||||
});
|
||||
|
||||
// The two sensor statistics exclude each other, so they keep their clear
|
||||
// button — and therefore no required marker — to stay swappable.
|
||||
it("does not mark the two sensor statistics as required", () => {
|
||||
const pickers = renderPickers("two_sensors", {});
|
||||
assert.deepEqual(
|
||||
pickers.map((p) => p.required),
|
||||
[false, false]
|
||||
);
|
||||
});
|
||||
|
||||
it("flags only the statistic that is still missing", () => {
|
||||
const pickers = renderPickers("two_sensors", {
|
||||
stat_rate_from: "sensor.power_from",
|
||||
});
|
||||
assert.deepEqual(
|
||||
pickers.map((p) => p.invalid),
|
||||
[false, true]
|
||||
);
|
||||
});
|
||||
|
||||
it("clears both flags once the two sensor pair is complete", () => {
|
||||
const pickers = renderPickers("two_sensors", {
|
||||
stat_rate_from: "sensor.power_from",
|
||||
stat_rate_to: "sensor.power_to",
|
||||
});
|
||||
assert.deepEqual(
|
||||
pickers.map((p) => p.invalid),
|
||||
[false, false]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
const hass = { localize: (key: string) => key } as unknown as HomeAssistant;
|
||||
|
||||
const openUrl = (url: string) => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
open.mockClear();
|
||||
handleAction(
|
||||
document.createElement("div"),
|
||||
hass,
|
||||
{ tap_action: { action: "url", url_path: url } },
|
||||
"tap"
|
||||
);
|
||||
return open.mock.calls[0]?.[0];
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("handleAction url", () => {
|
||||
it("opens a configured URL", () => {
|
||||
expect(openUrl("https://example.com/page")).toEqual(
|
||||
"https://example.com/page"
|
||||
);
|
||||
expect(openUrl("mailto:someone@example.com")).toEqual(
|
||||
"mailto:someone@example.com"
|
||||
);
|
||||
});
|
||||
|
||||
/* eslint-disable no-script-url */
|
||||
it("does not open a URL that runs script", () => {
|
||||
expect(openUrl("javascript:alert(1)")).toEqual("about:blank");
|
||||
expect(openUrl("JaVaScRiPt:alert(1)")).toEqual("about:blank");
|
||||
expect(openUrl("data:text/html,<script>alert(1)</script>")).toEqual(
|
||||
"about:blank"
|
||||
);
|
||||
});
|
||||
/* eslint-enable no-script-url */
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import "../../../src/panels/lovelace/cards/hui-iframe-card";
|
||||
import "../../../src/panels/lovelace/special-rows/hui-weblink-row";
|
||||
|
||||
/* eslint-disable no-script-url */
|
||||
const UNSAFE_URLS = [
|
||||
"javascript:alert(1)",
|
||||
"JaVaScRiPt:alert(1)",
|
||||
"java\tscript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
];
|
||||
/* eslint-enable no-script-url */
|
||||
|
||||
const SAFE_URLS = [
|
||||
"https://example.com/page",
|
||||
"http://192.168.1.5:8080/",
|
||||
"mailto:someone@example.com",
|
||||
"/local/page.html",
|
||||
// Forms that sanitizing rewrites, so they must not be compared to the input
|
||||
"http://example.com",
|
||||
"https://example.com/foo bar",
|
||||
"https://Example.com/Path",
|
||||
];
|
||||
|
||||
describe("hui-weblink-row config", () => {
|
||||
const row = () => document.createElement("hui-weblink-row") as any;
|
||||
|
||||
it.each(SAFE_URLS)("accepts %s", (url) => {
|
||||
expect(() => row().setConfig({ url })).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(UNSAFE_URLS)("rejects %s", (url) => {
|
||||
expect(() => row().setConfig({ url })).toThrow("Invalid URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hui-iframe-card config", () => {
|
||||
const card = () => document.createElement("hui-iframe-card") as any;
|
||||
|
||||
it.each(SAFE_URLS)("accepts %s", (url) => {
|
||||
expect(() => card().setConfig({ type: "iframe", url })).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(UNSAFE_URLS)("rejects %s", (url) => {
|
||||
expect(() => card().setConfig({ type: "iframe", url })).toThrow(
|
||||
"Invalid URL"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user