From 408735fa7771591f1e38812e23c6aa6ceaf3100b Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 28 Jan 2026 01:57:41 -0800 Subject: [PATCH] Live inline template previews (#27557) * Live inline template previews * Opt out for markdown, no fullscreen * flask/flaskOff * styling * mdiBug * Update src/components/ha-selector/ha-selector-template.ts Co-authored-by: Petar Petrov * Apply suggestions from code review Co-authored-by: Petar Petrov * resub on connect --------- Co-authored-by: Petar Petrov --- src/components/ha-code-editor.ts | 33 ++++- .../ha-selector/ha-selector-template.ts | 135 ++++++++++++++++++ src/data/selector.ts | 4 +- .../hui-markdown-card-editor.ts | 6 +- src/translations/en.json | 4 +- 5 files changed, 178 insertions(+), 4 deletions(-) diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index 059594ba6f..87feadec13 100644 --- a/src/components/ha-code-editor.ts +++ b/src/components/ha-code-editor.ts @@ -13,6 +13,8 @@ import { mdiArrowCollapse, mdiArrowExpand, mdiContentCopy, + mdiBug, + mdiBugOutline, mdiFindReplace, mdiRedo, mdiUndo, @@ -37,6 +39,7 @@ import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar"; declare global { interface HASSDomEvents { "editor-save": undefined; + "test-toggle": undefined; } } @@ -83,6 +86,11 @@ export class HaCodeEditor extends ReactiveElement { @property({ type: Boolean, attribute: "has-toolbar" }) public hasToolbar = true; + @property({ type: Boolean, attribute: "has-test" }) + public hasTest = false; + + @property({ attribute: false }) public testing = false; + @property({ type: String }) public placeholder?: string; @state() private _value = ""; @@ -214,7 +222,8 @@ export class HaCodeEditor extends ReactiveElement { if ( changedProps.has("_canCopy") || changedProps.has("_canUndo") || - changedProps.has("_canRedo") + changedProps.has("_canRedo") || + changedProps.has("testing") ) { this._updateToolbarButtons(); } @@ -362,6 +371,19 @@ export class HaCodeEditor extends ReactiveElement { } this._editorToolbar.items = [ + ...(this.hasTest && !this._isFullscreen + ? [ + { + id: "test", + label: + this.hass?.localize( + `ui.components.yaml-editor.test_${this.testing ? "off" : "on"}` + ) || "Test", + path: this.testing ? mdiBugOutline : mdiBug, + action: (e: Event) => this._handleTestClick(e), + }, + ] + : []), { id: "undo", disabled: !this._canUndo, @@ -427,6 +449,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 ac4a2f193c..f74ba3a809 100644 --- a/src/components/ha-selector/ha-selector-template.ts +++ b/src/components/ha-selector/ha-selector-template.ts @@ -1,11 +1,17 @@ +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"; +import type { TemplateSelector } from "../../data/selector"; const WARNING_STRINGS = ["template:", "sensor:", "state:", "trigger: template"]; @@ -13,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; @@ -27,6 +35,45 @@ 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(); + this._unsubscribeTemplate(); + } + + public connectedCallback() { + super.connectedCallback(); + if (this._test) { + this._subscribeTemplate(); + } + } + + protected updated(changedProps: PropertyValues) { + if (changedProps.has("value") && this._test) { + this._subscribeTemplate(); + } + } + protected render() { return html` ${this.warn @@ -61,10 +108,22 @@ export class HaTemplateSelector extends LitElement { autofocus autocomplete-entities autocomplete-icons + .hasTest=${this.selector.template?.preview !== false} + .testing=${this._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 = err.message || "Unknown error"; + 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; @@ -90,6 +212,19 @@ export class HaTemplateSelector extends LitElement { p { margin-top: 0; } + .rendered { + font-family: var(--ha-font-family-code); + -webkit-font-smoothing: var(--ha-font-smoothing); + -moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing); + clear: both; + white-space: pre-wrap; + background-color: var(--secondary-background-color); + padding: var(--ha-space-2); + margin-top: var(--ha-space-3); + margin-bottom: 0; + direction: ltr; + border-radius: var(--ha-border-radius-sm); + } `; } diff --git a/src/data/selector.ts b/src/data/selector.ts index 3c5e0c5139..c2ba695c82 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -476,7 +476,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 2e33bd0f39..77461696f9 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[] ); diff --git a/src/translations/en.json b/src/translations/en.json index 222a17e5ca..7875156e4d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1310,7 +1310,9 @@ "error_location": "line: {line}, column: {column}", "enter_fullscreen": "Enter fullscreen", "exit_fullscreen": "Exit fullscreen", - "find_and_replace": "Find and replace" + "find_and_replace": "Find and replace", + "test_on": "Turn on testing", + "test_off": "Turn off testing" }, "state-content-picker": { "state": "State",