diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index fabfe7da26..119231feec 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -53,6 +53,9 @@ export const fallbackDeviceName = ( return undefined; }; +export const devicesInArea = (devices: DeviceRegistryEntry[], areaId: string) => + devices.filter((device) => device.area_id === areaId); + export const updateDeviceRegistryEntry = ( hass: HomeAssistant, deviceId: string, diff --git a/src/dialogs/generic/show-dialog-box.ts b/src/dialogs/generic/show-dialog-box.ts index 4d9975a6df..8b05578bd7 100644 --- a/src/dialogs/generic/show-dialog-box.ts +++ b/src/dialogs/generic/show-dialog-box.ts @@ -1,8 +1,9 @@ import { fireEvent } from "../../common/dom/fire_event"; +import { TemplateResult } from "lit-html"; interface BaseDialogParams { confirmText?: string; - text?: string; + text?: string | TemplateResult; title?: string; } diff --git a/src/layouts/hass-tabs-subpage-data-table.ts b/src/layouts/hass-tabs-subpage-data-table.ts index d12ed9e8f8..4c458569cf 100644 --- a/src/layouts/hass-tabs-subpage-data-table.ts +++ b/src/layouts/hass-tabs-subpage-data-table.ts @@ -88,6 +88,7 @@ export class HaTabsSubpageDataTable extends LitElement { .route=${this.route} .tabs=${this.tabs} > +
${this.narrow ? html`
diff --git a/src/panels/config/areas/dialog-area-registry-detail.ts b/src/panels/config/areas/dialog-area-registry-detail.ts index af7410c8c5..3bfa1a0a99 100644 --- a/src/panels/config/areas/dialog-area-registry-detail.ts +++ b/src/panels/config/areas/dialog-area-registry-detail.ts @@ -109,9 +109,9 @@ class DialogAreaDetail extends LitElement { name: this._name.trim(), }; if (this._params!.entry) { - await this._params!.updateEntry(values); + await this._params!.updateEntry!(values); } else { - await this._params!.createEntry(values); + await this._params!.createEntry!(values); } this._params = undefined; } catch (err) { @@ -124,7 +124,7 @@ class DialogAreaDetail extends LitElement { private async _deleteEntry() { this._submitting = true; try { - if (await this._params!.removeEntry()) { + if (await this._params!.removeEntry!()) { this._params = undefined; } } finally { diff --git a/src/panels/config/areas/ha-config-area-page.ts b/src/panels/config/areas/ha-config-area-page.ts new file mode 100644 index 0000000000..9a2762d3dd --- /dev/null +++ b/src/panels/config/areas/ha-config-area-page.ts @@ -0,0 +1,397 @@ +import "@material/mwc-button"; +import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; +import "@polymer/paper-input/paper-input"; +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import "../../../components/dialog/ha-paper-dialog"; +import { haStyle } from "../../../resources/styles"; +import { HomeAssistant, Route } from "../../../types"; +import memoizeOne from "memoize-one"; +import { + AreaRegistryEntry, + updateAreaRegistryEntry, + deleteAreaRegistryEntry, +} from "../../../data/area_registry"; +import { + DeviceRegistryEntry, + devicesInArea, + computeDeviceName, +} from "../../../data/device_registry"; +import { configSections } from "../ha-panel-config"; +import { + showAreaRegistryDetailDialog, + loadAreaRegistryDetailDialog, +} from "./show-dialog-area-registry-detail"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import { RelatedResult, findRelated } from "../../../data/search"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { computeStateName } from "../../../common/entity/compute_state_name"; +import { ifDefined } from "lit-html/directives/if-defined"; + +@customElement("ha-config-area-page") +class HaConfigAreaPage extends LitElement { + @property() public hass!: HomeAssistant; + @property() public areaId!: string; + @property() public areas!: AreaRegistryEntry[]; + @property() public devices!: DeviceRegistryEntry[]; + @property({ type: Boolean, reflect: true }) public narrow!: boolean; + @property() public isWide!: boolean; + @property() public showAdvanced!: boolean; + @property() public route!: Route; + @property() private _related?: RelatedResult; + + private _area = memoizeOne((areaId: string, areas: AreaRegistryEntry[]): + | AreaRegistryEntry + | undefined => areas.find((area) => area.area_id === areaId)); + + private _devices = memoizeOne( + (areaId: string, devices: DeviceRegistryEntry[]): DeviceRegistryEntry[] => + devicesInArea(devices, areaId) + ); + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + loadAreaRegistryDetailDialog(); + } + + protected updated(changedProps) { + super.updated(changedProps); + if (changedProps.has("areaId")) { + this._findRelated(); + } + } + + protected render(): TemplateResult { + const area = this._area(this.areaId, this.areas); + + if (!area) { + return html` + + `; + } + + const devices = this._devices(this.areaId, this.devices); + + return html` + + ${this.narrow + ? html` + + ${area.name} + + ` + : ""} + + + +
+ ${!this.narrow + ? html` +
+

${area.name}

+
+ ` + : ""} +
+ ${devices.length + ? devices.map( + (device) => + html` + + + + ${computeDeviceName(device, this.hass)} + + + + + ` + ) + : html` + ${this.hass.localize( + "ui.panel.config.devices.no_devices" + )} + `} + +
+
+ ${isComponentLoaded(this.hass, "automation") + ? html` + ${this._related?.automation?.length + ? this._related.automation.map((automation) => { + const state = this.hass.states[automation]; + return state + ? html` +
+ + + + ${computeStateName(state)} + + + + + ${!state.attributes.id + ? html` + ${this.hass.localize( + "ui.panel.config.devices.cant_edit" + )} + + ` + : ""} +
+ ` + : ""; + }) + : html` + ${this.hass.localize( + "ui.panel.config.devices.automation.no_automations" + )} + `} +
+ ` + : ""} +
+
+ ${isComponentLoaded(this.hass, "scene") + ? html` + ${this._related?.scene?.length + ? this._related.scene.map((scene) => { + const state = this.hass.states[scene]; + return state + ? html` +
+ + + + ${computeStateName(state)} + + + + + ${!state.attributes.id + ? html` + ${this.hass.localize( + "ui.panel.config.devices.cant_edit" + )} + + ` + : ""} +
+ ` + : ""; + }) + : html` + ${this.hass.localize( + "ui.panel.config.devices.scene.no_scenes" + )} + `} +
+ ` + : ""} + ${isComponentLoaded(this.hass, "script") + ? html` + ${this._related?.script?.length + ? this._related.script.map((script) => { + const state = this.hass.states[script]; + return state + ? html` + + + + ${computeStateName(state)} + + + + + ` + : ""; + }) + : html` + + ${this.hass.localize( + "ui.panel.config.devices.script.no_scripts" + )} + `} + + ` + : ""} +
+
+
+ `; + } + + private async _findRelated() { + this._related = await findRelated(this.hass, "area", this.areaId); + } + + private _showSettings(ev: MouseEvent) { + const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry; + this._openDialog(entry); + } + + private _openDialog(entry?: AreaRegistryEntry) { + showAreaRegistryDetailDialog(this, { + entry, + updateEntry: async (values) => + updateAreaRegistryEntry(this.hass!, entry!.area_id, values), + removeEntry: async () => { + if ( + !(await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.areas.delete.confirmation_title" + ), + text: this.hass.localize( + "ui.panel.config.areas.delete.confirmation_text" + ), + dismissText: this.hass.localize("ui.common.no"), + confirmText: this.hass.localize("ui.common.yes"), + })) + ) { + return false; + } + + try { + await deleteAreaRegistryEntry(this.hass!, entry!.area_id); + return true; + } catch (err) { + return false; + } + }, + }); + } + + static get styles(): CSSResult[] { + return [ + haStyle, + css` + h1 { + margin-top: 0; + font-family: var(--paper-font-headline_-_font-family); + -webkit-font-smoothing: var( + --paper-font-headline_-_-webkit-font-smoothing + ); + font-size: var(--paper-font-headline_-_font-size); + font-weight: var(--paper-font-headline_-_font-weight); + letter-spacing: var(--paper-font-headline_-_letter-spacing); + line-height: var(--paper-font-headline_-_line-height); + opacity: var(--dark-primary-opacity); + } + + .container { + display: flex; + flex-wrap: wrap; + margin: auto; + max-width: 1000px; + margin-top: 32px; + margin-bottom: 32px; + } + .column { + padding: 8px; + box-sizing: border-box; + width: 33%; + flex-grow: 1; + } + .fullwidth { + padding: 8px; + width: 100%; + } + .column > *:not(:first-child) { + margin-top: 16px; + } + + :host([narrow]) .column { + width: 100%; + } + + :host([narrow]) .container { + margin-top: 0; + } + + paper-item { + cursor: pointer; + } + + a { + text-decoration: none; + color: var(--primary-text-color); + } + + paper-item.no-link { + cursor: default; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-area-page": HaConfigAreaPage; + } +} diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts new file mode 100644 index 0000000000..25abaa7a17 --- /dev/null +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -0,0 +1,217 @@ +import { + LitElement, + TemplateResult, + html, + css, + CSSResult, + property, + customElement, +} from "lit-element"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-item/paper-item-body"; + +import { HomeAssistant, Route } from "../../../types"; +import { + AreaRegistryEntry, + createAreaRegistryEntry, +} from "../../../data/area_registry"; +import "../../../components/ha-fab"; +import "../../../layouts/hass-loading-screen"; +import "../../../layouts/hass-tabs-subpage-data-table"; +import "../ha-config-section"; +import { + showAreaRegistryDetailDialog, + loadAreaRegistryDetailDialog, +} from "./show-dialog-area-registry-detail"; +import { configSections } from "../ha-panel-config"; +import memoizeOne from "memoize-one"; +import { + DataTableColumnContainer, + RowClickedEvent, +} from "../../../components/data-table/ha-data-table"; +import { + devicesInArea, + DeviceRegistryEntry, +} from "../../../data/device_registry"; +import { navigate } from "../../../common/navigate"; +import { HASSDomEvent } from "../../../common/dom/fire_event"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; + +@customElement("ha-config-areas-dashboard") +export class HaConfigAreasDashboard extends LitElement { + @property() public hass!: HomeAssistant; + @property() public isWide?: boolean; + @property() public narrow!: boolean; + @property() public route!: Route; + @property() public areas!: AreaRegistryEntry[]; + @property() public devices!: DeviceRegistryEntry[]; + + private _areas = memoizeOne( + (areas: AreaRegistryEntry[], devices: DeviceRegistryEntry[]) => { + return areas.map((area) => { + return { + ...area, + devices: devicesInArea(devices, area.area_id).length, + }; + }); + } + ); + + private _columns = memoizeOne( + (narrow: boolean): DataTableColumnContainer => + narrow + ? { + name: { + title: this.hass.localize( + "ui.panel.config.areas.data_table.area" + ), + sortable: true, + filterable: true, + grows: true, + direction: "asc", + }, + } + : { + name: { + title: this.hass.localize( + "ui.panel.config.areas.data_table.area" + ), + sortable: true, + filterable: true, + grows: true, + direction: "asc", + }, + devices: { + title: this.hass.localize( + "ui.panel.config.areas.data_table.devices" + ), + sortable: true, + type: "numeric", + width: "20%", + direction: "asc", + }, + } + ); + + protected render(): TemplateResult { + return html` + + + + + `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + loadAreaRegistryDetailDialog(); + } + + private _createArea() { + this._openDialog(); + } + + private _showHelp() { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.config.areas.caption"), + text: html` + ${this.hass.localize("ui.panel.config.areas.picker.introduction")} +

+ ${this.hass.localize("ui.panel.config.areas.picker.introduction2")} +

+ + ${this.hass.localize( + "ui.panel.config.areas.picker.integrations_page" + )} + + `, + }); + } + + private _handleRowClicked(ev: HASSDomEvent) { + const areaId = ev.detail.id; + navigate(this, `/config/areas/area/${areaId}`); + } + + private _openDialog(entry?: AreaRegistryEntry) { + showAreaRegistryDetailDialog(this, { + entry, + createEntry: async (values) => + createAreaRegistryEntry(this.hass!, values), + }); + } + + static get styles(): CSSResult { + return css` + hass-loading-screen { + --app-header-background-color: var(--sidebar-background-color); + --app-header-text-color: var(--sidebar-text-color); + } + a { + color: var(--primary-color); + } + ha-card { + max-width: 600px; + margin: 16px auto; + overflow: hidden; + } + .empty { + text-align: center; + } + paper-item { + cursor: pointer; + padding-top: 4px; + padding-bottom: 4px; + } + ha-fab { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 1; + } + + ha-fab[is-wide] { + bottom: 24px; + right: 24px; + } + ha-fab[narrow] { + bottom: 84px; + } + ha-fab.rtl { + right: auto; + left: 16px; + } + + ha-fab[is-wide].rtl { + bottom: 24px; + right: auto; + left: 24px; + } + `; + } +} diff --git a/src/panels/config/areas/ha-config-areas.ts b/src/panels/config/areas/ha-config-areas.ts index 7c5212bb5d..ac7c351eef 100644 --- a/src/panels/config/areas/ha-config-areas.ts +++ b/src/panels/config/areas/ha-config-areas.ts @@ -1,226 +1,120 @@ +import "./ha-config-areas-dashboard"; +import "./ha-config-area-page"; +import { compare } from "../../../common/string/compare"; import { - LitElement, - TemplateResult, - html, - css, - CSSResult, - property, - customElement, -} from "lit-element"; -import "@polymer/paper-item/paper-item"; -import "@polymer/paper-item/paper-item-body"; - -import { HomeAssistant, Route } from "../../../types"; -import { - AreaRegistryEntry, - updateAreaRegistryEntry, - deleteAreaRegistryEntry, - createAreaRegistryEntry, subscribeAreaRegistry, + AreaRegistryEntry, } from "../../../data/area_registry"; -import "../../../components/ha-card"; -import "../../../components/ha-fab"; -import "../../../layouts/hass-tabs-subpage"; -import "../../../layouts/hass-loading-screen"; -import "../ha-config-section"; import { - showAreaRegistryDetailDialog, - loadAreaRegistryDetailDialog, -} from "./show-dialog-area-registry-detail"; -import { classMap } from "lit-html/directives/class-map"; -import { computeRTL } from "../../../common/util/compute_rtl"; + HassRouterPage, + RouterOptions, +} from "../../../layouts/hass-router-page"; +import { property, customElement, PropertyValues } from "lit-element"; +import { HomeAssistant } from "../../../types"; +import { ConfigEntry, getConfigEntries } from "../../../data/config_entries"; +import { + DeviceRegistryEntry, + subscribeDeviceRegistry, +} from "../../../data/device_registry"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; -import { configSections } from "../ha-panel-config"; -import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; @customElement("ha-config-areas") -export class HaConfigAreas extends LitElement { +class HaConfigAreas extends HassRouterPage { @property() public hass!: HomeAssistant; - @property() public isWide?: boolean; @property() public narrow!: boolean; - @property() public route!: Route; - @property() private _areas?: AreaRegistryEntry[]; - private _unsubAreas?: UnsubscribeFunc; + @property() public isWide!: boolean; + @property() public showAdvanced!: boolean; + + protected routerOptions: RouterOptions = { + defaultPage: "dashboard", + routes: { + dashboard: { + tag: "ha-config-areas-dashboard", + cache: true, + }, + area: { + tag: "ha-config-area-page", + }, + }, + }; + + @property() private _configEntries: ConfigEntry[] = []; + @property() private _deviceRegistryEntries: DeviceRegistryEntry[] = []; + @property() private _areas: AreaRegistryEntry[] = []; + + private _unsubs?: UnsubscribeFunc[]; + + public connectedCallback() { + super.connectedCallback(); + + if (!this.hass) { + return; + } + this._loadData(); + } public disconnectedCallback() { super.disconnectedCallback(); - if (this._unsubAreas) { - this._unsubAreas(); + if (this._unsubs) { + while (this._unsubs.length) { + this._unsubs.pop()!(); + } + this._unsubs = undefined; } } - protected render(): TemplateResult { - if (!this.hass || this._areas === undefined) { - return html` - - `; - } - return html` - - - - ${this.hass.localize("ui.panel.config.areas.picker.header")} - - - ${this.hass.localize("ui.panel.config.areas.picker.introduction")} -

- ${this.hass.localize( - "ui.panel.config.areas.picker.introduction2" - )} -

- - ${this.hass.localize( - "ui.panel.config.areas.picker.integrations_page" - )} - -
- - ${this._areas.map((entry) => { - return html` - - - ${entry.name} - - - `; - })} - ${this._areas.length === 0 - ? html` -
- ${this.hass.localize("ui.panel.config.areas.no_areas")} - - ${this.hass.localize("ui.panel.config.areas.create_area")} - -
- ` - : html``} -
-
-
- - - `; - } - protected firstUpdated(changedProps) { super.firstUpdated(changedProps); - loadAreaRegistryDetailDialog(); - } - - protected updated(changedProps) { - super.updated(changedProps); - if (!this._unsubAreas) { - this._unsubAreas = subscribeAreaRegistry( - this.hass.connection, - (areas) => { - this._areas = areas; - } - ); - } - } - - private _createArea() { - this._openDialog(); - } - - private _openEditEntry(ev: MouseEvent) { - const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry; - this._openDialog(entry); - } - private _openDialog(entry?: AreaRegistryEntry) { - showAreaRegistryDetailDialog(this, { - entry, - createEntry: async (values) => - createAreaRegistryEntry(this.hass!, values), - updateEntry: async (values) => - updateAreaRegistryEntry(this.hass!, entry!.area_id, values), - removeEntry: async () => { - if ( - !(await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.areas.delete.confirmation_title" - ), - text: this.hass.localize( - "ui.panel.config.areas.delete.confirmation_text" - ), - dismissText: this.hass.localize("ui.common.no"), - confirmText: this.hass.localize("ui.common.yes"), - })) - ) { - return false; - } - - try { - await deleteAreaRegistryEntry(this.hass!, entry!.area_id); - return true; - } catch (err) { - return false; - } - }, + this.addEventListener("hass-reload-entries", () => { + this._loadData(); }); } - static get styles(): CSSResult { - return css` - hass-loading-screen { - --app-header-background-color: var(--sidebar-background-color); - --app-header-text-color: var(--sidebar-text-color); - } - a { - color: var(--primary-color); - } - ha-card { - max-width: 600px; - margin: 16px auto; - overflow: hidden; - } - .empty { - text-align: center; - } - paper-item { - cursor: pointer; - padding-top: 4px; - padding-bottom: 4px; - } - ha-fab { - position: fixed; - bottom: 16px; - right: 16px; - z-index: 1; - } + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (!this._unsubs && changedProps.has("hass")) { + this._loadData(); + } + } - ha-fab[is-wide] { - bottom: 24px; - right: 24px; - } - ha-fab[narrow] { - bottom: 84px; - } - ha-fab.rtl { - right: auto; - left: 16px; - } + protected updatePageEl(pageEl) { + pageEl.hass = this.hass; - ha-fab[is-wide].rtl { - bottom: 24px; - right: auto; - left: 24px; - } - `; + if (this._currentPage === "area") { + pageEl.areaId = this.routeTail.path.substr(1); + } + + pageEl.entries = this._configEntries; + pageEl.devices = this._deviceRegistryEntries; + pageEl.areas = this._areas; + pageEl.narrow = this.narrow; + pageEl.isWide = this.isWide; + pageEl.showAdvanced = this.showAdvanced; + pageEl.route = this.routeTail; + } + + private _loadData() { + getConfigEntries(this.hass).then((configEntries) => { + this._configEntries = configEntries.sort((conf1, conf2) => + compare(conf1.title, conf2.title) + ); + }); + if (this._unsubs) { + return; + } + this._unsubs = [ + subscribeAreaRegistry(this.hass.connection, (areas) => { + this._areas = areas; + }), + subscribeDeviceRegistry(this.hass.connection, (entries) => { + this._deviceRegistryEntries = entries; + }), + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-areas": HaConfigAreas; } } diff --git a/src/panels/config/areas/show-dialog-area-registry-detail.ts b/src/panels/config/areas/show-dialog-area-registry-detail.ts index 4a24231981..401c88f13b 100644 --- a/src/panels/config/areas/show-dialog-area-registry-detail.ts +++ b/src/panels/config/areas/show-dialog-area-registry-detail.ts @@ -6,11 +6,11 @@ import { export interface AreaRegistryDetailDialogParams { entry?: AreaRegistryEntry; - createEntry: (values: AreaRegistryEntryMutableParams) => Promise; - updateEntry: ( + createEntry?: (values: AreaRegistryEntryMutableParams) => Promise; + updateEntry?: ( updates: Partial ) => Promise; - removeEntry: () => Promise; + removeEntry?: () => Promise; } export const loadAreaRegistryDetailDialog = () => diff --git a/src/translations/en.json b/src/translations/en.json index 9307f0643d..0df149151d 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -785,6 +785,10 @@ "areas": { "caption": "Areas", "description": "Overview of all areas in your home.", + "data_table": { + "area": "Area", + "devices": "Devices" + }, "picker": { "header": "Areas", "introduction": "Areas are used to organize where devices are. This information will be used throughout Home Assistant to help you in organizing your interface, permissions and integrations with other systems.", @@ -1442,6 +1446,7 @@ "unknown_error": "Unknown error", "name": "Name", "update": "Update", + "no_devices": "No devices", "automation": { "automations": "Automations", "no_automations": "No automations",