From 03824945aa72e212d3048109176054c913042be4 Mon Sep 17 00:00:00 2001 From: karwosts Date: Fri, 17 Oct 2025 17:29:12 +0000 Subject: [PATCH 1/2] Live inline template previews --- src/components/ha-code-editor.ts | 24 ++++ .../ha-selector/ha-selector-template.ts | 116 ++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index bd890be1fe84..2012bf8598d5 100644 --- a/src/components/ha-code-editor.ts +++ b/src/components/ha-code-editor.ts @@ -13,6 +13,7 @@ import { mdiArrowCollapse, mdiArrowExpand, mdiContentCopy, + mdiFlask, mdiRedo, mdiUndo, } from "@mdi/js"; @@ -36,6 +37,7 @@ import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar"; declare global { interface HASSDomEvents { "editor-save": undefined; + "test-toggle": undefined; } } @@ -82,6 +84,9 @@ export class HaCodeEditor extends ReactiveElement { @property({ type: Boolean, attribute: "has-toolbar" }) public hasToolbar = true; + @property({ type: Boolean, attribute: "has-test" }) + public hasTest = false; + @property({ type: String }) public placeholder?: string; @state() private _value = ""; @@ -359,6 +364,16 @@ export class HaCodeEditor extends ReactiveElement { } this._editorToolbar.items = [ + ...(this.hasTest + ? [ + { + id: "test", + label: this.hass?.localize("ui.common.test") || "Test", + path: mdiFlask, + action: (e: Event) => this._handleTestClick(e), + }, + ] + : []), { id: "undo", disabled: !this._canUndo, @@ -416,6 +431,15 @@ export class HaCodeEditor extends ReactiveElement { } }; + private _handleTestClick = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + if (!this.codemirror) { + return; + } + fireEvent(this, "test-toggle"); + }; + private _handleUndoClick = (e: Event) => { e.preventDefault(); e.stopPropagation(); diff --git a/src/components/ha-selector/ha-selector-template.ts b/src/components/ha-selector/ha-selector-template.ts index ac4a2f193cec..f4e21aad17f6 100644 --- a/src/components/ha-selector/ha-selector-template.ts +++ b/src/components/ha-selector/ha-selector-template.ts @@ -1,11 +1,16 @@ +import type { PropertyValues } from "lit"; import { css, html, nothing, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import { fireEvent } from "../../common/dom/fire_event"; import type { HomeAssistant } from "../../types"; import { documentationUrl } from "../../util/documentation-url"; import "../ha-code-editor"; import "../ha-input-helper-text"; import "../ha-alert"; +import type { RenderTemplateResult } from "../../data/ws-templates"; +import { subscribeRenderTemplate } from "../../data/ws-templates"; +import { debounce } from "../../common/util/debounce"; const WARNING_STRINGS = ["template:", "sensor:", "state:", "trigger: template"]; @@ -27,6 +32,37 @@ export class HaTemplateSelector extends LitElement { @state() private warn: string | undefined = undefined; + @state() private _test = false; + + @state() private _error?: string; + + @state() private _errorLevel?: "ERROR" | "WARNING"; + + @state() private _templateResult?: RenderTemplateResult; + + @state() private _unsubRenderTemplate?: Promise; + + private _debounceError = debounce( + (error, level) => { + this._error = error; + this._errorLevel = level; + this._templateResult = undefined; + }, + 500, + false + ); + + public disconnectedCallback() { + super.disconnectedCallback(); + this._debounceError.cancel(); + } + + protected updated(changedProps: PropertyValues) { + if (changedProps.has("value") && this._test) { + this._subscribeTemplate(); + } + } + protected render() { return html` ${this.warn @@ -61,10 +97,23 @@ export class HaTemplateSelector extends LitElement { autofocus autocomplete-entities autocomplete-icons + has-test @value-changed=${this._handleChange} + @test-toggle=${this._testToggle} dir="ltr" linewrap > + ${this._test && this._error + ? html`${this._error}` + : this._test && this._templateResult + ? html` +
+${typeof this._templateResult.result === "object"
+                  ? JSON.stringify(this._templateResult.result, null, 2)
+                  : this._templateResult.result}
+
` + : nothing} ${this.helper ? html`${this.helper} { + if ("error" in result) { + // We show the latest error, or a warning if there are no errors + if (result.level === "ERROR" || this._errorLevel !== "ERROR") { + this._debounceError(result.error, result.level); + } + } else { + this._debounceError.cancel(); + this._error = undefined; + this._errorLevel = undefined; + this._templateResult = result; + } + }, + { + template, + timeout: 3, + report_errors: true, + } + ); + await this._unsubRenderTemplate; + } catch (err: any) { + this._error = "Unknown error"; + this._errorLevel = undefined; + if (err.message) { + this._error = err.message; + this._errorLevel = undefined; + this._templateResult = undefined; + } + this._unsubRenderTemplate = undefined; + } + } + + private async _unsubscribeTemplate(): Promise { + if (!this._unsubRenderTemplate) { + return; + } + + try { + const unsub = await this._unsubRenderTemplate; + unsub(); + this._unsubRenderTemplate = undefined; + } catch (err: any) { + if (err.code === "not_found") { + // If we get here, the connection was probably already closed. Ignore. + } else { + throw err; + } + } + } + private _handleChange(ev) { ev.stopPropagation(); let value = ev.target.value; From 03ccd3983b2ad1b9c5689b412bd7be4d9d1a75b2 Mon Sep 17 00:00:00 2001 From: karwosts Date: Sat, 18 Oct 2025 13:16:31 +0000 Subject: [PATCH 2/2] Opt out for markdown, no fullscreen --- src/components/ha-code-editor.ts | 2 +- src/components/ha-selector/ha-selector-template.ts | 5 ++++- src/data/selector.ts | 4 +++- .../editor/config-elements/hui-markdown-card-editor.ts | 6 +++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index 2012bf8598d5..50f634a78d4a 100644 --- a/src/components/ha-code-editor.ts +++ b/src/components/ha-code-editor.ts @@ -364,7 +364,7 @@ export class HaCodeEditor extends ReactiveElement { } this._editorToolbar.items = [ - ...(this.hasTest + ...(this.hasTest && !this._isFullscreen ? [ { id: "test", diff --git a/src/components/ha-selector/ha-selector-template.ts b/src/components/ha-selector/ha-selector-template.ts index f4e21aad17f6..3b6bca1b0292 100644 --- a/src/components/ha-selector/ha-selector-template.ts +++ b/src/components/ha-selector/ha-selector-template.ts @@ -11,6 +11,7 @@ import "../ha-alert"; import type { RenderTemplateResult } from "../../data/ws-templates"; import { subscribeRenderTemplate } from "../../data/ws-templates"; import { debounce } from "../../common/util/debounce"; +import type { TemplateSelector } from "../../data/selector"; const WARNING_STRINGS = ["template:", "sensor:", "state:", "trigger: template"]; @@ -18,6 +19,8 @@ const WARNING_STRINGS = ["template:", "sensor:", "state:", "trigger: template"]; export class HaTemplateSelector extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) public selector!: TemplateSelector; + @property() public value?: string; @property() public label?: string; @@ -97,7 +100,7 @@ export class HaTemplateSelector extends LitElement { autofocus autocomplete-entities autocomplete-icons - has-test + .hasTest=${this.selector.template?.preview !== false} @value-changed=${this._handleChange} @test-toggle=${this._testToggle} dir="ltr" diff --git a/src/data/selector.ts b/src/data/selector.ts index ae84dbcd2867..560603254ae5 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -461,7 +461,9 @@ export interface TargetSelector { } export interface TemplateSelector { - template: {} | null; + template: { + preview?: boolean; + } | null; } export interface ThemeSelector { diff --git a/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts index 2e33bd0f3975..77461696f948 100644 --- a/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-markdown-card-editor.ts @@ -63,7 +63,11 @@ export class HuiMarkdownCardEditor ...(!text_only ? ([{ name: "title", selector: { text: {} } }] as const) : []), - { name: "content", required: true, selector: { template: {} } }, + { + name: "content", + required: true, + selector: { template: { preview: false } }, + }, ] as const satisfies HaFormSchema[] );