1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-02-15 07:25:54 +00:00

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 <MindFreeze@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* resub on connect

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
This commit is contained in:
karwosts
2026-01-28 01:57:41 -08:00
committed by GitHub
parent c0442b5b39
commit 408735fa77
5 changed files with 178 additions and 4 deletions

View File

@@ -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();

View File

@@ -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<UnsubscribeFunc>;
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
></ha-code-editor>
${this._test && this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: this._test && this._templateResult
? html`<pre class="rendered">
${typeof this._templateResult.result === "object"
? JSON.stringify(this._templateResult.result, null, 2)
: this._templateResult.result}</pre
>`
: nothing}
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
@@ -73,6 +132,69 @@ export class HaTemplateSelector extends LitElement {
`;
}
private _testToggle() {
this._test = !this._test;
if (this._test) {
this._subscribeTemplate();
} else {
this._unsubscribeTemplate();
}
}
private async _subscribeTemplate() {
await this._unsubscribeTemplate();
const template = this.value || "";
try {
this._unsubRenderTemplate = subscribeRenderTemplate(
this.hass.connection,
(result) => {
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<void> {
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);
}
`;
}

View File

@@ -476,7 +476,9 @@ export interface TargetSelector {
}
export interface TemplateSelector {
template: {} | null;
template: {
preview?: boolean;
} | null;
}
export interface ThemeSelector {

View File

@@ -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[]
);

View File

@@ -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",