Convert integration entry page to data table (#3963)

* Convert integration entry page to data table

* Simplify device-card

In a future PR this has to be changed further

* Center no devices text

* Review comments
This commit is contained in:
Bram Kragten 2019-10-09 17:48:41 +02:00 committed by Paulus Schoutsen
parent fc3f7ca4b2
commit 320be2e5d9
7 changed files with 311 additions and 407 deletions

View File

@ -73,7 +73,7 @@ export interface DataTabelSortColumnData {
export interface DataTabelColumnData extends DataTabelSortColumnData {
title: string;
type?: "numeric";
template?: (data: any) => TemplateResult;
template?: (data: any, row: DataTabelRowData) => TemplateResult;
}
export interface DataTabelRowData {
@ -254,7 +254,7 @@ export class HaDataTable extends BaseElement {
})}"
>
${column.template
? column.template(row[key])
? column.template(row[key], row)
: row[key]}
</td>
`;

View File

@ -1,35 +1,17 @@
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-dropdown-menu/paper-dropdown-menu";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../../../components/ha-card";
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";
import { compare } from "../../../../common/string/compare";
import {
subscribeDeviceRegistry,
updateDeviceRegistryEntry,
} from "../../../../data/device_registry";
import { subscribeAreaRegistry } from "../../../../data/area_registry";
import { updateDeviceRegistryEntry } from "../../../../data/device_registry";
import {
loadDeviceRegistryDetailDialog,
showDeviceRegistryDetailDialog,
} from "../../../../dialogs/device-registry-detail/show-dialog-device-registry-detail";
function computeEntityName(hass, entity) {
if (entity.name) return entity.name;
const state = hass.states[entity.entity_id];
return state ? computeStateName(state) : null;
}
/*
* @appliesMixin EventsMixin
*/
@ -37,10 +19,6 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) {
static get template() {
return html`
<style>
:host(:not([narrow])) .device-entities {
max-height: 225px;
overflow: auto;
}
ha-card {
flex: 1 0 100%;
padding-bottom: 10px;
@ -70,11 +48,6 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) {
.extra-info {
margin-top: 8px;
}
paper-icon-item {
cursor: pointer;
padding-top: 4px;
padding-bottom: 4px;
}
.manuf,
.entity-id,
.area {
@ -82,15 +55,6 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) {
}
</style>
<ha-card>
<div class="card-header">
<template is="dom-if" if="[[!hideSettings]]">
<div class="name">[[_deviceName(device)]]</div>
<paper-icon-button
icon="hass:settings"
on-click="_gotoSettings"
></paper-icon-button>
</template>
</div>
<div class="card-content">
<div class="info">
<div class="model">[[device.model]]</div>
@ -122,27 +86,6 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) {
</div>
</template>
</div>
<template is="dom-if" if="[[!hideEntities]]">
<div class="device-entities">
<template
is="dom-repeat"
items="[[_computeDeviceEntities(hass, device, entities)]]"
as="entity"
>
<paper-icon-item on-click="_openMoreInfo">
<state-badge
state-obj="[[_computeStateObj(entity, hass)]]"
slot="item-icon"
></state-badge>
<paper-item-body>
<div class="name">[[_computeEntityName(entity, hass)]]</div>
<div class="secondary entity-id">[[entity.entity_id]]</div>
</paper-item-body>
</paper-icon-item>
</template>
</div>
</template>
</ha-card>
`;
}
@ -152,14 +95,11 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) {
device: Object,
devices: Array,
areas: Array,
entities: Array,
hass: Object,
narrow: {
type: Boolean,
reflectToAttribute: true,
},
hideSettings: { type: Boolean, value: false },
hideEntities: { type: Boolean, value: false },
_childDevices: {
type: Array,
computed: "_computeChildDevices(device, devices)",
@ -172,30 +112,6 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) {
loadDeviceRegistryDetailDialog();
}
connectedCallback() {
super.connectedCallback();
this._unsubAreas = subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
});
this._unsubDevices = subscribeDeviceRegistry(
this.hass.connection,
(devices) => {
this.devices = devices;
this.device = devices.find((device) => device.id === this.device.id);
}
);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubAreas) {
this._unsubAreas();
}
if (this._unsubDevices) {
this._unsubDevices();
}
}
_computeArea(areas, device) {
if (!areas || !device || !device.area_id) {
return "No Area";
@ -210,30 +126,6 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) {
.sort((dev1, dev2) => compare(dev1.name, dev2.name));
}
_computeDeviceEntities(hass, device, entities) {
return entities
.filter((entity) => entity.device_id === device.id)
.sort((ent1, ent2) =>
compare(
computeEntityName(hass, ent1) || `zzz${ent1.entity_id}`,
computeEntityName(hass, ent2) || `zzz${ent2.entity_id}`
)
);
}
_computeStateObj(entity, hass) {
return hass.states[entity.entity_id];
}
_computeEntityName(entity, hass) {
return (
computeEntityName(hass, entity) ||
`(${this.localize(
"ui.panel.config.integrations.config_entry.entity_unavailable"
)})`
);
}
_deviceName(device) {
return device.name_by_user || device.name;
}

View File

@ -13,6 +13,7 @@ import "../../../layouts/hass-subpage";
import "../../../layouts/hass-error-screen";
import "../ha-config-section";
import "./device-detail/ha-device-card";
import "./device-detail/ha-device-triggers-card";
import "./device-detail/ha-device-conditions-card";
import "./device-detail/ha-device-actions-card";
@ -144,9 +145,6 @@ export class HaConfigDevicePage extends LitElement {
.areas=${this.areas}
.devices=${this.devices}
.device=${device}
.entities=${this.entities}
hide-settings
hide-entities
></ha-device-card>
${entities.length

View File

@ -1,19 +1,5 @@
import "@polymer/paper-tooltip/paper-tooltip";
import "@material/mwc-button";
import "@polymer/iron-icon/iron-icon";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import "../../../components/ha-card";
import "../../../components/data-table/ha-data-table";
import "../../../components/entity/ha-state-icon";
import "../../../layouts/hass-subpage";
import "../../../resources/ha-style";
import "../../../components/ha-icon-next";
import "../ha-config-section";
import memoizeOne from "memoize-one";
import "./ha-devices-data-table";
import {
LitElement,
@ -21,33 +7,14 @@ import {
TemplateResult,
property,
customElement,
CSSResult,
css,
} from "lit-element";
import { HomeAssistant } from "../../../types";
// tslint:disable-next-line
import {
DataTabelColumnContainer,
RowClickedEvent,
DataTabelRowData,
} from "../../../components/data-table/ha-data-table";
// tslint:disable-next-line
import { DeviceRegistryEntry } from "../../../data/device_registry";
import { EntityRegistryEntry } from "../../../data/entity_registry";
import { ConfigEntry } from "../../../data/config_entries";
import { AreaRegistryEntry } from "../../../data/area_registry";
import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize";
import { computeStateName } from "../../../common/entity/compute_state_name";
interface DeviceRowData extends DeviceRegistryEntry {
device?: DeviceRowData;
area?: string;
integration?: string;
battery_entity?: string;
}
interface DeviceEntityLookup {
[deviceId: string]: EntityRegistryEntry[];
}
@customElement("ha-config-devices-dashboard")
export class HaConfigDeviceDashboard extends LitElement {
@ -59,234 +26,35 @@ export class HaConfigDeviceDashboard extends LitElement {
@property() public areas!: AreaRegistryEntry[];
@property() public domain!: string;
private _devices = memoizeOne(
(
devices: DeviceRegistryEntry[],
entries: ConfigEntry[],
entities: EntityRegistryEntry[],
areas: AreaRegistryEntry[],
domain: string,
localize: LocalizeFunc
) => {
// Some older installations might have devices pointing at invalid entryIDs
// So we guard for that.
let outputDevices: DeviceRowData[] = devices;
const deviceLookup: { [deviceId: string]: DeviceRegistryEntry } = {};
for (const device of devices) {
deviceLookup[device.id] = device;
}
const deviceEntityLookup: DeviceEntityLookup = {};
for (const entity of entities) {
if (!entity.device_id) {
continue;
}
if (!(entity.device_id in deviceEntityLookup)) {
deviceEntityLookup[entity.device_id] = [];
}
deviceEntityLookup[entity.device_id].push(entity);
}
const entryLookup: { [entryId: string]: ConfigEntry } = {};
for (const entry of entries) {
entryLookup[entry.entry_id] = entry;
}
const areaLookup: { [areaId: string]: AreaRegistryEntry } = {};
for (const area of areas) {
areaLookup[area.area_id] = area;
}
if (domain) {
outputDevices = outputDevices.filter((device) =>
device.config_entries.find(
(entryId) =>
entryId in entryLookup && entryLookup[entryId].domain === domain
)
);
}
outputDevices = outputDevices.map((device) => {
return {
...device,
name:
device.name_by_user ||
device.name ||
this._fallbackDeviceName(device.id, deviceEntityLookup) ||
"No name",
model: device.model || "<unknown>",
manufacturer: device.manufacturer || "<unknown>",
area: device.area_id ? areaLookup[device.area_id].name : "No area",
integration: device.config_entries.length
? device.config_entries
.filter((entId) => entId in entryLookup)
.map(
(entId) =>
localize(
`component.${entryLookup[entId].domain}.config.title`
) || entryLookup[entId].domain
)
.join(", ")
: "No integration",
battery_entity: this._batteryEntity(device.id, deviceEntityLookup),
};
});
return outputDevices;
}
);
private _columns = memoizeOne(
(narrow: boolean): DataTabelColumnContainer =>
narrow
? {
device: {
title: "Device",
sortable: true,
filterKey: "name",
filterable: true,
direction: "asc",
template: (device: DeviceRowData) => {
const battery = device.battery_entity
? this.hass.states[device.battery_entity]
: undefined;
// Have to work on a nice layout for mobile
return html`
${device.name_by_user || device.name}<br />
${device.area} | ${device.integration}<br />
${battery
? html`
${battery.state}%
<ha-state-icon
.hass=${this.hass!}
.stateObj=${battery}
></ha-state-icon>
`
: ""}
`;
},
},
}
: {
device_name: {
title: "Device",
sortable: true,
filterable: true,
direction: "asc",
},
manufacturer: {
title: "Manufacturer",
sortable: true,
filterable: true,
},
model: {
title: "Model",
sortable: true,
filterable: true,
},
area: {
title: "Area",
sortable: true,
filterable: true,
},
integration: {
title: "Integration",
sortable: true,
filterable: true,
},
battery: {
title: "Battery",
sortable: true,
type: "numeric",
template: (batteryEntity: string) => {
const battery = batteryEntity
? this.hass.states[batteryEntity]
: undefined;
return battery
? html`
${battery.state}%
<ha-state-icon
.hass=${this.hass!}
.stateObj=${battery}
></ha-state-icon>
`
: html`
-
`;
},
},
}
);
protected render(): TemplateResult {
return html`
<hass-subpage
header=${this.hass.localize("ui.panel.config.devices.caption")}
>
<ha-data-table
.columns=${this._columns(this.narrow)}
.data=${this._devices(
this.devices,
this.entries,
this.entities,
this.areas,
this.domain,
this.hass.localize
).map((device: DeviceRowData) => {
// We don't need a lot of this data for mobile view, but kept it for filtering...
const data: DataTabelRowData = {
device_name: device.name,
id: device.id,
manufacturer: device.manufacturer,
model: device.model,
area: device.area,
integration: device.integration,
};
if (this.narrow) {
data.device = device;
return data;
}
data.battery = device.battery_entity;
return data;
})}
@row-click=${this._handleRowClicked}
></ha-data-table>
<div class="content">
<ha-devices-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.devices=${this.devices}
.entries=${this.entries}
.entities=${this.entities}
.areas=${this.areas}
.domain=${this.domain}
></ha-devices-data-table>
</div>
</hass-subpage>
`;
}
private _batteryEntity(
deviceId: string,
deviceEntityLookup: DeviceEntityLookup
): string | undefined {
const batteryEntity = (deviceEntityLookup[deviceId] || []).find(
(entity) =>
this.hass.states[entity.entity_id] &&
this.hass.states[entity.entity_id].attributes.device_class === "battery"
);
return batteryEntity ? batteryEntity.entity_id : undefined;
}
private _fallbackDeviceName(
deviceId: string,
deviceEntityLookup: DeviceEntityLookup
): string | undefined {
for (const entity of deviceEntityLookup[deviceId] || []) {
const stateObj = this.hass.states[entity.entity_id];
if (stateObj) {
return computeStateName(stateObj);
static get styles(): CSSResult {
return css`
.content {
padding: 4px;
}
}
return undefined;
}
private _handleRowClicked(ev: CustomEvent) {
const deviceId = (ev.detail as RowClickedEvent).id;
navigate(this, `/config/devices/device/${deviceId}`);
ha-devices-data-table {
width: 100%;
}
`;
}
}

View File

@ -0,0 +1,265 @@
import "../../../components/data-table/ha-data-table";
import "../../../components/entity/ha-state-icon";
import memoizeOne from "memoize-one";
import {
LitElement,
html,
TemplateResult,
property,
customElement,
} from "lit-element";
import { HomeAssistant } from "../../../types";
// tslint:disable-next-line
import {
DataTabelColumnContainer,
RowClickedEvent,
DataTabelRowData,
} from "../../../components/data-table/ha-data-table";
// tslint:disable-next-line
import { DeviceRegistryEntry } from "../../../data/device_registry";
import { EntityRegistryEntry } from "../../../data/entity_registry";
import { ConfigEntry } from "../../../data/config_entries";
import { AreaRegistryEntry } from "../../../data/area_registry";
import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize";
import { computeStateName } from "../../../common/entity/compute_state_name";
export interface DeviceRowData extends DeviceRegistryEntry {
device?: DeviceRowData;
area?: string;
integration?: string;
battery_entity?: string;
}
export interface DeviceEntityLookup {
[deviceId: string]: EntityRegistryEntry[];
}
@customElement("ha-devices-data-table")
export class HaDevicesDataTable extends LitElement {
@property() public hass!: HomeAssistant;
@property() public narrow = false;
@property() public devices!: DeviceRegistryEntry[];
@property() public entries!: ConfigEntry[];
@property() public entities!: EntityRegistryEntry[];
@property() public areas!: AreaRegistryEntry[];
@property() public domain!: string;
private _devices = memoizeOne(
(
devices: DeviceRegistryEntry[],
entries: ConfigEntry[],
entities: EntityRegistryEntry[],
areas: AreaRegistryEntry[],
domain: string,
localize: LocalizeFunc
) => {
// Some older installations might have devices pointing at invalid entryIDs
// So we guard for that.
let outputDevices: DeviceRowData[] = devices;
const deviceLookup: { [deviceId: string]: DeviceRegistryEntry } = {};
for (const device of devices) {
deviceLookup[device.id] = device;
}
const deviceEntityLookup: DeviceEntityLookup = {};
for (const entity of entities) {
if (!entity.device_id) {
continue;
}
if (!(entity.device_id in deviceEntityLookup)) {
deviceEntityLookup[entity.device_id] = [];
}
deviceEntityLookup[entity.device_id].push(entity);
}
const entryLookup: { [entryId: string]: ConfigEntry } = {};
for (const entry of entries) {
entryLookup[entry.entry_id] = entry;
}
const areaLookup: { [areaId: string]: AreaRegistryEntry } = {};
for (const area of areas) {
areaLookup[area.area_id] = area;
}
if (domain) {
outputDevices = outputDevices.filter((device) =>
device.config_entries.find(
(entryId) =>
entryId in entryLookup && entryLookup[entryId].domain === domain
)
);
}
outputDevices = outputDevices.map((device) => {
return {
...device,
name:
device.name_by_user ||
device.name ||
this._fallbackDeviceName(device.id, deviceEntityLookup) ||
"No name",
model: device.model || "<unknown>",
manufacturer: device.manufacturer || "<unknown>",
area: device.area_id ? areaLookup[device.area_id].name : "No area",
integration: device.config_entries.length
? device.config_entries
.filter((entId) => entId in entryLookup)
.map(
(entId) =>
localize(
`component.${entryLookup[entId].domain}.config.title`
) || entryLookup[entId].domain
)
.join(", ")
: "No integration",
battery_entity: this._batteryEntity(device.id, deviceEntityLookup),
};
});
return outputDevices;
}
);
private _columns = memoizeOne(
(narrow: boolean): DataTabelColumnContainer =>
narrow
? {
name: {
title: "Device",
sortable: true,
filterKey: "name",
filterable: true,
direction: "asc",
template: (name, device: DataTabelRowData) => {
const battery = device.battery_entity
? this.hass.states[device.battery_entity]
: undefined;
// Have to work on a nice layout for mobile
return html`
${name}<br />
${device.area} | ${device.integration}<br />
${battery
? html`
${battery.state}%
<ha-state-icon
.hass=${this.hass!}
.stateObj=${battery}
></ha-state-icon>
`
: ""}
`;
},
},
}
: {
name: {
title: "Device",
sortable: true,
filterable: true,
direction: "asc",
},
manufacturer: {
title: "Manufacturer",
sortable: true,
filterable: true,
},
model: {
title: "Model",
sortable: true,
filterable: true,
},
area: {
title: "Area",
sortable: true,
filterable: true,
},
integration: {
title: "Integration",
sortable: true,
filterable: true,
},
battery_entity: {
title: "Battery",
sortable: true,
type: "numeric",
template: (batteryEntity: string) => {
const battery = batteryEntity
? this.hass.states[batteryEntity]
: undefined;
return battery
? html`
${battery.state}%
<ha-state-icon
.hass=${this.hass!}
.stateObj=${battery}
></ha-state-icon>
`
: html`
-
`;
},
},
}
);
protected render(): TemplateResult {
return html`
<ha-data-table
.columns=${this._columns(this.narrow)}
.data=${this._devices(
this.devices,
this.entries,
this.entities,
this.areas,
this.domain,
this.hass.localize
)}
@row-click=${this._handleRowClicked}
></ha-data-table>
`;
}
private _batteryEntity(
deviceId: string,
deviceEntityLookup: DeviceEntityLookup
): string | undefined {
const batteryEntity = (deviceEntityLookup[deviceId] || []).find(
(entity) =>
this.hass.states[entity.entity_id] &&
this.hass.states[entity.entity_id].attributes.device_class === "battery"
);
return batteryEntity ? batteryEntity.entity_id : undefined;
}
private _fallbackDeviceName(
deviceId: string,
deviceEntityLookup: DeviceEntityLookup
): string | undefined {
for (const entity of deviceEntityLookup[deviceId] || []) {
const stateObj = this.hass.states[entity.entity_id];
if (stateObj) {
return computeStateName(stateObj);
}
}
return undefined;
}
private _handleRowClicked(ev: CustomEvent) {
const deviceId = (ev.detail as RowClickedEvent).id;
navigate(this, `/config/devices/device/${deviceId}`);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-devices-data-table": HaDevicesDataTable;
}
}

View File

@ -20,7 +20,7 @@ class HaCeEntitiesCard extends LocalizeMixIn(EventsMixin(PolymerElement)) {
return html`
<style>
ha-card {
flex: 1 0 100%;
margin-top: 8px;
padding-bottom: 8px;
}
paper-icon-item {

View File

@ -2,10 +2,7 @@ import memoizeOne from "memoize-one";
import "../../../../layouts/hass-subpage";
import "../../../../layouts/hass-error-screen";
import "../../../../components/entity/state-badge";
import { compare } from "../../../../common/string/compare";
import "../../devices/device-detail/ha-device-card";
import "../../devices/ha-devices-data-table";
import "./ha-ce-entities-card";
import { showOptionsFlowDialog } from "../../../../dialogs/config-flow/show-dialog-options-flow";
import { property, LitElement, CSSResult, css, html } from "lit-element";
@ -43,15 +40,9 @@ class HaConfigEntryPage extends LitElement {
if (!devices) {
return [];
}
return devices
.filter((device) =>
device.config_entries.includes(configEntry.entry_id)
)
.sort(
(dev1, dev2) =>
Number(!!dev1.via_device_id) - Number(!!dev2.via_device_id) ||
compare(dev1.name || "", dev2.name || "")
);
return devices.filter((device) =>
device.config_entries.includes(configEntry.entry_id)
);
}
);
@ -116,24 +107,19 @@ class HaConfigEntryPage extends LitElement {
)}
</p>
`
: ""}
${configEntryDevices.map(
(device) => html`
<ha-device-card
class="card"
.hass=${this.hass}
.areas=${this.areas}
.devices=${this.deviceRegistryEntries}
.device=${device}
.entities=${this.entityRegistryEntries}
.narrow=${this.narrow}
></ha-device-card>
`
)}
: html`
<ha-devices-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.devices=${configEntryDevices}
.entries=${this.configEntries}
.entities=${this.entityRegistryEntries}
.areas=${this.areas}
></ha-devices-data-table>
`}
${noDeviceEntities.length > 0
? html`
<ha-ce-entities-card
class="card"
.heading=${this.hass.localize(
"ui.panel.config.integrations.config_entry.no_device"
)}
@ -185,18 +171,13 @@ class HaConfigEntryPage extends LitElement {
static get styles(): CSSResult {
return css`
.content {
display: flex;
flex-wrap: wrap;
padding: 4px;
justify-content: center;
}
.card {
box-sizing: border-box;
display: flex;
flex: 1 0 300px;
min-width: 0;
max-width: 500px;
padding: 8px;
p {
text-align: center;
}
ha-devices-data-table {
width: 100%;
}
`;
}