From a08185a1a584307b8b3e55df96edf228fb49acc0 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 16 Oct 2023 20:37:04 +0200 Subject: [PATCH] Migrate developer state tools to LitElement (#18134) Co-authored-by: Steve Repsher --- .../state/developer-tools-state.js | 699 ----------------- .../state/developer-tools-state.ts | 708 ++++++++++++++++++ 2 files changed, 708 insertions(+), 699 deletions(-) delete mode 100644 src/panels/developer-tools/state/developer-tools-state.js create mode 100644 src/panels/developer-tools/state/developer-tools-state.ts diff --git a/src/panels/developer-tools/state/developer-tools-state.js b/src/panels/developer-tools/state/developer-tools-state.js deleted file mode 100644 index bddf382e5b..0000000000 --- a/src/panels/developer-tools/state/developer-tools-state.js +++ /dev/null @@ -1,699 +0,0 @@ -import { addHours } from "date-fns/esm"; -import "@material/mwc-button"; -import { - mdiClipboardTextMultipleOutline, - mdiInformationOutline, - mdiRefresh, -} from "@mdi/js"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { dump, load } from "js-yaml"; -import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time"; -import { computeRTL } from "../../../common/util/compute_rtl"; -import { escapeRegExp } from "../../../common/string/escape_regexp"; -import { copyToClipboard } from "../../../common/util/copy-clipboard"; -import "../../../components/entity/ha-entity-picker"; -import "../../../components/ha-code-editor"; -import "../../../components/ha-icon-button"; -import "../../../components/ha-svg-icon"; -import "../../../components/ha-checkbox"; -import "../../../components/ha-tip"; -import "../../../components/ha-alert"; -import "../../../components/search-input"; -import "../../../components/ha-expansion-panel"; -import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; -import { EventsMixin } from "../../../mixins/events-mixin"; -import LocalizeMixin from "../../../mixins/localize-mixin"; -import "../../../styles/polymer-ha-style"; - -const ERROR_SENTINEL = {}; -/* - * @appliesMixin EventsMixin - * @appliesMixin LocalizeMixin - */ -class HaPanelDevState extends EventsMixin(LocalizeMixin(PolymerElement)) { - static get template() { - return html` - -

- [[localize('ui.panel.developer-tools.tabs.states.current_entities')]] -

- -

- [[localize('ui.panel.developer-tools.tabs.states.description1')]]
- [[localize('ui.panel.developer-tools.tabs.states.description2')]] -

- -
-
- - [[localize('ui.tips.key_e_hint')]] - -

- [[localize('ui.panel.developer-tools.tabs.states.state_attributes')]] -

- -
- [[localize('ui.panel.developer-tools.tabs.states.set_state')]] - -
-
-
- -
-
-
-
- - - - - - - - - - - - - - - -
[[localize('ui.panel.developer-tools.tabs.states.entity')]][[localize('ui.panel.developer-tools.tabs.states.state')]] - [[localize('ui.panel.developer-tools.tabs.states.attributes')]] - -
- - - - - -
- [[localize('ui.panel.developer-tools.tabs.states.no_entities')]] -
-
- `; - } - - static get properties() { - return { - hass: { - type: Object, - }, - - parsedJSON: { - type: Object, - computed: "_computeParsedStateAttributes(_stateAttributes)", - }, - - validJSON: { - type: Boolean, - computed: "_computeValidJSON(parsedJSON)", - }, - - _error: { - type: String, - value: "", - }, - - _entityId: { - type: String, - value: "", - }, - - _entityFilter: { - type: String, - value: "", - }, - - _stateFilter: { - type: String, - value: "", - }, - - _attributeFilter: { - type: String, - value: "", - }, - - _entity: { - type: Object, - }, - - _state: { - type: String, - value: "", - }, - - _stateAttributes: { - type: String, - value: "", - }, - - _showAttributes: { - type: Boolean, - value: JSON.parse( - localStorage.getItem("devToolsShowAttributes") || true - ), - }, - - _entities: { - type: Array, - computed: - "computeEntities(hass, _entityFilter, _stateFilter, _attributeFilter)", - }, - - _expanded: { - type: Boolean, - value: false, - }, - - narrow: { - type: Boolean, - reflectToAttribute: true, - }, - - rtl: { - reflectToAttribute: true, - computed: "_computeRTL(hass)", - }, - }; - } - - copyEntity(ev) { - ev.preventDefault(); - copyToClipboard(ev.model.entity.entity_id); - } - - entitySelected(ev) { - const state = ev.model.entity; - this._entityId = state.entity_id; - this._entity = state; - this._state = state.state; - this._stateAttributes = dump(state.attributes); - this._expanded = true; - ev.preventDefault(); - } - - entityIdChanged() { - if (!this._entityId) { - this._entity = undefined; - this._state = ""; - this._stateAttributes = ""; - return; - } - const state = this.hass.states[this._entityId]; - if (!state) { - return; - } - this._entity = state; - this._state = state.state; - this._stateAttributes = dump(state.attributes); - this._expanded = true; - } - - stateChanged(ev) { - this._state = ev.target.value; - } - - _entityFilterChanged(ev) { - this._entityFilter = ev.detail.value; - } - - _stateFilterChanged(ev) { - this._stateFilter = ev.detail.value; - } - - _attributeFilterChanged(ev) { - this._attributeFilter = ev.detail.value; - } - - _getHistoryURL(entityId, inputDate) { - const date = new Date(inputDate); - const hourBefore = addHours(date, -1).toISOString(); - return `/history?entity_id=${entityId}&start_date=${hourBefore}`; - } - - historyFromLastChanged(entity) { - return this._getHistoryURL(entity.entity_id, entity.last_changed); - } - - historyFromLastUpdated(entity) { - return this._getHistoryURL(entity.entity_id, entity.last_updated); - } - - expandedChanged(ev) { - this._expanded = ev.detail.expanded; - } - - entityMoreInfo(ev) { - ev.preventDefault(); - this.fire("hass-more-info", { entityId: ev.model.entity.entity_id }); - } - - async handleSetState() { - this._error = ""; - if (!this._entityId) { - showAlertDialog(this, { - text: this.hass.localize( - "ui.panel.developer-tools.tabs.states.alert_entity_field" - ), - }); - return; - } - try { - await this.hass.callApi("POST", "states/" + this._entityId, { - state: this._state, - attributes: this.parsedJSON, - }); - } catch (e) { - this._error = e.body?.message || "Unknown error"; - } - } - - informationOutlineIcon() { - return mdiInformationOutline; - } - - clipboardOutlineIcon() { - return mdiClipboardTextMultipleOutline; - } - - refreshIcon() { - return mdiRefresh; - } - - computeEntities(hass, _entityFilter, _stateFilter, _attributeFilter) { - const entityFilterRegExp = - _entityFilter && - RegExp(escapeRegExp(_entityFilter).replace(/\\\*/g, ".*"), "i"); - - const stateFilterRegExp = - _stateFilter && - RegExp(escapeRegExp(_stateFilter).replace(/\\\*/g, ".*"), "i"); - - let keyFilterRegExp; - let valueFilterRegExp; - let multiMode = false; - - if (_attributeFilter) { - const colonIndex = _attributeFilter.indexOf(":"); - multiMode = colonIndex !== -1; - - const keyFilter = multiMode - ? _attributeFilter.substring(0, colonIndex).trim() - : _attributeFilter; - const valueFilter = multiMode - ? _attributeFilter.substring(colonIndex + 1).trim() - : _attributeFilter; - - keyFilterRegExp = RegExp( - escapeRegExp(keyFilter).replace(/\\\*/g, ".*"), - "i" - ); - valueFilterRegExp = multiMode - ? RegExp(escapeRegExp(valueFilter).replace(/\\\*/g, ".*"), "i") - : keyFilterRegExp; - } - - return Object.values(hass.states) - .filter((value) => { - if ( - entityFilterRegExp && - !entityFilterRegExp.test(value.entity_id) && - (value.attributes.friendly_name === undefined || - !entityFilterRegExp.test(value.attributes.friendly_name)) - ) { - return false; - } - - if (stateFilterRegExp && !stateFilterRegExp.test(value.state)) { - return false; - } - - if (keyFilterRegExp && valueFilterRegExp) { - for (const [key, attributeValue] of Object.entries( - value.attributes - )) { - const match = keyFilterRegExp.test(key); - if (match && !multiMode) { - return true; // in single mode we're already satisfied with this match - } - if (!match && multiMode) { - continue; - } - - if ( - attributeValue !== undefined && - valueFilterRegExp.test(JSON.stringify(attributeValue)) - ) { - return true; - } - } - - // there are no attributes where the key and/or value can be matched - return false; - } - - return true; - }) - .sort((entityA, entityB) => { - if (entityA.entity_id < entityB.entity_id) { - return -1; - } - if (entityA.entity_id > entityB.entity_id) { - return 1; - } - return 0; - }); - } - - computeShowEntitiesPlaceholder(_entities) { - return _entities.length === 0; - } - - computeShowAttributes(narrow, _showAttributes) { - return !narrow && _showAttributes; - } - - attributeString(entity) { - let output = ""; - let i; - let keys; - let key; - let value; - - for (i = 0, keys = Object.keys(entity.attributes); i < keys.length; i++) { - key = keys[i]; - value = this.formatAttributeValue(entity.attributes[key]); - output += `${key}: ${value}\n`; - } - return output; - } - - lastChangedString(entity) { - return formatDateTimeWithSeconds( - new Date(entity.last_changed), - this.hass.locale, - this.hass.config - ); - } - - lastUpdatedString(entity) { - return formatDateTimeWithSeconds( - new Date(entity.last_updated), - this.hass.locale, - this.hass.config - ); - } - - formatAttributeValue(value) { - if ( - (Array.isArray(value) && value.some((val) => val instanceof Object)) || - (!Array.isArray(value) && value instanceof Object) - ) { - return `\n${dump(value)}`; - } - return Array.isArray(value) ? value.join(", ") : value; - } - - saveAttributeCheckboxState(ev) { - this._showAttributes = ev.target.checked; - try { - localStorage.setItem("devToolsShowAttributes", ev.target.checked); - } catch (e) { - // Catch for Safari private mode - } - } - - _computeParsedStateAttributes(stateAttributes) { - try { - return stateAttributes.trim() ? load(stateAttributes) : {}; - } catch (err) { - return ERROR_SENTINEL; - } - } - - _computeValidJSON(parsedJSON) { - return parsedJSON !== ERROR_SENTINEL; - } - - _yamlChanged(ev) { - this._stateAttributes = ev.detail.value; - } - - _computeRTL(hass) { - return computeRTL(hass); - } -} - -customElements.define("developer-tools-state", HaPanelDevState); diff --git a/src/panels/developer-tools/state/developer-tools-state.ts b/src/panels/developer-tools/state/developer-tools-state.ts new file mode 100644 index 0000000000..8a77150e45 --- /dev/null +++ b/src/panels/developer-tools/state/developer-tools-state.ts @@ -0,0 +1,708 @@ +import { addHours } from "date-fns/esm"; +import "@material/mwc-button"; +import { + mdiClipboardTextMultipleOutline, + mdiInformationOutline, + mdiRefresh, +} from "@mdi/js"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { + HassEntities, + HassEntity, + HassEntityAttributeBase, +} from "home-assistant-js-websocket"; +import memoizeOne from "memoize-one"; +import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time"; +import { computeRTL } from "../../../common/util/compute_rtl"; +import { escapeRegExp } from "../../../common/string/escape_regexp"; +import { copyToClipboard } from "../../../common/util/copy-clipboard"; +import "../../../components/entity/ha-entity-picker"; +import "../../../components/ha-yaml-editor"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-svg-icon"; +import "../../../components/ha-checkbox"; +import "../../../components/ha-tip"; +import "../../../components/ha-alert"; +import "../../../components/search-input"; +import "../../../components/ha-expansion-panel"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { toggleAttribute } from "../../../common/dom/toggle_attribute"; +import { storage } from "../../../common/decorators/storage"; + +@customElement("developer-tools-state") +class HaPanelDevState extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _error: string = ""; + + @state() private _entityId: string = ""; + + @state() private _entityFilter: string = ""; + + @state() private _stateFilter: string = ""; + + @state() private _attributeFilter: string = ""; + + @state() private _entity?: HassEntity; + + @state() private _state: string = ""; + + @state() private _stateAttributes: HassEntityAttributeBase & { + [key: string]: any; + } = {}; + + @state() private _expanded = false; + + @state() private _validJSON: boolean = true; + + @storage({ + key: "devToolsShowAttributes", + state: true, + }) + private _showAttributes = true; + + @property({ type: Boolean, reflect: true }) public narrow = false; + + @property({ type: Boolean, reflect: true }) public rtl = false; + + private _filteredEntities = memoizeOne( + ( + entityFilter: string, + stateFilter: string, + attributeFilter: string, + states: HassEntities + ): HassEntity[] => + this._applyFiltersOnEntities( + entityFilter, + stateFilter, + attributeFilter, + states + ) + ); + + protected render() { + const entities = this._filteredEntities( + this._entityFilter, + this._stateFilter, + this._attributeFilter, + this.hass.states + ); + const showAttributes = !this.narrow && this._showAttributes; + + return html` +

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.current_entities" + )} +

+ +

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.description1" + )}
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.description2" + )} +

+ ${this._error + ? html`${this._error}}` + : nothing} +
+
+ + ${this.hass.localize("ui.tips.key_e_hint")} + +

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.state_attributes" + )} +

+ +
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.set_state" + )} + +
+
+
+ ${this._entity + ? html`

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.last_changed" + )}:
+ ${this._lastChangedString(this._entity)} +

+

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.last_updated" + )}:
+ ${this._lastUpdatedString(this._entity)} +

` + : nothing} +
+
+
+
+ + + + + ${!this.narrow + ? html`` + : nothing} + + + + + ${showAttributes + ? html`` + : nothing} + + ${entities.length === 0 + ? html` + + ` + : nothing} + ${entities.map( + (entity) => + html` + + + ${showAttributes + ? html`` + : nothing} + ` + )} +
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.entity" + )} + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.state" + )} + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.attributes" + )} + +
+ + + + + +
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.no_entities" + )} +
+
+ +
+ + + ${entity.attributes.friendly_name} + +
+
+
${entity.state}${this._attributeString(entity)}
+
+ `; + } + + protected updated(changedProps) { + super.updated(changedProps); + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + if (!oldHass || oldHass.locale !== this.hass.locale) { + toggleAttribute(this, "rtl", computeRTL(this.hass)); + } + } + + private _copyEntity(ev) { + ev.preventDefault(); + const entity = (ev.currentTarget! as any).entity; + copyToClipboard(entity.entity_id); + } + + private _entitySelected(ev) { + const entityState: HassEntity = (ev.currentTarget! as any).entity; + this._entityId = entityState.entity_id; + this._entity = entityState; + this._state = entityState.state; + this._stateAttributes = entityState.attributes; + this._expanded = true; + ev.preventDefault(); + } + + private _entityIdChanged() { + if (!this._entityId) { + this._entity = undefined; + this._state = ""; + this._stateAttributes = {}; + return; + } + const entityState = this.hass.states[this._entityId]; + if (!entityState) { + return; + } + this._entity = entityState; + this._state = entityState.state; + this._stateAttributes = entityState.attributes; + this._expanded = true; + } + + private _stateChanged(ev) { + this._state = ev.target.value; + } + + private _entityFilterChanged(ev) { + this._entityFilter = ev.detail.value; + } + + private _stateFilterChanged(ev) { + this._stateFilter = ev.detail.value; + } + + private _attributeFilterChanged(ev) { + this._attributeFilter = ev.detail.value; + } + + private _getHistoryURL(entityId, inputDate) { + const date = new Date(inputDate); + const hourBefore = addHours(date, -1).toISOString(); + return `/history?entity_id=${entityId}&start_date=${hourBefore}`; + } + + private _historyFromLastChanged(entity) { + return this._getHistoryURL(entity.entity_id, entity.last_changed); + } + + private _historyFromLastUpdated(entity) { + return this._getHistoryURL(entity.entity_id, entity.last_updated); + } + + private _expandedChanged(ev) { + this._expanded = ev.detail.expanded; + } + + private _entityMoreInfo(ev) { + ev.preventDefault(); + const entity = (ev.currentTarget! as any).entity; + fireEvent(this, "hass-more-info", { entityId: entity.entity_id }); + } + + private async _handleSetState() { + this._error = ""; + if (!this._entityId) { + showAlertDialog(this, { + text: this.hass.localize( + "ui.panel.developer-tools.tabs.states.alert_entity_field" + ), + }); + return; + } + try { + await this.hass.callApi("POST", "states/" + this._entityId, { + state: this._state, + attributes: this._stateAttributes, + }); + } catch (e: any) { + this._error = e.body?.message || "Unknown error"; + } + } + + private _applyFiltersOnEntities( + entityFilter: string, + stateFilter: string, + attributeFilter: string, + states: HassEntities + ) { + const entityFilterRegExp = + entityFilter && + RegExp(escapeRegExp(entityFilter).replace(/\\\*/g, ".*"), "i"); + + const stateFilterRegExp = + stateFilter && + RegExp(escapeRegExp(stateFilter).replace(/\\\*/g, ".*"), "i"); + + let keyFilterRegExp; + let valueFilterRegExp; + let multiMode = false; + + if (attributeFilter) { + const colonIndex = attributeFilter.indexOf(":"); + multiMode = colonIndex !== -1; + + const keyFilter = multiMode + ? attributeFilter.substring(0, colonIndex).trim() + : attributeFilter; + const valueFilter = multiMode + ? attributeFilter.substring(colonIndex + 1).trim() + : attributeFilter; + + keyFilterRegExp = RegExp( + escapeRegExp(keyFilter).replace(/\\\*/g, ".*"), + "i" + ); + valueFilterRegExp = multiMode + ? RegExp(escapeRegExp(valueFilter).replace(/\\\*/g, ".*"), "i") + : keyFilterRegExp; + } + + return Object.values(states) + .filter((value) => { + if ( + entityFilterRegExp && + !entityFilterRegExp.test(value.entity_id) && + (value.attributes.friendly_name === undefined || + !entityFilterRegExp.test(value.attributes.friendly_name)) + ) { + return false; + } + + if (stateFilterRegExp && !stateFilterRegExp.test(value.state)) { + return false; + } + + if (keyFilterRegExp && valueFilterRegExp) { + for (const [key, attributeValue] of Object.entries( + value.attributes + )) { + const match = keyFilterRegExp.test(key); + if (match && !multiMode) { + return true; // in single mode we're already satisfied with this match + } + if (!match && multiMode) { + continue; + } + + if ( + attributeValue !== undefined && + valueFilterRegExp.test(JSON.stringify(attributeValue)) + ) { + return true; + } + } + + // there are no attributes where the key and/or value can be matched + return false; + } + + return true; + }) + .sort((entityA, entityB) => { + if (entityA.entity_id < entityB.entity_id) { + return -1; + } + if (entityA.entity_id > entityB.entity_id) { + return 1; + } + return 0; + }); + } + + private _attributeString(entity) { + const output = ""; + + if (entity && entity.attributes) { + return Object.keys(entity.attributes).map( + (key) => `${key}: ${entity.attributes[key]}\n` + ); + } + + return output; + } + + private _lastChangedString(entity) { + return formatDateTimeWithSeconds( + new Date(entity.last_changed), + this.hass.locale, + this.hass.config + ); + } + + private _lastUpdatedString(entity) { + return formatDateTimeWithSeconds( + new Date(entity.last_updated), + this.hass.locale, + this.hass.config + ); + } + + private _saveAttributeCheckboxState(ev) { + this._showAttributes = ev.target.checked; + } + + private _yamlChanged(ev) { + this._stateAttributes = ev.detail.value; + this._validJSON = ev.detail.isValid; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + :host { + -ms-user-select: initial; + -webkit-user-select: initial; + -moz-user-select: initial; + display: block; + 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)); + } + + ha-textfield { + display: block; + } + + .state-input { + margin-top: 16px; + } + + ha-expansion-panel { + margin: 0 8px 16px; + } + + .inputs { + width: 100%; + max-width: 800px; + } + + .info { + padding: 0 16px; + } + + .button-row { + display: flex; + margin-top: 8px; + align-items: center; + } + + .table-wrapper { + width: 100%; + overflow: auto; + } + + .entities th { + padding: 0 8px; + text-align: left; + font-size: var( + --paper-input-container-shared-input-style_-_font-size + ); + } + + .filters th { + padding: 0; + } + + .filters search-input { + display: block; + --mdc-text-field-fill-color: transparent; + } + ha-tip { + display: flex; + padding: 8px 0; + text-align: left; + } + + th.attributes { + position: relative; + } + + th.attributes ha-checkbox { + position: absolute; + bottom: -8px; + } + + :host([rtl]) .entities th { + text-align: right; + direction: rtl; + } + + :host([rtl]) .filters { + direction: rtl; + } + + .entities tr { + vertical-align: top; + direction: ltr; + } + + .entities tr:nth-child(odd) { + background-color: var(--table-row-background-color, #fff); + } + + .entities tr:nth-child(even) { + background-color: var(--table-row-alternative-background-color, #eee); + } + .entities td { + padding: 4px; + min-width: 200px; + word-break: break-word; + } + .entities ha-svg-icon { + --mdc-icon-size: 20px; + padding: 4px; + cursor: pointer; + flex-shrink: 0; + margin-right: 8px; + } + .entities td:nth-child(1) { + min-width: 300px; + width: 30%; + } + .entities td:nth-child(3) { + white-space: pre-wrap; + word-break: break-word; + } + + .entities a { + color: var(--primary-color); + } + + .entities .id-name-container { + display: flex; + flex-direction: column; + } + .entities .id-name-row { + display: flex; + align-items: center; + } + + :host([narrow]) .state-wrapper { + flex-direction: column; + } + + :host([narrow]) .info { + padding: 0; + } + + .flex-horizontal { + display: flex; + flex-direction: row; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "developer-tools-state": HaPanelDevState; + } +}