Add toolbars and mobile headers + layout tweaks (#4803)

* Add toolbars and mobile headers + layout tweaks

* Comments
This commit is contained in:
Bram Kragten 2020-02-13 19:53:48 +01:00 committed by GitHub
parent c93e1b0123
commit 7903541689
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 855 additions and 430 deletions

View File

@ -7,14 +7,18 @@ import {
property, property,
} from "lit-element"; } from "lit-element";
import { fireEvent } from "../dom/fire_event"; import { fireEvent } from "../dom/fire_event";
import "@polymer/iron-icon/iron-icon";
import "@polymer/paper-input/paper-input"; import "@polymer/paper-input/paper-input";
import "@polymer/paper-icon-button/paper-icon-button"; import "@polymer/paper-icon-button/paper-icon-button";
import "@material/mwc-button"; import "../../components/ha-icon";
import { classMap } from "lit-html/directives/class-map";
@customElement("search-input") @customElement("search-input")
class SearchInput extends LitElement { class SearchInput extends LitElement {
@property() public filter?: string; @property() public filter?: string;
@property({ type: Boolean, attribute: "no-label-float" })
public noLabelFloat? = false;
@property({ type: Boolean, attribute: "no-underline" })
public noUnderline = false;
public focus() { public focus() {
this.shadowRoot!.querySelector("paper-input")!.focus(); this.shadowRoot!.querySelector("paper-input")!.focus();
@ -22,18 +26,24 @@ class SearchInput extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<style>
.no-underline {
--paper-input-container-underline: {
display: none;
height: 0;
}
}
</style>
<div class="search-container"> <div class="search-container">
<paper-input <paper-input
class=${classMap({ "no-underline": this.noUnderline })}
autofocus autofocus
label="Search" label="Search"
.value=${this.filter} .value=${this.filter}
@value-changed=${this._filterInputChanged} @value-changed=${this._filterInputChanged}
.noLabelFloat=${this.noLabelFloat}
> >
<iron-icon <ha-icon icon="hass:magnify" slot="prefix" class="prefix"></ha-icon>
icon="hass:magnify"
slot="prefix"
class="prefix"
></iron-icon>
${this.filter && ${this.filter &&
html` html`
<paper-icon-button <paper-icon-button

View File

@ -101,6 +101,8 @@ export class HaDataTable extends BaseElement {
@property({ type: String }) private _sortColumn?: string; @property({ type: String }) private _sortColumn?: string;
@property({ type: String }) private _sortDirection: SortingDirection = null; @property({ type: String }) private _sortDirection: SortingDirection = null;
@property({ type: Array }) private _filteredData: DataTableRowData[] = []; @property({ type: Array }) private _filteredData: DataTableRowData[] = [];
@query("slot[name='header']") private _header!: HTMLSlotElement;
@query(".scroller") private _scroller!: HTMLDivElement;
private _sortColumns: { private _sortColumns: {
[key: string]: DataTableSortColumnData; [key: string]: DataTableSortColumnData;
} = {}; } = {};
@ -170,7 +172,7 @@ export class HaDataTable extends BaseElement {
protected render() { protected render() {
return html` return html`
<div class="mdc-data-table"> <div class="mdc-data-table">
<slot name="header"> <slot name="header" @slotchange=${this._calcScrollHeight}>
${this._filterable ${this._filterable
? html` ? html`
<div class="table-header"> <div class="table-header">
@ -181,112 +183,114 @@ export class HaDataTable extends BaseElement {
` `
: ""} : ""}
</slot> </slot>
<table class="mdc-data-table__table"> <div class="scroller">
<thead> <table class="mdc-data-table__table">
<tr class="mdc-data-table__header-row"> <thead>
${this.selectable <tr class="mdc-data-table__header-row">
? html` ${this.selectable
? html`
<th
class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox"
role="columnheader"
scope="col"
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxChange}
.indeterminate=${this._headerIndeterminate}
.checked=${this._headerChecked}
>
</ha-checkbox>
</th>
`
: ""}
${Object.entries(this.columns).map((columnEntry) => {
const [key, column] = columnEntry;
const sorted = key === this._sortColumn;
const classes = {
"mdc-data-table__header-cell--numeric": Boolean(
column.type && column.type === "numeric"
),
"mdc-data-table__header-cell--icon": Boolean(
column.type && column.type === "icon"
),
sortable: Boolean(column.sortable),
"not-sorted": Boolean(column.sortable && !sorted),
};
return html`
<th <th
class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox" class="mdc-data-table__header-cell ${classMap(classes)}"
role="columnheader" role="columnheader"
scope="col" scope="col"
@click=${this._handleHeaderClick}
data-column-id="${key}"
> >
<ha-checkbox ${column.sortable
class="mdc-data-table__row-checkbox" ? html`
@change=${this._handleHeaderRowCheckboxChange} <ha-icon
.indeterminate=${this._headerIndeterminate} .icon=${sorted && this._sortDirection === "desc"
.checked=${this._headerChecked} ? "hass:arrow-down"
> : "hass:arrow-up"}
</ha-checkbox> ></ha-icon>
`
: ""}
<span>${column.title}</span>
</th> </th>
` `;
: ""} })}
${Object.entries(this.columns).map((columnEntry) => { </tr>
const [key, column] = columnEntry; </thead>
const sorted = key === this._sortColumn; <tbody class="mdc-data-table__content">
const classes = { ${repeat(
"mdc-data-table__header-cell--numeric": Boolean( this._filteredData!,
column.type && column.type === "numeric" (row: DataTableRowData) => row[this.id],
), (row: DataTableRowData) => html`
"mdc-data-table__header-cell--icon": Boolean( <tr
column.type && column.type === "icon" data-row-id="${row[this.id]}"
), @click=${this._handleRowClick}
sortable: Boolean(column.sortable), class="mdc-data-table__row"
"not-sorted": Boolean(column.sortable && !sorted),
};
return html`
<th
class="mdc-data-table__header-cell ${classMap(classes)}"
role="columnheader"
scope="col"
@click=${this._handleHeaderClick}
data-column-id="${key}"
> >
${column.sortable ${this.selectable
? html` ? html`
<ha-icon <td
.icon=${sorted && this._sortDirection === "desc" class="mdc-data-table__cell mdc-data-table__cell--checkbox"
? "hass:arrow-down" >
: "hass:arrow-up"} <ha-checkbox
></ha-icon> class="mdc-data-table__row-checkbox"
@change=${this._handleRowCheckboxChange}
.checked=${this._checkedRows.includes(
String(row[this.id])
)}
>
</ha-checkbox>
</td>
` `
: ""} : ""}
<span>${column.title}</span> ${Object.entries(this.columns).map((columnEntry) => {
</th> const [key, column] = columnEntry;
`; return html`
})}
</tr>
</thead>
<tbody class="mdc-data-table__content">
${repeat(
this._filteredData!,
(row: DataTableRowData) => row[this.id],
(row: DataTableRowData) => html`
<tr
data-row-id="${row[this.id]}"
@click=${this._handleRowClick}
class="mdc-data-table__row"
>
${this.selectable
? html`
<td <td
class="mdc-data-table__cell mdc-data-table__cell--checkbox" class="mdc-data-table__cell ${classMap({
"mdc-data-table__cell--numeric": Boolean(
column.type && column.type === "numeric"
),
"mdc-data-table__cell--icon": Boolean(
column.type && column.type === "icon"
),
})}"
> >
<ha-checkbox ${column.template
class="mdc-data-table__row-checkbox" ? column.template(row[key], row)
@change=${this._handleRowCheckboxChange} : row[key]}
.checked=${this._checkedRows.includes(
String(row[this.id])
)}
>
</ha-checkbox>
</td> </td>
` `;
: ""} })}
${Object.entries(this.columns).map((columnEntry) => { </tr>
const [key, column] = columnEntry; `
return html` )}
<td </tbody>
class="mdc-data-table__cell ${classMap({ </table>
"mdc-data-table__cell--numeric": Boolean( </div>
column.type && column.type === "numeric"
),
"mdc-data-table__cell--icon": Boolean(
column.type && column.type === "icon"
),
})}"
>
${column.template
? column.template(row[key], row)
: row[key]}
</td>
`;
})}
</tr>
`
)}
</tbody>
</table>
</div> </div>
`; `;
} }
@ -434,6 +438,11 @@ export class HaDataTable extends BaseElement {
this._debounceSearch(ev.detail.value); this._debounceSearch(ev.detail.value);
} }
private async _calcScrollHeight() {
await this.updateComplete;
this._scroller.style.maxHeight = `calc(100% - ${this._header.clientHeight}px)`;
}
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
/* default mdc styles, colors changed, without checkbox styles */ /* default mdc styles, colors changed, without checkbox styles */
@ -584,8 +593,14 @@ export class HaDataTable extends BaseElement {
/* custom from here */ /* custom from here */
:host {
display: block;
}
.mdc-data-table { .mdc-data-table {
display: block; display: block;
border-width: var(--data-table-border-width, 1px);
height: 100%;
} }
.mdc-data-table__header-cell { .mdc-data-table__header-cell {
overflow: hidden; overflow: hidden;
@ -614,6 +629,16 @@ export class HaDataTable extends BaseElement {
.table-header { .table-header {
border-bottom: 1px solid rgba(var(--rgb-primary-text-color), 0.12); border-bottom: 1px solid rgba(var(--rgb-primary-text-color), 0.12);
} }
search-input {
position: relative;
top: 2px;
}
.scroller {
overflow: auto;
}
slot[name="header"] {
display: block;
}
`; `;
} }
} }

View File

@ -23,6 +23,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import { import {
DeviceRegistryEntry, DeviceRegistryEntry,
subscribeDeviceRegistry, subscribeDeviceRegistry,
DeviceEntityLookup,
} from "../../data/device_registry"; } from "../../data/device_registry";
import { compare } from "../../common/string/compare"; import { compare } from "../../common/string/compare";
import { PolymerChangedEvent } from "../../polymer-types"; import { PolymerChangedEvent } from "../../polymer-types";
@ -30,7 +31,6 @@ import {
AreaRegistryEntry, AreaRegistryEntry,
subscribeAreaRegistry, subscribeAreaRegistry,
} from "../../data/area_registry"; } from "../../data/area_registry";
import { DeviceEntityLookup } from "../../panels/config/devices/ha-devices-data-table";
import { import {
EntityRegistryEntry, EntityRegistryEntry,
subscribeEntityRegistry, subscribeEntityRegistry,

View File

@ -22,6 +22,7 @@ import {
DeviceRegistryEntry, DeviceRegistryEntry,
subscribeDeviceRegistry, subscribeDeviceRegistry,
computeDeviceName, computeDeviceName,
DeviceEntityLookup,
} from "../../data/device_registry"; } from "../../data/device_registry";
import { compare } from "../../common/string/compare"; import { compare } from "../../common/string/compare";
import { PolymerChangedEvent } from "../../polymer-types"; import { PolymerChangedEvent } from "../../polymer-types";
@ -29,7 +30,6 @@ import {
AreaRegistryEntry, AreaRegistryEntry,
subscribeAreaRegistry, subscribeAreaRegistry,
} from "../../data/area_registry"; } from "../../data/area_registry";
import { DeviceEntityLookup } from "../../panels/config/devices/ha-devices-data-table";
import { import {
EntityRegistryEntry, EntityRegistryEntry,
subscribeEntityRegistry, subscribeEntityRegistry,

View File

@ -17,6 +17,10 @@ export interface DeviceRegistryEntry {
name_by_user?: string; name_by_user?: string;
} }
export interface DeviceEntityLookup {
[deviceId: string]: EntityRegistryEntry[];
}
export interface DeviceRegistryEntryMutableParams { export interface DeviceRegistryEntryMutableParams {
area_id?: string | null; area_id?: string | null;
name_by_user?: string | null; name_by_user?: string | null;

View File

@ -0,0 +1,157 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
query,
TemplateResult,
} from "lit-element";
import "../components/data-table/ha-data-table";
// tslint:disable-next-line
import {
HaDataTable,
DataTableColumnContainer,
DataTableRowData,
} from "../components/data-table/ha-data-table";
import "./hass-tabs-subpage";
import { HomeAssistant, Route } from "../types";
// tslint:disable-next-line
import { PageNavigation } from "./hass-tabs-subpage";
@customElement("hass-tabs-subpage-data-table")
export class HaTabsSubpageDataTable extends LitElement {
@property() public hass!: HomeAssistant;
@property() public isWide!: boolean;
@property({ type: Boolean, reflect: true }) public narrow!: boolean;
/**
* Object with the columns.
* @type {Object}
*/
@property({ type: Object }) public columns: DataTableColumnContainer = {};
/**
* Data to show in the table.
* @type {Array}
*/
@property({ type: Array }) public data: DataTableRowData[] = [];
/**
* Should rows be selectable.
* @type {Boolean}
*/
@property({ type: Boolean }) public selectable = false;
/**
* Field with a unique id per entry in data.
* @type {String}
*/
@property({ type: String }) public id = "id";
/**
* String to filter the data in the data table on.
* @type {String}
*/
@property({ type: String }) public filter = "";
/**
* What path to use when the back button is pressed.
* @type {String}
* @attr back-path
*/
@property({ type: String, attribute: "back-path" }) public backPath?: string;
/**
* Function to call when the back button is pressed.
* @type {() => void}
*/
@property() public backCallback?: () => void;
@property() public route!: Route;
/**
* Array of tabs to show on the page.
* @type {Array}
*/
@property() public tabs!: PageNavigation[];
@query("ha-data-table") private _dataTable!: HaDataTable;
public clearSelection() {
this._dataTable.clearSelection();
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${this.backPath}
.backCallback=${this.backCallback}
.route=${this.route}
.tabs=${this.tabs}
>
${this.narrow
? html`
<div slot="header">
<slot name="header">
<div class="search-toolbar">
<search-input
no-label-float
no-underline
@value-changed=${this._handleSearchChange}
></search-input>
</div>
</slot>
</div>
`
: ""}
<ha-data-table
.columns=${this.columns}
.data=${this.data}
.filter=${this.filter}
.selectable=${this.selectable}
.id=${this.id}
>
${!this.narrow
? html`
<div slot="header">
<slot name="header">
<slot name="header">
<div class="table-header">
<search-input
no-label-float
no-underline
@value-changed=${this._handleSearchChange}
></search-input></div></slot
></slot>
</div>
`
: html`
<div slot="header"></div>
`}
</ha-data-table>
</hass-tabs-subpage>
`;
}
private _handleSearchChange(ev: CustomEvent) {
this.filter = ev.detail.value;
}
static get styles(): CSSResult {
return css`
ha-data-table {
width: 100%;
--data-table-border-width: 0;
}
:host(:not([narrow])) ha-data-table {
height: calc(100vh - 65px);
display: block;
}
.table-header {
border-bottom: 1px solid rgba(var(--rgb-primary-text-color), 0.12);
}
.search-toolbar {
margin-left: -24px;
color: var(--secondary-text-color);
}
search-input {
position: relative;
top: 2px;
}
`;
}
}

View File

@ -56,6 +56,11 @@ class HassTabsSubpage extends LitElement {
.hassio=${this.hassio} .hassio=${this.hassio}
@click=${this._backTapped} @click=${this._backTapped}
></ha-paper-icon-button-arrow-prev> ></ha-paper-icon-button-arrow-prev>
${this.narrow
? html`
<div main-title><slot name="header"></slot></div>
`
: ""}
<div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}> <div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}>
${this.tabs.map((page, index) => ${this.tabs.map((page, index) =>
(!page.component || (!page.component ||
@ -138,11 +143,6 @@ class HassTabsSubpage extends LitElement {
box-sizing: border-box; box-sizing: border-box;
} }
:host([narrow]) .toolbar {
background-color: var(--primary-background-color);
border-bottom: none;
}
#tabbar { #tabbar {
display: flex; display: flex;
font-size: 14px; font-size: 14px;

View File

@ -77,154 +77,155 @@ export class HaAutomationEditor extends LitElement {
<div class="errors">${this._errors}</div> <div class="errors">${this._errors}</div>
` `
: ""} : ""}
<div ${this._config
class="content ${classMap({ ? html`
rtl: computeRTL(this.hass), ${this.narrow
})}" ? html`
> <span slot="header">${this._config?.alias}</span>
${this._config `
? html` : ""}
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
<span slot="header">${this._config.alias}</span> ${!this.narrow
<span slot="introduction"> ? html`
${this.hass.localize( <span slot="header">${this._config.alias}</span>
"ui.panel.config.automation.editor.introduction" `
)} : ""}
</span> <span slot="introduction">
<ha-card> ${this.hass.localize(
<div class="card-content"> "ui.panel.config.automation.editor.introduction"
<paper-input )}
.label=${this.hass.localize( </span>
"ui.panel.config.automation.editor.alias" <ha-card>
)} <div class="card-content">
name="alias" <paper-input
.value=${this._config.alias} .label=${this.hass.localize(
@value-changed=${this._valueChanged} "ui.panel.config.automation.editor.alias"
> )}
</paper-input> name="alias"
<ha-textarea .value=${this._config.alias}
.label=${this.hass.localize( @value-changed=${this._valueChanged}
"ui.panel.config.automation.editor.description.label" >
)} </paper-input>
.placeholder=${this.hass.localize( <ha-textarea
"ui.panel.config.automation.editor.description.placeholder" .label=${this.hass.localize(
)} "ui.panel.config.automation.editor.description.label"
name="description" )}
.value=${this._config.description} .placeholder=${this.hass.localize(
@value-changed=${this._valueChanged} "ui.panel.config.automation.editor.description.placeholder"
></ha-textarea> )}
</div> name="description"
${this.creatingNew .value=${this._config.description}
? "" @value-changed=${this._valueChanged}
: html` ></ha-textarea>
<div </div>
class="card-actions layout horizontal justified center" ${this.creatingNew
> ? ""
<div class="layout horizontal center"> : html`
<ha-entity-toggle <div
.hass=${this.hass} class="card-actions layout horizontal justified center"
.stateObj=${this.automation} >
></ha-entity-toggle> <div class="layout horizontal center">
${this.hass.localize( <ha-entity-toggle
"ui.panel.config.automation.editor.enable_disable" .hass=${this.hass}
)} .stateObj=${this.automation}
</div> ></ha-entity-toggle>
<mwc-button @click=${this._excuteAutomation}> ${this.hass.localize(
${this.hass.localize( "ui.panel.config.automation.editor.enable_disable"
"ui.card.automation.trigger" )}
)}
</mwc-button>
</div> </div>
`} <mwc-button @click=${this._excuteAutomation}>
</ha-card> ${this.hass.localize("ui.card.automation.trigger")}
</ha-config-section> </mwc-button>
</div>
`}
</ha-card>
</ha-config-section>
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
<span slot="header"> <span slot="header">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.header"
)}
</span>
<span slot="introduction">
<p>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.triggers.header" "ui.panel.config.automation.editor.triggers.introduction"
)} )}
</span> </p>
<span slot="introduction"> <a
<p> href="https://home-assistant.io/docs/automation/trigger/"
${this.hass.localize( target="_blank"
"ui.panel.config.automation.editor.triggers.introduction" >
)} ${this.hass.localize(
</p> "ui.panel.config.automation.editor.triggers.learn_more"
<a )}
href="https://home-assistant.io/docs/automation/trigger/" </a>
target="_blank" </span>
> <ha-automation-trigger
${this.hass.localize( .triggers=${this._config.trigger}
"ui.panel.config.automation.editor.triggers.learn_more" @value-changed=${this._triggerChanged}
)} .hass=${this.hass}
</a> ></ha-automation-trigger>
</span> </ha-config-section>
<ha-automation-trigger
.triggers=${this._config.trigger}
@value-changed=${this._triggerChanged}
.hass=${this.hass}
></ha-automation-trigger>
</ha-config-section>
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
<span slot="header"> <span slot="header">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.header"
)}
</span>
<span slot="introduction">
<p>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.conditions.header" "ui.panel.config.automation.editor.conditions.introduction"
)} )}
</span> </p>
<span slot="introduction"> <a
<p> href="https://home-assistant.io/docs/scripts/conditions/"
${this.hass.localize( target="_blank"
"ui.panel.config.automation.editor.conditions.introduction" >
)} ${this.hass.localize(
</p> "ui.panel.config.automation.editor.conditions.learn_more"
<a )}
href="https://home-assistant.io/docs/scripts/conditions/" </a>
target="_blank" </span>
> <ha-automation-condition
${this.hass.localize( .conditions=${this._config.condition || []}
"ui.panel.config.automation.editor.conditions.learn_more" @value-changed=${this._conditionChanged}
)} .hass=${this.hass}
</a> ></ha-automation-condition>
</span> </ha-config-section>
<ha-automation-condition
.conditions=${this._config.condition || []}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
></ha-automation-condition>
</ha-config-section>
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
<span slot="header"> <span slot="header">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.header"
)}
</span>
<span slot="introduction">
<p>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.actions.header" "ui.panel.config.automation.editor.actions.introduction"
)} )}
</span> </p>
<span slot="introduction"> <a
<p> href="https://home-assistant.io/docs/automation/action/"
${this.hass.localize( target="_blank"
"ui.panel.config.automation.editor.actions.introduction" >
)} ${this.hass.localize(
</p> "ui.panel.config.automation.editor.actions.learn_more"
<a )}
href="https://home-assistant.io/docs/automation/action/" </a>
target="_blank" </span>
> <ha-automation-action
${this.hass.localize( .actions=${this._config.action}
"ui.panel.config.automation.editor.actions.learn_more" @value-changed=${this._actionChanged}
)} .hass=${this.hass}
</a> ></ha-automation-action>
</span> </ha-config-section>
<ha-automation-action `
.actions=${this._config.action} : ""}
@value-changed=${this._actionChanged}
.hass=${this.hass}
></ha-automation-action>
</ha-config-section>
`
: ""}
</div>
<ha-fab <ha-fab
?is-wide="${this.isWide}" ?is-wide="${this.isWide}"
?narrow="${this.narrow}" ?narrow="${this.narrow}"

