From 175693ba4e12f22801d40e678832044a5f4b79e3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 30 Jan 2019 13:02:41 -0800 Subject: [PATCH] Allow editting entity registry (#2630) * Allow editting entity registry * Slight simplify * Style fixes * Correctly set submitting * Apply suggestions from code review Co-Authored-By: balloob * Fix invalid type * Add config section to entity registry * Trim * Fix trimming --- src/data/entity_registry.ts | 52 +++++ src/dialogs/more-info/more-info-settings.js | 15 +- src/layouts/app/dialog-manager-mixin.ts | 56 +++-- .../config-entries/ha-ce-entities-card.js | 10 +- .../config/dashboard/ha-config-navigation.js | 12 +- .../dialog-entity-registry-detail.ts | 191 ++++++++++++++++++ .../ha-config-entity-registry.ts | 165 +++++++++++++++ .../show-dialog-entity-registry-detail.ts | 27 +++ src/panels/config/ha-panel-config.js | 14 ++ src/translations/en.json | 6 +- 10 files changed, 518 insertions(+), 30 deletions(-) create mode 100644 src/data/entity_registry.ts create mode 100644 src/panels/config/entity_registry/dialog-entity-registry-detail.ts create mode 100644 src/panels/config/entity_registry/ha-config-entity-registry.ts create mode 100644 src/panels/config/entity_registry/show-dialog-entity-registry-detail.ts diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts new file mode 100644 index 0000000000..c11d8ec371 --- /dev/null +++ b/src/data/entity_registry.ts @@ -0,0 +1,52 @@ +import { HomeAssistant } from "../types"; +import computeStateName from "../common/entity/compute_state_name"; + +export interface EntityRegistryEntry { + entity_id: string; + name: string; + platform: string; + config_entry_id?: string; + device_id?: string; + disabled_by?: string; +} + +export interface EntityRegistryEntryUpdateParams { + name: string | null; + new_entity_id: string; +} + +export const computeEntityRegistryName = ( + hass: HomeAssistant, + entry: EntityRegistryEntry +): string | null => { + if (entry.name) { + return entry.name; + } + const state = hass.states[entry.entity_id]; + return state ? computeStateName(state) : null; +}; + +export const fetchEntityRegistry = ( + hass: HomeAssistant +): Promise => + hass.callWS({ type: "config/entity_registry/list" }); + +export const updateEntityRegistryEntry = ( + hass: HomeAssistant, + entityId: string, + updates: Partial +): Promise => + hass.callWS({ + type: "config/entity_registry/update", + entity_id: entityId, + ...updates, + }); + +export const removeEntityRegistryEntry = ( + hass: HomeAssistant, + entityId: string +): Promise => + hass.callWS({ + type: "config/entity_registry/remove", + entity_id: entityId, + }); diff --git a/src/dialogs/more-info/more-info-settings.js b/src/dialogs/more-info/more-info-settings.js index 3118580c9f..878b3a2b8c 100644 --- a/src/dialogs/more-info/more-info-settings.js +++ b/src/dialogs/more-info/more-info-settings.js @@ -11,6 +11,7 @@ import LocalizeMixin from "../../mixins/localize-mixin"; import computeStateName from "../../common/entity/compute_state_name"; import computeDomain from "../../common/entity/compute_domain"; import isComponentLoaded from "../../common/config/is_component_loaded"; +import { updateEntityRegistryEntry } from "../../data/entity_registry"; /* * @appliesMixin EventsMixin @@ -122,12 +123,14 @@ class MoreInfoSettings extends LocalizeMixin(EventsMixin(PolymerElement)) { async _save() { try { - const info = await this.hass.callWS({ - type: "config/entity_registry/update", - entity_id: this.stateObj.entity_id, - name: this._name, - new_entity_id: this._entityId, - }); + const info = await updateEntityRegistryEntry( + this.hass, + this.stateObj.entity_id, + { + name: this._name, + new_entity_id: this._entityId, + } + ); this.registryInfo = info; diff --git a/src/layouts/app/dialog-manager-mixin.ts b/src/layouts/app/dialog-manager-mixin.ts index 812754d162..6d1699d706 100644 --- a/src/layouts/app/dialog-manager-mixin.ts +++ b/src/layouts/app/dialog-manager-mixin.ts @@ -1,5 +1,6 @@ import { Constructor, LitElement } from "lit-element"; import { HASSDomEvent, ValidHassDomEvent } from "../../common/dom/fire_event"; +import { HassBaseEl } from "./hass-base-mixin"; interface RegisterDialogParams { dialogShowEvent: keyof HASSDomEvents; @@ -7,6 +8,12 @@ interface RegisterDialogParams { dialogImport: () => Promise; } +interface ShowDialogParams { + dialogTag: keyof HTMLElementTagNameMap; + dialogImport: () => Promise; + dialogParams: T; +} + interface HassDialog extends HTMLElement { showDialog(params: T); } @@ -15,20 +22,34 @@ declare global { // for fire event interface HASSDomEvents { "register-dialog": RegisterDialogParams; + "show-dialog": ShowDialogParams; } // for add event listener interface HTMLElementEventMap { "register-dialog": HASSDomEvent; + "show-dialog": HASSDomEvent>; } } -export const dialogManagerMixin = (superClass: Constructor) => +const LOADED = {}; + +export const dialogManagerMixin = ( + superClass: Constructor +) => class extends superClass { protected firstUpdated(changedProps) { super.firstUpdated(changedProps); + // deprecated this.addEventListener("register-dialog", (e) => this.registerDialog(e.detail) ); + this.addEventListener( + "show-dialog", + async (e: HASSDomEvent>) => { + const { dialogTag, dialogImport, dialogParams } = e.detail; + this._showDialog(dialogImport, dialogTag, dialogParams); + } + ); } private registerDialog({ @@ -36,20 +57,29 @@ export const dialogManagerMixin = (superClass: Constructor) => dialogTag, dialogImport, }: RegisterDialogParams) { - let loaded: Promise>; - this.addEventListener(dialogShowEvent, (showEv) => { - if (!loaded) { - loaded = dialogImport().then(() => { - const dialogEl = document.createElement(dialogTag) as HassDialog; - this.shadowRoot!.appendChild(dialogEl); - (this as any).provideHass(dialogEl); - return dialogEl; - }); - } - loaded.then((dialogEl) => - dialogEl.showDialog((showEv as HASSDomEvent).detail) + this._showDialog( + dialogImport, + dialogTag, + (showEv as HASSDomEvent).detail ); }); } + + private async _showDialog( + dialogImport: () => Promise, + dialogTag: string, + dialogParams: unknown + ) { + if (!(dialogTag in LOADED)) { + LOADED[dialogTag] = dialogImport().then(() => { + const dialogEl = document.createElement(dialogTag) as HassDialog; + this.shadowRoot!.appendChild(dialogEl); + this.provideHass(dialogEl); + return dialogEl; + }); + } + const element = await LOADED[dialogTag]; + element.showDialog(dialogParams); + } }; diff --git a/src/panels/config/config-entries/ha-ce-entities-card.js b/src/panels/config/config-entries/ha-ce-entities-card.js index ea75808b7a..c06a75d525 100644 --- a/src/panels/config/config-entries/ha-ce-entities-card.js +++ b/src/panels/config/config-entries/ha-ce-entities-card.js @@ -8,14 +8,8 @@ import "../../../layouts/hass-subpage"; import EventsMixin from "../../../mixins/events-mixin"; import LocalizeMixIn from "../../../mixins/localize-mixin"; -import computeStateName from "../../../common/entity/compute_state_name"; import "../../../components/entity/state-badge"; - -function computeEntityName(hass, entity) { - if (entity.name) return entity.name; - const state = hass.states[entity.entity_id]; - return state ? computeStateName(state) : null; -} +import { computeEntityRegistryName } from "../../../data/entity_registry"; /* * @appliesMixin LocalizeMixIn @@ -66,7 +60,7 @@ class HaCeEntitiesCard extends LocalizeMixIn(EventsMixin(PolymerElement)) { _computeEntityName(entity, hass) { return ( - computeEntityName(hass, entity) || + computeEntityRegistryName(hass, entity) || `(${this.localize( "ui.panel.config.integrations.config_entry.entity_unavailable" )})` diff --git a/src/panels/config/dashboard/ha-config-navigation.js b/src/panels/config/dashboard/ha-config-navigation.js index 8be8a20c8d..2d092c76eb 100644 --- a/src/panels/config/dashboard/ha-config-navigation.js +++ b/src/panels/config/dashboard/ha-config-navigation.js @@ -10,7 +10,7 @@ import LocalizeMixin from "../../../mixins/localize-mixin"; import isComponentLoaded from "../../../common/config/is_component_loaded"; -const CORE_PAGES = ["core", "customize"]; +const CORE_PAGES = ["core", "customize", "entity_registry"]; /* * @appliesMixin LocalizeMixin * @appliesMixin NavigateMixin @@ -50,7 +50,15 @@ class HaConfigNavigation extends LocalizeMixin(NavigateMixin(PolymerElement)) { pages: { type: Array, - value: ["core", "customize", "automation", "script", "zha", "zwave"], + value: [ + "core", + "customize", + "entity_registry", + "automation", + "script", + "zha", + "zwave", + ], }, }; } diff --git a/src/panels/config/entity_registry/dialog-entity-registry-detail.ts b/src/panels/config/entity_registry/dialog-entity-registry-detail.ts new file mode 100644 index 0000000000..b980befb94 --- /dev/null +++ b/src/panels/config/entity_registry/dialog-entity-registry-detail.ts @@ -0,0 +1,191 @@ +import { + LitElement, + html, + css, + PropertyDeclarations, + CSSResult, + TemplateResult, +} from "lit-element"; +import "@polymer/paper-dialog/paper-dialog"; +import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; +import "@polymer/paper-input/paper-input"; + +import { EntityRegistryDetailDialogParams } from "./show-dialog-entity-registry-detail"; +import { PolymerChangedEvent } from "../../../polymer-types"; +import { haStyleDialog } from "../../../resources/ha-style"; +import { HomeAssistant } from "../../../types"; +import computeDomain from "../../../common/entity/compute_domain"; +import { HassEntity } from "home-assistant-js-websocket"; +import computeStateName from "../../../common/entity/compute_state_name"; + +class DialogEntityRegistryDetail extends LitElement { + public hass!: HomeAssistant; + private _name!: string; + private _entityId!: string; + private _error?: string; + private _params?: EntityRegistryDetailDialogParams; + private _submitting?: boolean; + + static get properties(): PropertyDeclarations { + return { + _error: {}, + _name: {}, + _entityId: {}, + _params: {}, + }; + } + + public async showDialog( + params: EntityRegistryDetailDialogParams + ): Promise { + this._params = params; + this._error = undefined; + this._name = this._params.entry.name || ""; + this._entityId = this._params.entry.entity_id; + await this.updateComplete; + } + + protected render(): TemplateResult | void { + if (!this._params) { + return html``; + } + const entry = this._params.entry; + const stateObj: HassEntity | undefined = this.hass.states[entry.entity_id]; + const invalidDomainUpdate = + computeDomain(this._entityId.trim()) !== + computeDomain(this._params.entry.entity_id); + + return html` + +

${entry.entity_id}

+ + ${!stateObj + ? html` +
This entity is not currently available.
+ ` + : ""} + ${this._error + ? html` +
${this._error}
+ ` + : ""} +
+ + +
+
+
+ + DELETE + + + UPDATE + +
+
+ `; + } + + private _nameChanged(ev: PolymerChangedEvent): void { + this._error = undefined; + this._name = ev.detail.value; + } + + private _entityIdChanged(ev: PolymerChangedEvent): void { + this._error = undefined; + this._entityId = ev.detail.value; + } + + private async _updateEntry(): Promise { + try { + this._submitting = true; + await this._params!.updateEntry({ + name: this._name.trim() || null, + new_entity_id: this._entityId.trim(), + }); + this._params = undefined; + } catch (err) { + this._submitting = false; + this._error = err; + } + } + + private async _deleteEntry(): Promise { + this._submitting = true; + + if (await this._params!.removeEntry()) { + this._params = undefined; + } else { + this._submitting = false; + } + } + + private _openedChanged(ev: PolymerChangedEvent): void { + if (!(ev.detail as any).value) { + this._params = undefined; + } + } + + static get styles(): CSSResult[] { + return [ + haStyleDialog, + css` + paper-dialog { + min-width: 400px; + } + .form { + padding-bottom: 24px; + } + paper-button { + font-weight: 500; + } + paper-button.danger { + font-weight: 500; + color: var(--google-red-500); + margin-left: -12px; + margin-right: auto; + } + .error { + color: var(--google-red-500); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-entity-registry-detail": DialogEntityRegistryDetail; + } +} + +customElements.define( + "dialog-entity-registry-detail", + DialogEntityRegistryDetail +); diff --git a/src/panels/config/entity_registry/ha-config-entity-registry.ts b/src/panels/config/entity_registry/ha-config-entity-registry.ts new file mode 100644 index 0000000000..c4401dd20b --- /dev/null +++ b/src/panels/config/entity_registry/ha-config-entity-registry.ts @@ -0,0 +1,165 @@ +import { + LitElement, + TemplateResult, + html, + css, + CSSResult, + PropertyDeclarations, +} from "lit-element"; +import "@polymer/paper-item/paper-icon-item"; +import "@polymer/paper-item/paper-item-body"; +import "@polymer/paper-card/paper-card"; + +import { HomeAssistant } from "../../../types"; +import { + EntityRegistryEntry, + fetchEntityRegistry, + computeEntityRegistryName, + updateEntityRegistryEntry, + removeEntityRegistryEntry, +} from "../../../data/entity_registry"; +import "../../../layouts/hass-subpage"; +import "../../../layouts/hass-loading-screen"; +import "../../../components/ha-icon"; +import compare from "../../../common/string/compare"; +import domainIcon from "../../../common/entity/domain_icon"; +import stateIcon from "../../../common/entity/state_icon"; +import computeDomain from "../../../common/entity/compute_domain"; +import "../ha-config-section"; +import { + showEntityRegistryDetailDialog, + loadEntityRegistryDetailDialog, +} from "./show-dialog-entity-registry-detail"; + +class HaConfigEntityRegistry extends LitElement { + public hass?: HomeAssistant; + public isWide?: boolean; + private _items?: EntityRegistryEntry[]; + + static get properties(): PropertyDeclarations { + return { + hass: {}, + isWide: {}, + _items: {}, + }; + } + + protected render(): TemplateResult | void { + if (!this.hass || this._items === undefined) { + return html` + + `; + } + return html` + + + Entity Registry + + Home Assistant keeps a registry of every entity it has ever seen + that can be uniquely identified. Each of these entities will have an + entity ID assigned which will be reserved for just this entity. +

+ Use the entity registry to override the name, change the entity ID + or remove the entry from Home Assistant. Note, removing the entity + registry entry won't remove the entity. To do that, remove it from + the integrations page. +

+
+ + ${this._items.map((entry) => { + const state = this.hass!.states[entry.entity_id]; + return html` + + + +
+ ${computeEntityRegistryName(this.hass!, entry) || + "(unavailable)"} +
+
+ ${entry.entity_id} +
+
+
${entry.platform}
+
+ `; + })} +
+
+
+ `; + } + + protected firstUpdated(changedProps): void { + super.firstUpdated(changedProps); + this._fetchData(); + loadEntityRegistryDetailDialog(); + } + + private async _fetchData(): Promise { + this._items = (await fetchEntityRegistry(this.hass!)).sort((ent1, ent2) => + compare(ent1.entity_id, ent2.entity_id) + ); + } + + private _openEditEntry(ev: MouseEvent): void { + const entry = (ev.currentTarget! as any).entry; + showEntityRegistryDetailDialog(this, { + entry, + updateEntry: async (updates) => { + const updated = await updateEntityRegistryEntry( + this.hass!, + entry.entity_id, + updates + ); + this._items = this._items!.map((ent) => + ent === entry ? updated : ent + ); + }, + removeEntry: async () => { + if ( + !confirm(`Are you sure you want to delete this entry? + +Deleting an entry will not remove the entity from Home Assistant. To do this, you will need to remove the integration "${ + entry.platform + }" from Home Assistant.`) + ) { + return false; + } + + try { + await removeEntityRegistryEntry(this.hass!, entry.entity_id); + this._items = this._items!.filter((ent) => ent !== entry); + return true; + } catch (err) { + return false; + } + }, + }); + } + + static get styles(): CSSResult { + return css` + a { + color: var(--primary-color); + } + paper-card { + display: block; + background-color: white; + } + paper-icon-item { + cursor: pointer; + } + ha-icon { + margin-left: 8px; + } + `; + } +} + +customElements.define("ha-config-entity-registry", HaConfigEntityRegistry); diff --git a/src/panels/config/entity_registry/show-dialog-entity-registry-detail.ts b/src/panels/config/entity_registry/show-dialog-entity-registry-detail.ts new file mode 100644 index 0000000000..372c5f2efc --- /dev/null +++ b/src/panels/config/entity_registry/show-dialog-entity-registry-detail.ts @@ -0,0 +1,27 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { + EntityRegistryEntry, + EntityRegistryEntryUpdateParams, +} from "../../../data/entity_registry"; + +export interface EntityRegistryDetailDialogParams { + entry: EntityRegistryEntry; + updateEntry: ( + updates: Partial + ) => Promise; + removeEntry: () => Promise; +} + +export const loadEntityRegistryDetailDialog = () => + import(/* webpackChunkName: "entity-registry-detail-dialog" */ "./dialog-entity-registry-detail"); + +export const showEntityRegistryDetailDialog = ( + element: HTMLElement, + systemLogDetailParams: EntityRegistryDetailDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-entity-registry-detail", + dialogImport: loadEntityRegistryDetailDialog, + dialogParams: systemLogDetailParams, + }); +}; diff --git a/src/panels/config/ha-panel-config.js b/src/panels/config/ha-panel-config.js index ff701dc6db..db10771fe1 100644 --- a/src/panels/config/ha-panel-config.js +++ b/src/panels/config/ha-panel-config.js @@ -16,6 +16,7 @@ import(/* webpackChunkName: "panel-config-core" */ "./core/ha-config-core"); import(/* webpackChunkName: "panel-config-customize" */ "./customize/ha-config-customize"); import(/* webpackChunkName: "panel-config-dashboard" */ "./dashboard/ha-config-dashboard"); import(/* webpackChunkName: "panel-config-script" */ "./script/ha-config-script"); +import(/* webpackChunkName: "panel-config-entity-registry" */ "./entity_registry/ha-config-entity-registry"); import(/* webpackChunkName: "panel-config-users" */ "./users/ha-config-users"); import(/* webpackChunkName: "panel-config-zha" */ "./zha/ha-config-zha"); import(/* webpackChunkName: "panel-config-zwave" */ "./zwave/ha-config-zwave"); @@ -92,6 +93,19 @@ class HaPanelConfig extends EventsMixin(NavigateMixin(PolymerElement)) { > + +