diff --git a/src/panels/developer-tools/state/developer-tools-state-renderer.ts b/src/panels/developer-tools/state/developer-tools-state-renderer.ts new file mode 100644 index 0000000000..44b321cb81 --- /dev/null +++ b/src/panels/developer-tools/state/developer-tools-state-renderer.ts @@ -0,0 +1,366 @@ +import { + mdiClipboardTextMultipleOutline, + mdiInformationOutline, +} from "@mdi/js"; +import type { HassEntity } from "home-assistant-js-websocket"; +import { dump } from "js-yaml"; +import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; +import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; +import { css, html, LitElement, nothing } from "lit"; +import { classMap } from "lit/directives/class-map"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { loadVirtualizer } from "../../../resources/virtualizer"; +import { copyToClipboard } from "../../../common/util/copy-clipboard"; +import "../../../components/ha-checkbox"; +import "../../../components/ha-svg-icon"; +import type { HomeAssistant } from "../../../types"; +import { showToast } from "../../../util/toast"; +import { haStyle } from "../../../resources/styles"; + +@customElement("developer-tools-state-renderer") +class HaPanelDevStateRenderer extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public entities: HassEntity[] = []; + + @property({ type: Boolean, attribute: "narrow" }) + public narrow = false; + + @property({ type: Boolean, attribute: "virtualize", reflect: true }) + public virtualize = true; + + @property({ type: Boolean, attribute: false }) + public showAttributes = true; + + protected willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + if ( + (!this.hasUpdated && this.virtualize) || + (changedProps.has("virtualize") && this.virtualize) + ) { + loadVirtualizer(); + } + } + + protected shouldUpdate(changedProps: PropertyValues) { + super.shouldUpdate(changedProps); + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + const languageChanged = + oldHass === undefined || oldHass.locale !== this.hass.locale; + + return ( + changedProps.has("entities") || + changedProps.has("narrow") || + changedProps.has("virtualize") || + changedProps.has("showAttributes") || + languageChanged + ); + } + + protected render() { + const showAttributes = !this.narrow && this.showAttributes; + return html` +
+
+
+ + ${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.entities.length === 0 + ? html`
+
+ + ${this.hass.localize( + "ui.panel.developer-tools.tabs.states.no_entities" + )} + +
+
` + : nothing + } + ${ + this.virtualize + ? html` + ` + : this.entities.map((item, index) => + this._renderStateItem(item, index) + ) + } +
+ `; + } + + private _renderStateItem: RenderItemFunction = ( + item: HassEntity, + index: number + ): TemplateResult | any => { + if (!item || index === undefined) { + return nothing; + } + return html` +
+
+ +
+ +
+ + + ${item.attributes.friendly_name} + +
+
+
+
+
+ ${item.state} +
+
+ ${this._attributeString(item)} +
+
+ `; + }; + + private _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; + } + + private _attributeString(entity) { + const output = ""; + + if (entity && entity.attributes) { + return Object.keys(entity.attributes).map( + (key) => + `${key}: ${this._formatAttributeValue(entity.attributes[key])}\n` + ); + } + + return output; + } + + private _copyEntity = async (ev) => { + ev.preventDefault(); + const entity = (ev.currentTarget! as any).entity; + await copyToClipboard(entity.entity_id); + showToast(this, { + message: this.hass.localize("ui.common.copied_clipboard"), + }); + }; + + private _entityMoreInfo(ev) { + ev.preventDefault(); + const entity = (ev.currentTarget! as any).entity; + fireEvent(this, "hass-more-info", { entityId: entity.entity_id }); + } + + private _entitySelected(ev) { + ev.preventDefault(); + const entity = (ev.currentTarget! as any).entity; + fireEvent(this, "states-tool-entity-selected", { + entity: entity, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + :host([virtualize]) { + display: block; + height: 100%; + } + + .entities { + width: 100%; + } + + .entities .row { + display: flex; + width: 100%; + } + + .entities .odd .cell { + background-color: var(--table-row-background-color, #fff); + } + + .entities .even .cell { + background-color: var(--table-row-alternative-background-color, #eee); + } + + .header, + .cell { + min-width: 200px; + flex: 1; + display: flex; + margin: 0 1px 0 1px; + } + + .header { + font-weight: bold; + text-align: var(--float-start); + direction: var(--direction); + } + + .header .padded { + padding: 0 8px; + } + + .filters .header { + background-color: var(--table-row-alternative-background-color, #eee); + --mdc-text-field-fill-color: var( + --table-row-alternative-background-color, + #eee + ); + } + + .cell { + word-break: break-word; + vertical-align: top; + direction: ltr; + } + + .cell .padded { + padding: 4px; + } + + .entities .row .header:nth-child(1), + .entities .row .cell:nth-child(1) { + min-width: 300px; + width: 30%; + flex: 2; + } + + .entities .row .header:nth-child(3), + .entities .row .cell:nth-child(3) { + flex: 2; + } + + .entities .row .cell:nth-child(3) { + white-space: pre-wrap; + } + + .hide-attributes .filter-attributes { + display: none; + } + + .hide-attributes .row .header:nth-child(3), + .hide-attributes .row .cell:nth-child(3) { + display: none; + } + + .entities ha-svg-icon { + --mdc-icon-size: 20px; + padding: 4px; + cursor: pointer; + flex-shrink: 0; + margin-right: 8px; + margin-inline-end: 8px; + margin-inline-start: initial; + } + + .entities a { + color: var(--primary-color); + } + + .entities .id-name-container { + display: flex; + flex-direction: column; + } + + .entities .id-name-row { + display: flex; + align-items: center; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "developer-tools-state-renderer": HaPanelDevStateRenderer; + } + + interface HASSDomEvents { + "states-tool-entity-selected": { + entity: Partial; + }; + } +} diff --git a/src/panels/developer-tools/state/developer-tools-state.ts b/src/panels/developer-tools/state/developer-tools-state.ts index a7d68c89bb..467f80a48b 100644 --- a/src/panels/developer-tools/state/developer-tools-state.ts +++ b/src/panels/developer-tools/state/developer-tools-state.ts @@ -1,23 +1,16 @@ -import { - mdiClipboardTextMultipleOutline, - mdiContentCopy, - mdiInformationOutline, - mdiRefresh, -} from "@mdi/js"; +import { mdiContentCopy, mdiRefresh } from "@mdi/js"; import { addHours } from "date-fns"; import type { HassEntities, HassEntity, HassEntityAttributeBase, } from "home-assistant-js-websocket"; -import { dump } from "js-yaml"; import type { CSSResultGroup } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time"; import { storage } from "../../../common/decorators/storage"; -import { fireEvent } from "../../../common/dom/fire_event"; import { escapeRegExp } from "../../../common/string/escape_regexp"; import { copyToClipboard } from "../../../common/util/copy-clipboard"; import "../../../components/entity/ha-entity-picker"; @@ -36,6 +29,14 @@ import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { showToast } from "../../../util/toast"; +import "./developer-tools-state-renderer"; + +// Use virtualizer after threshold to avoid performance issues +// NOTE: If virtualizer is used when filtered entiity state +// array size is 1, the virtualizer will scroll up the page on +// render updates until the view matches to near the top of the +// virtualized list, an undesirable effect. +const VIRTUALIZE_THRESHOLD = 100; @customElement("developer-tools-state") class HaPanelDevState extends LitElement { @@ -95,14 +96,28 @@ class HaPanelDevState extends LitElement { 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.current_entities" + )} +

+ ${!this.narrow + ? html` + + ` + : nothing} +
${this._error - ? html`${this._error}}` + ? html`${this._error}` : nothing}
@@ -212,136 +227,47 @@ class HaPanelDevState extends LitElement {
-
- - - - - ${!this.narrow - ? html`` - : nothing} - - - - - ${showAttributes - ? html`` - : nothing} - - ${entities.length === 0 - ? html` - - ` - : nothing} - ${entities.map( - (entity) => - html` - - - ${showAttributes - ? html`` - : nothing} - ` + VIRTUALIZE_THRESHOLD} + .showAttributes=${this._showAttributes} + @states-tool-entity-selected=${this._entitySelected} + > + - + .value=${this._entityFilter} + @value-changed=${this._entityFilterChanged} + > + + + `; } - private async _copyEntity(ev) { - ev.preventDefault(); - const entity = (ev.currentTarget! as any).entity; - await copyToClipboard(entity.entity_id); - } - private async _copyStateEntity(ev) { ev.preventDefault(); await copyToClipboard(this._entityId); @@ -351,7 +277,7 @@ class HaPanelDevState extends LitElement { } private _entitySelected(ev) { - const entityState: HassEntity = (ev.currentTarget! as any).entity; + const entityState: HassEntity = ev.detail.entity; this._entityId = entityState.entity_id; this._entity = entityState; this._state = entityState.state; @@ -359,6 +285,7 @@ class HaPanelDevState extends LitElement { this._updateEditor(); this._expanded = true; ev.preventDefault(); + window.scrollTo({ top: 0 }); } private _updateEditor() { @@ -420,12 +347,14 @@ class HaPanelDevState extends LitElement { 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 }); + if (!ev.detail.expanded) { + // lit-virtulizer in state renderer will not show items + // if none were in view when panel was expanded + // so we fire scroll event to trigger a re-render + setTimeout(() => { + window.dispatchEvent(new Event("scroll")); + }, 100); + } } private async _handleSetState() { @@ -539,29 +468,6 @@ class HaPanelDevState extends LitElement { }); } - private _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; - } - - private _attributeString(entity) { - const output = ""; - - if (entity && entity.attributes) { - return Object.keys(entity.attributes).map( - (key) => - `${key}: ${this._formatAttributeValue(entity.attributes[key])}\n` - ); - } - - return output; - } - private _lastChangedString(entity) { return formatDateTimeWithSeconds( new Date(entity.last_changed), @@ -591,6 +497,11 @@ class HaPanelDevState extends LitElement { return [ haStyle, css` + :host { + display: block; + height: 100%; + } + :host { -ms-user-select: initial; -webkit-user-select: initial; @@ -603,10 +514,26 @@ class HaPanelDevState extends LitElement { max(16px, var(--safe-area-inset-left)); } + :host search-input { + display: block; + width: 100%; + } + ha-textfield { display: block; } + .heading { + display: flex; + justify-content: space-between; + } + + .heading ha-formfield { + margin-right: 8px; + --mdc-typography-body2-font-size: var(--ha-font-size-m); + --mdc-typography-body2-font-weight: var(--ha-font-weight-medium); + } + .entity-id { display: block; font-family: var(--ha-font-family-code); @@ -656,83 +583,6 @@ class HaPanelDevState extends LitElement { align-items: center; } - .table-wrapper { - width: 100%; - overflow: auto; - } - - .entities th { - padding: 0 8px; - text-align: var(--float-start); - direction: var(--direction); - } - - .filters th { - padding: 0; - } - - .filters search-input { - display: block; - --mdc-text-field-fill-color: transparent; - } - - th.attributes { - position: relative; - } - - th.attributes ha-checkbox { - position: absolute; - bottom: -8px; - } - - .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; - margin-inline-end: 8px; - margin-inline-start: initial; - } - .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; }
- ${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)}