View File

@ -121,6 +121,14 @@ export class HaConfigDevicePage extends LitElement {
.tabs=${configSections.integrations} .tabs=${configSections.integrations}
.route=${this.route} .route=${this.route}
> >
${
this.narrow
? html`
<span slot="header">${device.name_by_user || device.name}</span>
`
: ""
}
<paper-icon-button <paper-icon-button
slot="toolbar-icon" slot="toolbar-icon"
icon="hass:settings" icon="hass:settings"
@ -130,7 +138,13 @@ export class HaConfigDevicePage extends LitElement {
<div class="container"> <div class="container">
<div class="left"> <div class="left">
<div class="device-info"> <div class="device-info">
<h1>${device.name_by_user || device.name}</h1> ${
this.narrow
? ""
: html`
<h1>${device.name_by_user || device.name}</h1>
`
}
<ha-device-card <ha-device-card
.hass=${this.hass} .hass=${this.hass}
.areas=${this.areas} .areas=${this.areas}
@ -498,6 +512,10 @@ export class HaConfigDevicePage extends LitElement {
width: 100%; width: 100%;
} }
:host([narrow]) .container > *:first-child {
padding-top: 0;
}
:host([narrow]) .container { :host([narrow]) .container {
margin-top: 0; margin-top: 0;
} }

View File

@ -1,5 +1,4 @@
import "../../../layouts/hass-tabs-subpage"; import "../../../layouts/hass-tabs-subpage-data-table";
import "./ha-devices-data-table";
import { import {
LitElement, LitElement,
@ -11,11 +10,24 @@ import {
css, css,
} from "lit-element"; } from "lit-element";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { DeviceRegistryEntry } from "../../../data/device_registry"; import {
DeviceRegistryEntry,
computeDeviceName,
DeviceEntityLookup,
} from "../../../data/device_registry";
import { EntityRegistryEntry } from "../../../data/entity_registry"; import { EntityRegistryEntry } from "../../../data/entity_registry";
import { ConfigEntry } from "../../../data/config_entries"; import { ConfigEntry } from "../../../data/config_entries";
import { AreaRegistryEntry } from "../../../data/area_registry"; import { AreaRegistryEntry } from "../../../data/area_registry";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import memoizeOne from "memoize-one";
import { LocalizeFunc } from "../../../common/translations/localize";
import { DeviceRowData } from "./ha-devices-data-table";
import {
DataTableColumnContainer,
DataTableRowData,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import { navigate } from "../../../common/navigate";
@customElement("ha-config-devices-dashboard") @customElement("ha-config-devices-dashboard")
export class HaConfigDeviceDashboard extends LitElement { export class HaConfigDeviceDashboard extends LitElement {
@ -29,30 +41,219 @@ export class HaConfigDeviceDashboard extends LitElement {
@property() public domain!: string; @property() public domain!: string;
@property() public route!: Route; @property() public route!: Route;
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: computeDeviceName(
device,
this.hass,
deviceEntityLookup[device.id]
),
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): DataTableColumnContainer =>
narrow
? {
name: {
title: "Device",
sortable: true,
filterable: true,
direction: "asc",
template: (name, device: DataTableRowData) => {
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 && !isNaN(battery.state as any)
? html`
${battery.state}%
<ha-state-icon
.hass=${this.hass!}
.stateObj=${battery}
></ha-state-icon>
`
: ""}
`;
},
},
}
: {
name: {
title: this.hass.localize(
"ui.panel.config.devices.data_table.device"
),
sortable: true,
filterable: true,
direction: "asc",
},
manufacturer: {
title: this.hass.localize(
"ui.panel.config.devices.data_table.manufacturer"
),
sortable: true,
filterable: true,
},
model: {
title: this.hass.localize(
"ui.panel.config.devices.data_table.model"
),
sortable: true,
filterable: true,
},
area: {
title: this.hass.localize(
"ui.panel.config.devices.data_table.area"
),
sortable: true,
filterable: true,
},
integration: {
title: this.hass.localize(
"ui.panel.config.devices.data_table.integration"
),
sortable: true,
filterable: true,
},
battery_entity: {
title: this.hass.localize(
"ui.panel.config.devices.data_table.battery"
),
sortable: true,
type: "numeric",
template: (batteryEntity: string) => {
const battery = batteryEntity
? this.hass.states[batteryEntity]
: undefined;
return battery && !isNaN(battery.state as any)
? html`
${battery.state}%
<ha-state-icon
.hass=${this.hass!}
.stateObj=${battery}
></ha-state-icon>
`
: html`
-
`;
},
},
}
);
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage-data-table
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
back-path="/config" back-path="/config"
.tabs=${configSections.integrations} .tabs=${configSections.integrations}
.route=${this.route} .route=${this.route}
.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}
> >
<div class="content"> </hass-tabs-subpage-data-table>
<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-tabs-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 _handleRowClicked(ev: CustomEvent) {
const deviceId = (ev.detail as RowClickedEvent).id;
navigate(this, `/config/devices/device/${deviceId}`);
}
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
.content { .content {

View File

@ -21,6 +21,7 @@ import {
import { import {
DeviceRegistryEntry, DeviceRegistryEntry,
computeDeviceName, computeDeviceName,
DeviceEntityLookup,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import { EntityRegistryEntry } from "../../../data/entity_registry"; import { EntityRegistryEntry } from "../../../data/entity_registry";
import { ConfigEntry } from "../../../data/config_entries"; import { ConfigEntry } from "../../../data/config_entries";
@ -35,10 +36,6 @@ export interface DeviceRowData extends DeviceRegistryEntry {
battery_entity?: string; battery_entity?: string;
} }
export interface DeviceEntityLookup {
[deviceId: string]: EntityRegistryEntry[];
}
@customElement("ha-devices-data-table") @customElement("ha-devices-data-table")
export class HaDevicesDataTable extends LitElement { export class HaDevicesDataTable extends LitElement {
@property() public hass!: HomeAssistant; @property() public hass!: HomeAssistant;

View File

@ -19,8 +19,6 @@ import memoize from "memoize-one";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { domainIcon } from "../../../common/entity/domain_icon"; import { domainIcon } from "../../../common/entity/domain_icon";
import { stateIcon } from "../../../common/entity/state_icon"; import { stateIcon } from "../../../common/entity/state_icon";
import "../../../components/data-table/ha-data-table";
// tslint:disable-next-line
import { import {
DataTableColumnContainer, DataTableColumnContainer,
DataTableColumnData, DataTableColumnData,
@ -29,6 +27,7 @@ import {
SelectionChangedEvent, SelectionChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-icon"; import "../../../components/ha-icon";
import "../../../common/search/search-input";
import { import {
computeEntityRegistryName, computeEntityRegistryName,
EntityRegistryEntry, EntityRegistryEntry,
@ -38,7 +37,7 @@ import {
} from "../../../data/entity_registry"; } from "../../../data/entity_registry";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage"; import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { DialogEntityRegistryDetail } from "./dialog-entity-registry-detail"; import { DialogEntityRegistryDetail } from "./dialog-entity-registry-detail";
@ -47,6 +46,7 @@ import {
showEntityRegistryDetailDialog, showEntityRegistryDetailDialog,
} from "./show-dialog-entity-registry-detail"; } from "./show-dialog-entity-registry-detail";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { classMap } from "lit-html/directives/class-map";
@customElement("ha-config-entities") @customElement("ha-config-entities")
export class HaConfigEntities extends SubscribeMixin(LitElement) { export class HaConfigEntities extends SubscribeMixin(LitElement) {
@ -223,154 +223,136 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
<hass-loading-screen></hass-loading-screen> <hass-loading-screen></hass-loading-screen>
`; `;
} }
const headerToolbar = this._selectedEntities.length
? html`
<p class="selected-txt">
${this.hass.localize(
"ui.panel.config.entities.picker.selected",
"number",
this._selectedEntities.length
)}
</p>
<div class="header-btns">
${!this.narrow
? html`
<mwc-button @click=${this._enableSelected}
>${this.hass.localize(
"ui.panel.config.entities.picker.enable_selected.button"
)}</mwc-button
>
<mwc-button @click=${this._disableSelected}
>${this.hass.localize(
"ui.panel.config.entities.picker.disable_selected.button"
)}</mwc-button
>
<mwc-button @click=${this._removeSelected}
>${this.hass.localize(
"ui.panel.config.entities.picker.remove_selected.button"
)}</mwc-button
>
`
: html`
<paper-icon-button
id="enable-btn"
icon="hass:undo"
@click=${this._enableSelected}
></paper-icon-button>
<paper-tooltip for="enable-btn">
${this.hass.localize(
"ui.panel.config.entities.picker.enable_selected.button"
)}
</paper-tooltip>
<paper-icon-button
id="disable-btn"
icon="hass:cancel"
@click=${this._disableSelected}
></paper-icon-button>
<paper-tooltip for="disable-btn">
${this.hass.localize(
"ui.panel.config.entities.picker.disable_selected.button"
)}
</paper-tooltip>
<paper-icon-button
id="remove-btn"
icon="hass:delete"
@click=${this._removeSelected}
></paper-icon-button>
<paper-tooltip for="remove-btn">
${this.hass.localize(
"ui.panel.config.entities.picker.remove_selected.button"
)}
</paper-tooltip>
`}
</div>
`
: html`
<search-input
no-label-float
no-underline
@value-changed=${this._handleSearchChange}
.filter=${this._filter}
></search-input>
<paper-menu-button no-animations horizontal-align="right">
<paper-icon-button
aria-label=${this.hass!.localize(
"ui.panel.config.entities.picker.filter.filter"
)}
title="${this.hass!.localize(
"ui.panel.config.entities.picker.filter.filter"
)}"
icon="hass:filter-variant"
slot="dropdown-trigger"
></paper-icon-button>
<paper-listbox slot="dropdown-content">
<paper-icon-item @tap="${this._showDisabledChanged}">
<paper-checkbox
.checked=${this._showDisabled}
slot="item-icon"
></paper-checkbox>
${this.hass!.localize(
"ui.panel.config.entities.picker.filter.show_disabled"
)}
</paper-icon-item>
<paper-icon-item @tap="${this._showRestoredChanged}">
<paper-checkbox
.checked=${this._showUnavailable}
slot="item-icon"
></paper-checkbox>
${this.hass!.localize(
"ui.panel.config.entities.picker.filter.show_unavailable"
)}
</paper-icon-item>
</paper-listbox>
</paper-menu-button>
`;
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage-data-table
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
back-path="/config" back-path="/config"
.route=${this.route} .route=${this.route}
.tabs=${configSections.integrations} .tabs=${configSections.integrations}
.columns=${this._columns(this.narrow, this.hass.language)}
.data=${this._filteredEntities(
this._entities,
this._showDisabled,
this._showUnavailable
)}
.filter=${this._filter}
selectable
@selection-changed=${this._handleSelectionChanged}
@row-click=${this._openEditEntry}
id="entity_id"
> >
<div class="content"> <div class=${classMap({
<div class="intro"> "search-toolbar": this.narrow,
<h2> "table-header": !this.narrow,
${this.hass.localize("ui.panel.config.entities.picker.header")} })} slot="header">
</h2> ${headerToolbar}
<p> </div>
${this.hass.localize( </ha-data-table>
"ui.panel.config.entities.picker.introduction" </hass-tabs-subpage-data-table>
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.entities.picker.introduction2"
)}
</p>
<a href="/config/integrations">
${this.hass.localize(
"ui.panel.config.entities.picker.integrations_page"
)}
</a>
</div>
<ha-data-table
.columns=${this._columns(this.narrow, this.hass.language)}
.data=${this._filteredEntities(
this._entities,
this._showDisabled,
this._showUnavailable
)}
.filter=${this._filter}
selectable
@selection-changed=${this._handleSelectionChanged}
@row-click=${this._openEditEntry}
id="entity_id"
>
<div class="table-header" slot="header">
${this._selectedEntities.length
? html`
<p class="selected-txt">
${this.hass.localize(
"ui.panel.config.entities.picker.selected",
"number",
this._selectedEntities.length
)}
</p>
<div class="header-btns">
${!this.narrow
? html`
<mwc-button @click=${this._enableSelected}
>${this.hass.localize(
"ui.panel.config.entities.picker.enable_selected.button"
)}</mwc-button
>
<mwc-button @click=${this._disableSelected}
>${this.hass.localize(
"ui.panel.config.entities.picker.disable_selected.button"
)}</mwc-button
>
<mwc-button @click=${this._removeSelected}
>${this.hass.localize(
"ui.panel.config.entities.picker.remove_selected.button"
)}</mwc-button
>
`
: html`
<paper-icon-button
id="enable-btn"
icon="hass:undo"
@click=${this._enableSelected}
></paper-icon-button>
<paper-tooltip for="enable-btn">
${this.hass.localize(
"ui.panel.config.entities.picker.enable_selected.button"
)}
</paper-tooltip>
<paper-icon-button
id="disable-btn"
icon="hass:cancel"
@click=${this._disableSelected}
></paper-icon-button>
<paper-tooltip for="disable-btn">
${this.hass.localize(
"ui.panel.config.entities.picker.disable_selected.button"
)}
</paper-tooltip>
<paper-icon-button
id="remove-btn"
icon="hass:delete"
@click=${this._removeSelected}
></paper-icon-button>
<paper-tooltip for="remove-btn">
${this.hass.localize(
"ui.panel.config.entities.picker.remove_selected.button"
)}
</paper-tooltip>
`}
</div>
`
: html`
<search-input
@value-changed=${this._handleSearchChange}
.filter=${this._filter}
></search-input>
<paper-menu-button no-animations horizontal-align="right">
<paper-icon-button
aria-label=${this.hass!.localize(
"ui.panel.config.entities.picker.filter.filter"
)}
title="${this.hass!.localize(
"ui.panel.config.entities.picker.filter.filter"
)}"
icon="hass:filter-variant"
slot="dropdown-trigger"
></paper-icon-button>
<paper-listbox slot="dropdown-content">
<paper-icon-item @tap="${this._showDisabledChanged}">
<paper-checkbox
.checked=${this._showDisabled}
slot="item-icon"
></paper-checkbox>
${this.hass!.localize(
"ui.panel.config.entities.picker.filter.show_disabled"
)}
</paper-icon-item>
<paper-icon-item @tap="${this._showRestoredChanged}">
<paper-checkbox
.checked=${this._showUnavailable}
slot="item-icon"
></paper-checkbox>
${this.hass!.localize(
"ui.panel.config.entities.picker.filter.show_unavailable"
)}
</paper-icon-item>
</paper-listbox>
</paper-menu-button>
`}
</div>
</ha-data-table>
</div>
</hass-tabs-subpage>
`; `;
} }
@ -520,14 +502,13 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
font-weight: var(--paper-font-subhead_-_font-weight); font-weight: var(--paper-font-subhead_-_font-weight);
line-height: var(--paper-font-subhead_-_line-height); line-height: var(--paper-font-subhead_-_line-height);
} }
.intro {
padding: 24px 16px;
}
.content {
padding: 4px;
}
ha-data-table { ha-data-table {
width: 100%; width: 100%;
--data-table-border-width: 0;
}
:host(:not([narrow])) ha-data-table {
height: calc(100vh - 65px);
display: block;
} }
ha-switch { ha-switch {
margin-top: 16px; margin-top: 16px;
@ -540,12 +521,26 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
} }
search-input { search-input {
flex-grow: 1; flex-grow: 1;
position: relative;
top: 2px;
}
.search-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-left: -24px;
color: var(--secondary-text-color);
} }
.selected-txt { .selected-txt {
font-weight: bold; font-weight: bold;
margin-top: 38px;
padding-left: 16px; padding-left: 16px;
} }
.table-header .selected-txt {
margin-top: 20px;
}
.search-toolbar .selected-txt {
font-size: 16px;
}
.header-btns > mwc-button, .header-btns > mwc-button,
.header-btns > paper-icon-button { .header-btns > paper-icon-button {
margin: 8px; margin: 8px;

View File

@ -160,6 +160,10 @@ export class HaSceneEditor extends SubscribeMixin(LitElement) {
this._deviceEntityLookup, this._deviceEntityLookup,
this._deviceRegistryEntries this._deviceRegistryEntries
); );
const name = this.scene
? computeStateName(this.scene)
: this.hass.localize("ui.panel.config.scene.editor.default_name");
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage
.hass=${this.hass} .hass=${this.hass}
@ -191,6 +195,13 @@ export class HaSceneEditor extends SubscribeMixin(LitElement) {
` `
: "" : ""
} }
${
this.narrow
? html`
<span slot="header">${name}</span>
`
: ""
}
<div <div
id="root" id="root"
class="${classMap({ class="${classMap({
@ -198,15 +209,13 @@ export class HaSceneEditor extends SubscribeMixin(LitElement) {
})}" })}"
> >
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
<div slot="header"> ${
${ !this.narrow
this.scene ? html`
? computeStateName(this.scene) <span slot="header">${name}</span>
: this.hass.localize( `
"ui.panel.config.scene.editor.default_name" : ""
) }
}
</div>
<div slot="introduction"> <div slot="introduction">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.scene.editor.introduction" "ui.panel.config.scene.editor.introduction"

View File

@ -63,7 +63,11 @@ export class HaScriptEditor extends LitElement {
@click=${this._deleteConfirm} @click=${this._deleteConfirm}
></paper-icon-button> ></paper-icon-button>
`} `}
${this.narrow
? html`
<span slot="header">${this._config?.alias}</span>
`
: ""}
<div class="content"> <div class="content">
${this._errors ${this._errors
? html` ? html`
@ -78,7 +82,11 @@ export class HaScriptEditor extends LitElement {
${this._config ${this._config
? html` ? html`
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
<span slot="header">${this._config.alias}</span> ${!this.narrow
? html`
<span slot="header">${this._config.alias}</span>
`
: ""}
<span slot="introduction"> <span slot="introduction">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.script.editor.introduction" "ui.panel.config.script.editor.introduction"