From f02fa6a94b0eeb5323b9a79e5b93009e5bab52f6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 7 Jan 2020 12:29:42 +0100 Subject: [PATCH] Add multi select to entity registry (#4424) * Add multi select to entity registry * Fix filter and sort on status * Remove unused prop platform * Review * Update ha-config-entity-registry.ts --- src/common/search/search-input.ts | 2 +- src/components/data-table/ha-data-table.ts | 73 ++- .../dialog-entity-registry-detail.ts | 12 +- .../ha-config-entity-registry.ts | 451 +++++++++++++++--- src/translations/en.json | 33 +- 5 files changed, 454 insertions(+), 117 deletions(-) diff --git a/src/common/search/search-input.ts b/src/common/search/search-input.ts index 8bfcf399fd..2c3e772c42 100644 --- a/src/common/search/search-input.ts +++ b/src/common/search/search-input.ts @@ -14,7 +14,7 @@ import "@material/mwc-button"; @customElement("search-input") class SearchInput extends LitElement { - @property() private filter?: string; + @property() public filter?: string; public focus() { this.shadowRoot!.querySelector("paper-input")!.focus(); diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index ea7f0e59ac..7827938381 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -75,7 +75,7 @@ export interface DataTableSortColumnData { export interface DataTableColumnData extends DataTableSortColumnData { title: string; type?: "numeric" | "icon"; - template?: (data: any, row: T) => TemplateResult; + template?: (data: any, row: T) => TemplateResult | string; } export interface DataTableRowData { @@ -88,11 +88,11 @@ export class HaDataTable extends BaseElement { @property({ type: Array }) public data: DataTableRowData[] = []; @property({ type: Boolean }) public selectable = false; @property({ type: String }) public id = "id"; + @property({ type: String }) public filter = ""; protected mdcFoundation!: MDCDataTableFoundation; protected readonly mdcFoundationClass = MDCDataTableFoundation; @query(".mdc-data-table") protected mdcRoot!: HTMLElement; @queryAll(".mdc-data-table__row") protected rowElements!: HTMLElement[]; - @query("#header-checkbox") private _headerCheckbox!: HaCheckbox; @property({ type: Boolean }) private _filterable = false; @property({ type: Boolean }) private _headerChecked = false; @property({ type: Boolean }) private _headerIndeterminate = false; @@ -108,13 +108,19 @@ export class HaDataTable extends BaseElement { private _worker: any | undefined; private _debounceSearch = debounce( - (ev) => { - this._filter = ev.detail.value; + (value: string) => { + this._filter = value; }, 200, false ); + public clearSelection(): void { + this._headerChecked = false; + this._headerIndeterminate = false; + this.mdcFoundation.handleHeaderRowCheckboxChange(); + } + protected firstUpdated() { super.firstUpdated(); this._worker = sortFilterWorker(); @@ -146,6 +152,10 @@ export class HaDataTable extends BaseElement { this._sortColumns = clonedColumns; } + if (properties.has("filter")) { + this._debounceSearch(this.filter); + } + if ( properties.has("data") || properties.has("columns") || @@ -159,14 +169,18 @@ export class HaDataTable extends BaseElement { protected render() { return html` - ${this._filterable - ? html` - - ` - : ""}
+ + ${this._filterable + ? html` +
+ +
+ ` + : ""} +
@@ -178,7 +192,6 @@ export class HaDataTable extends BaseElement { scope="col" > ${this.hass.localize( "ui.panel.config.entity_registry.editor.delete" @@ -201,13 +200,8 @@ class DialogEntityRegistryDetail extends LitElement { private _confirmDeleteEntry(): void { showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.entity_registry.editor.confirm_delete" - ), text: this.hass.localize( - "ui.panel.config.entity_registry.editor.confirm_delete2", - "platform", - this._platform + "ui.panel.config.entity_registry.editor.confirm_delete" ), confirm: () => this._deleteEntry(), }); diff --git a/src/panels/config/entity_registry/ha-config-entity-registry.ts b/src/panels/config/entity_registry/ha-config-entity-registry.ts index e7854ece8f..3b3fec5eff 100644 --- a/src/panels/config/entity_registry/ha-config-entity-registry.ts +++ b/src/panels/config/entity_registry/ha-config-entity-registry.ts @@ -5,19 +5,28 @@ import { css, CSSResult, property, + query, } from "lit-element"; +import { styleMap } from "lit-html/directives/style-map"; + +import "@polymer/paper-checkbox/paper-checkbox"; +import "@polymer/paper-dropdown-menu/paper-dropdown-menu"; +import "@polymer/paper-item/paper-icon-item"; +import "@polymer/paper-listbox/paper-listbox"; +import "@polymer/paper-tooltip/paper-tooltip"; import { HomeAssistant } from "../../../types"; import { EntityRegistryEntry, computeEntityRegistryName, subscribeEntityRegistry, + removeEntityRegistryEntry, + updateEntityRegistryEntry, } from "../../../data/entity_registry"; import "../../../layouts/hass-subpage"; import "../../../layouts/hass-loading-screen"; import "../../../components/data-table/ha-data-table"; import "../../../components/ha-icon"; -import "../../../components/ha-switch"; import { domainIcon } from "../../../common/entity/domain_icon"; import { stateIcon } from "../../../common/entity/state_icon"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -26,25 +35,33 @@ import { loadEntityRegistryDetailDialog, } from "./show-dialog-entity-registry-detail"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; -// tslint:disable-next-line -import { HaSwitch } from "../../../components/ha-switch"; import memoize from "memoize-one"; // tslint:disable-next-line import { DataTableColumnContainer, RowClickedEvent, + SelectionChangedEvent, + HaDataTable, + DataTableColumnData, } from "../../../components/data-table/ha-data-table"; +import { showConfirmationDialog } from "../../../dialogs/confirmation/show-dialog-confirmation"; class HaConfigEntityRegistry extends LitElement { @property() public hass!: HomeAssistant; - @property() public isWide?: boolean; + @property() public isWide!: boolean; + @property() public narrow!: boolean; @property() private _entities?: EntityRegistryEntry[]; @property() private _showDisabled = false; + @property() private _showUnavailable = true; + @property() private _filter = ""; + @property() private _selectedEntities: string[] = []; + @query("ha-data-table") private _dataTable!: HaDataTable; + private _unsubEntities?: UnsubscribeFunc; private _columns = memoize( - (_language): DataTableColumnContainer => { - return { + (narrow, _language): DataTableColumnContainer => { + const columns: DataTableColumnContainer = { icon: { title: "", type: "icon", @@ -60,58 +77,123 @@ class HaConfigEntityRegistry extends LitElement { filterable: true, direction: "asc", }, - entity_id: { - title: this.hass.localize( - "ui.panel.config.entity_registry.picker.headers.entity_id" - ), - sortable: true, - filterable: true, - }, - platform: { - title: this.hass.localize( - "ui.panel.config.entity_registry.picker.headers.integration" - ), - sortable: true, - filterable: true, - template: (platform) => - html` - ${this.hass.localize(`component.${platform}.config.title`) || - platform} - `, - }, - disabled_by: { - title: this.hass.localize( - "ui.panel.config.entity_registry.picker.headers.enabled" - ), - type: "icon", - template: (disabledBy) => html` - - `, - }, }; + + const statusColumn: DataTableColumnData = { + title: this.hass.localize( + "ui.panel.config.entity_registry.picker.headers.status" + ), + type: "icon", + sortable: true, + filterable: true, + template: (_status, entity: any) => + entity.unavailable || entity.disabled_by + ? html` +
+ + + ${entity.unavailable + ? this.hass.localize( + "ui.panel.config.entity_registry.picker.status.unavailable" + ) + : this.hass.localize( + "ui.panel.config.entity_registry.picker.status.disabled" + )} + +
+ ` + : "", + }; + + if (narrow) { + columns.name.template = (name, entity: any) => { + return html` + ${name}
+ ${entity.entity_id} | + ${this.hass.localize(`component.${entity.platform}.config.title`) || + entity.platform} + `; + }; + columns.status = statusColumn; + return columns; + } + + columns.entity_id = { + title: this.hass.localize( + "ui.panel.config.entity_registry.picker.headers.entity_id" + ), + sortable: true, + filterable: true, + }; + columns.platform = { + title: this.hass.localize( + "ui.panel.config.entity_registry.picker.headers.integration" + ), + sortable: true, + filterable: true, + template: (platform) => + this.hass.localize(`component.${platform}.config.title`) || platform, + }; + columns.status = statusColumn; + + return columns; } ); private _filteredEntities = memoize( - (entities: EntityRegistryEntry[], showDisabled: boolean) => - (showDisabled - ? entities - : entities.filter((entity) => !Boolean(entity.disabled_by)) - ).map((entry) => { + ( + entities: EntityRegistryEntry[], + showDisabled: boolean, + showUnavailable: boolean + ) => { + if (!showDisabled) { + entities = entities.filter((entity) => !Boolean(entity.disabled_by)); + } + + return entities.reduce((result, entry) => { const state = this.hass!.states[entry.entity_id]; - return { + + const unavailable = + state && (state.state === "unavailable" || state.attributes.restored); // if there is not state it is disabled + + if (!showUnavailable && unavailable) { + return result; + } + + result.push({ ...entry, icon: state ? stateIcon(state) : domainIcon(computeDomain(entry.entity_id)), name: computeEntityRegistryName(this.hass!, entry) || - this.hass!.localize("state.default.unavailable"), - }; - }) + this.hass.localize("state.default.unavailable"), + unavailable, + status: unavailable + ? this.hass.localize( + "ui.panel.config.entity_registry.picker.status.unavailable" + ) + : entry.disabled_by + ? this.hass.localize( + "ui.panel.config.entity_registry.picker.status.disabled" + ) + : this.hass.localize( + "ui.panel.config.entity_registry.picker.status.ok" + ), + }); + return result; + }, [] as any); + } ); public disconnectedCallback() { @@ -133,17 +215,19 @@ class HaConfigEntityRegistry extends LitElement { "ui.panel.config.entity_registry.caption" )}" > -
-
-

- ${this.hass.localize( - "ui.panel.config.entity_registry.picker.header" - )} -

-

- ${this.hass.localize( - "ui.panel.config.entity_registry.picker.introduction" - )} +

+
+

+ ${this.hass.localize( + "ui.panel.config.entity_registry.picker.header" + )} +

+

+ ${this.hass.localize( + "ui.panel.config.entity_registry.picker.introduction" + )} +

+

${this.hass.localize( "ui.panel.config.entity_registry.picker.introduction2" @@ -154,22 +238,123 @@ class HaConfigEntityRegistry extends LitElement { "ui.panel.config.entity_registry.picker.integrations_page" )} - ${this.hass.localize( - "ui.panel.config.entity_registry.picker.show_disabled" - )} -

-

- - + +
+ ${this._selectedEntities.length + ? html` +

+ ${this.hass.localize( + "ui.panel.config.entity_registry.picker.selected", + "number", + this._selectedEntities.length + )} +

+
+ ${!this.narrow + ? html` + ${this.hass.localize( + "ui.panel.config.entity_registry.picker.enable_selected.button" + )} + ${this.hass.localize( + "ui.panel.config.entity_registry.picker.disable_selected.button" + )} + ${this.hass.localize( + "ui.panel.config.entity_registry.picker.remove_selected.button" + )} + ` + : html` + + + ${this.hass.localize( + "ui.panel.config.entity_registry.picker.enable_selected.button" + )} + + + + ${this.hass.localize( + "ui.panel.config.entity_registry.picker.disable_selected.button" + )} + + + + ${this.hass.localize( + "ui.panel.config.entity_registry.picker.remove_selected.button" + )} + + `} +
+ ` + : html` + + + + + + + ${this.hass!.localize( + "ui.panel.config.entity_registry.picker.filter.show_disabled" + )} + + + + ${this.hass!.localize( + "ui.panel.config.entity_registry.picker.filter.show_unavailable" + )} + + + + `} +
+
`; @@ -192,8 +377,103 @@ class HaConfigEntityRegistry extends LitElement { } } - private _showDisabledChanged(ev: Event) { - this._showDisabled = (ev.target as HaSwitch).checked; + private _showDisabledChanged() { + this._showDisabled = !this._showDisabled; + } + + private _showRestoredChanged() { + this._showUnavailable = !this._showUnavailable; + } + + private _handleSearchChange(ev: CustomEvent) { + this._filter = ev.detail.value; + } + + private _handleSelectionChanged(ev: CustomEvent): void { + const changedSelection = ev.detail as SelectionChangedEvent; + const entity = changedSelection.id; + if (changedSelection.selected) { + this._selectedEntities = [...this._selectedEntities, entity]; + } else { + this._selectedEntities = this._selectedEntities.filter( + (entityId) => entityId !== entity + ); + } + } + + private _enableSelected() { + showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.entity_registry.picker.enable_selected.confirm_title", + "number", + this._selectedEntities.length + ), + text: this.hass.localize( + "ui.panel.config.entity_registry.picker.enable_selected.confirm_text" + ), + confirmBtnText: this.hass.localize("ui.common.yes"), + cancelBtnText: this.hass.localize("ui.common.no"), + confirm: () => { + this._selectedEntities.forEach((entity) => + updateEntityRegistryEntry(this.hass, entity, { + disabled_by: null, + }) + ); + this._clearSelection(); + }, + }); + } + + private _disableSelected() { + showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.entity_registry.picker.disable_selected.confirm_title", + "number", + this._selectedEntities.length + ), + text: this.hass.localize( + "ui.panel.config.entity_registry.picker.disable_selected.confirm_text" + ), + confirmBtnText: this.hass.localize("ui.common.yes"), + cancelBtnText: this.hass.localize("ui.common.no"), + confirm: () => { + this._selectedEntities.forEach((entity) => + updateEntityRegistryEntry(this.hass, entity, { + disabled_by: "user", + }) + ); + this._clearSelection(); + }, + }); + } + + private _removeSelected() { + const removeableEntities = this._selectedEntities.filter((entity) => { + const stateObj = this.hass.states[entity]; + return stateObj?.attributes.restored; + }); + showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.entity_registry.picker.remove_selected.confirm_title", + "number", + removeableEntities.length + ), + text: this.hass.localize( + "ui.panel.config.entity_registry.picker.remove_selected.confirm_text" + ), + confirmBtnText: this.hass.localize("ui.common.yes"), + cancelBtnText: this.hass.localize("ui.common.no"), + confirm: () => { + removeableEntities.forEach((entity) => + removeEntityRegistryEntry(this.hass, entity) + ); + this._clearSelection(); + }, + }); + } + + private _clearSelection() { + this._dataTable.clearSelection(); } private _openEditEntry(ev: CustomEvent): void { @@ -237,18 +517,35 @@ class HaConfigEntityRegistry extends LitElement { opacity: var(--dark-primary-opacity); } .intro { - padding: 24px 16px 0; + padding: 24px 16px; } .content { padding: 4px; } ha-data-table { - margin-bottom: 24px; - margin-top: 0px; + width: 100%; } ha-switch { margin-top: 16px; } + .table-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + border-bottom: 1px solid rgba(var(--rgb-primary-text-color), 0.12); + } + search-input { + flex-grow: 1; + } + .selected-txt { + font-weight: bold; + margin-top: 38px; + padding-left: 16px; + } + .header-btns > mwc-button, + .header-btns > paper-icon-button { + margin: 8px; + } `; } } diff --git a/src/translations/en.json b/src/translations/en.json index 8e8a008cf0..5e2655b497 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1257,14 +1257,38 @@ "picker": { "header": "Entities", "introduction": "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.", - "introduction2": "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, follow the link below and remove it from the integrations page.", - "integrations_page": "Integrations page", - "show_disabled": "Show disabled entities", + "introduction2": "Use the entity registry to override the name, change the entity ID or remove the entry from Home Assistant.", + "filter": { + "filter": "Filter", + "show_disabled": "Show disabled entities", + "show_unavailable": "Show unavailable entities" + }, + "status": { + "unavailable": "Unavailable", + "disabled": "Disabled", + "ok": "Ok" + }, "headers": { "name": "Name", "entity_id": "Entity ID", "integration": "Integration", - "enabled": "Enabled" + "status": "Status" + }, + "selected": "{number} selected", + "enable_selected": { + "button": "Enable selected", + "confirm_title": "Do you want to enable {number} entities?", + "confirm_text": "This will make them available in Home Assistant again if they are now disabled." + }, + "disable_selected": { + "button": "Disable selected", + "confirm_title": "Do you want to disable {number} entities?", + "confirm_text": "Disabled entities will not be added to Home Assistant." + }, + "remove_selected": { + "button": "Remove selected", + "confirm_title": "Do you want to remove {number} entities?", + "confirm_text": "Entities can only be removed when the integration is no longer providing the entities." } }, "editor": { @@ -1275,7 +1299,6 @@ "enabled_description": "Disabled entities will not be added to Home Assistant.", "delete": "DELETE", "confirm_delete": "Are you sure you want to delete this entry?", - "confirm_delete2": "Deleting an entry will not remove the entity from Home Assistant. To do this, you will need to remove the integration '{platform}' from Home Assistant.", "update": "UPDATE", "note": "Note: this might not work yet with all integrations." }