ha-frontend/src/panels/developer-tools/template/developer-tools-template.ts

482 lines
14 KiB
TypeScript

import "@material/mwc-button/mwc-button";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { debounce } from "../../../common/util/debounce";
import { storage } from "../../../common/decorators/storage";
import "../../../components/ha-alert";
import "../../../components/ha-circular-progress";
import "../../../components/ha-code-editor";
import {
RenderTemplateResult,
subscribeRenderTemplate,
} from "../../../data/ws-templates";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
const SERVICE_RESPONSE = "__service_response__";
const DEMO_TEMPLATE = `{## Imitate available variables: ##}
{% set my_test_json = {
"temperature": 25,
"unit": "°C"
} %}
The temperature is {{ my_test_json.temperature }} {{ my_test_json.unit }}.
{% if is_state("sun.sun", "above_horizon") -%}
The sun rose {{ relative_time(states.sun.sun.last_changed) }} ago.
{%- else -%}
The sun will rise at {{ as_timestamp(state_attr("sun.sun", "next_rising")) | timestamp_local }}.
{%- endif %}
For loop example getting entity values in the weather domain:
{% for state in states.weather -%}
{%- if loop.first %}The {% elif loop.last %} and the {% else %}, the {% endif -%}
{{ state.name | lower }} is {{state.state_with_unit}}
{%- endfor %}.`;
@customElement("developer-tools-template")
class HaPanelDevTemplate extends LitElement {
@property() public hass!: HomeAssistant;
@property() public narrow!: boolean;
@state() private _error?: string;
@state() private _errorLevel?: "ERROR" | "WARNING";
@state() private _rendering = false;
@state() private _templateResult?: RenderTemplateResult;
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
@storage({
key: "panel-dev-service-response-data",
state: false,
subscribe: true,
storage: "sessionStorage",
})
private _serviceResponse?: Record<string, any>;
private _template = "";
private _inited = false;
public connectedCallback() {
super.connectedCallback();
if (this._template && !this._unsubRenderTemplate) {
this._subscribeTemplate();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeTemplate();
}
protected firstUpdated() {
if (localStorage && localStorage["panel-dev-template-template"]) {
this._template = localStorage["panel-dev-template-template"];
} else {
this._template = DEMO_TEMPLATE;
}
this._subscribeTemplate();
this._inited = true;
}
protected render() {
const type = typeof this._templateResult?.result;
const resultType =
type === "object"
? Array.isArray(this._templateResult?.result)
? "list"
: "dict"
: type;
return html`
<div
class="content ${classMap({
layout: !this.narrow,
horizontal: !this.narrow,
})}"
>
<div class="edit-pane">
<p>
${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.description"
)}
</p>
<ul>
<li>
<a
href="https://jinja.palletsprojects.com/en/latest/templates/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.jinja_documentation"
)}
</a>
</li>
<li>
<a
href=${documentationUrl(
this.hass,
"/docs/configuration/templating/"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.template_extensions"
)}</a
>
</li>
</ul>
<p>
${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.editor"
)}
</p>
<ha-code-editor
mode="jinja2"
.hass=${this.hass}
.value=${this._template}
.error=${this._error}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
<mwc-button @click=${this._restoreDemo}>
${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.reset"
)}
</mwc-button>
<mwc-button @click=${this._clear}>
${this.hass.localize("ui.common.clear")}
</mwc-button>
${this._serviceResponse && typeof this._serviceResponse === "object"
? html`<mwc-button @click=${this._importServiceResponse}>
${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.import_service_response"
)}
</mwc-button>`
: nothing}
</div>
<div class="render-pane">
${this._rendering
? html`<ha-circular-progress
class="render-spinner"
active
size="small"
></ha-circular-progress>`
: ""}
${this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing}
${this._templateResult
? html`${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.result_type"
)}:
${resultType}
<!-- prettier-ignore -->
<pre class="rendered ${classMap({
[resultType]: resultType,
})}"
>${type === "object"
? JSON.stringify(this._templateResult.result, null, 2)
: this._templateResult.result}</pre>
${this._templateResult.listeners.time
? html`
<p>
${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.time"
)}
</p>
`
: ""}
${!this._templateResult.listeners
? nothing
: this._templateResult.listeners.all
? html`
<p class="all_listeners">
${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.all_listeners"
)}
</p>
`
: this._templateResult.listeners.domains.length ||
this._templateResult.listeners.entities.length
? html`
<p>
${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.listeners"
)}
</p>
<ul>
${this._templateResult.listeners.domains
.sort()
.map(
(domain) => html`
<li>
<b
>${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.domain"
)}</b
>: ${domain}
</li>
`
)}
${this._templateResult.listeners.entities
.sort()
.map(
(entity_id) => html`
<li>
<b
>${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.entity"
)}</b
>: ${entity_id}
</li>
`
)}
</ul>
`
: !this._templateResult.listeners.time
? html`<span class="all_listeners">
${this.hass.localize(
"ui.panel.developer-tools.tabs.templates.no_listeners"
)}
</span>`
: nothing}`
: nothing}
</div>
</div>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.content {
padding: 16px;
padding: max(16px, env(safe-area-inset-top))
max(16px, env(safe-area-inset-right))
max(16px, env(safe-area-inset-bottom))
max(16px, env(safe-area-inset-left));
}
.edit-pane {
margin-right: 16px;
margin-inline-start: initial;
margin-inline-end: 16px;
direction: var(--direction);
}
.edit-pane a {
color: var(--primary-color);
}
.horizontal .edit-pane {
max-width: 50%;
}
.render-pane {
position: relative;
max-width: 50%;
flex: 1;
}
.render-spinner {
position: absolute;
top: 8px;
right: 8px;
}
ha-alert {
margin-bottom: 8px;
display: block;
}
.rendered {
@apply --paper-font-code1;
clear: both;
white-space: pre-wrap;
background-color: var(--secondary-background-color);
padding: 8px;
direction: ltr;
}
.all_listeners {
color: var(--warning-color);
}
@media all and (max-width: 870px) {
.render-pane {
max-width: 100%;
}
}
`,
];
}
private _debounceRender = debounce(
() => {
this._subscribeTemplate();
this._storeTemplate();
},
500,
false
);
private _templateChanged(ev) {
this._template = ev.detail.value;
if (this._error) {
this._error = undefined;
this._errorLevel = undefined;
}
this._debounceRender();
}
private async _subscribeTemplate() {
this._rendering = true;
await this._unsubscribeTemplate();
this._error = undefined;
this._errorLevel = undefined;
this._templateResult = undefined;
let template = this._template;
if (
this._serviceResponse &&
typeof this._serviceResponse === "object" &&
template.includes(SERVICE_RESPONSE)
) {
template = `
{% set ${SERVICE_RESPONSE} = ${JSON.stringify(this._serviceResponse)} %}
${template}`;
}
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._error = result.error;
this._errorLevel = result.level;
}
} else {
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;
} finally {
this._rendering = false;
}
}
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 _storeTemplate() {
if (!this._inited) {
return;
}
localStorage["panel-dev-template-template"] = this._template;
}
private async _restoreDemo() {
if (
!(await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.developer-tools.tabs.templates.confirm_reset"
),
warning: true,
}))
) {
return;
}
this._template = DEMO_TEMPLATE;
this._subscribeTemplate();
delete localStorage["panel-dev-template-template"];
}
private async _clear() {
if (
!(await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.developer-tools.tabs.templates.confirm_clear"
),
warning: true,
}))
) {
return;
}
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 },
};
}
private _importServiceResponse() {
this._template += `{{ ${SERVICE_RESPONSE} }}`;
this._subscribeTemplate();
}
}
declare global {
interface HTMLElementTagNameMap {
"developer-tools-template": HaPanelDevTemplate;
}
}