diff --git a/build-scripts/gulp/translations.js b/build-scripts/gulp/translations.js index c02a158c75..138f8b324c 100755 --- a/build-scripts/gulp/translations.js +++ b/build-scripts/gulp/translations.js @@ -197,13 +197,14 @@ gulp.task( // than a base translation + region. const tr = path.basename(file.history[0], ".json"); const subtags = tr.split("-"); - const src = [ - workDir + "/translationMaster.json", - workDir + "/test.json", - ]; + const src = [workDir + "/translationMaster.json"]; for (let i = 1; i <= subtags.length; i++) { const lang = subtags.slice(0, i).join("-"); - src.push(inDir + "/" + lang + ".json"); + if (lang === "test") { + src.push(workDir + "/test.json"); + } else { + src.push(inDir + "/" + lang + ".json"); + } } return gulp .src(src, { allowEmpty: true }) diff --git a/setup.py b/setup.py index c856037abb..73f2acd8f4 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="home-assistant-frontend", - version="20190523.0", + version="20190529.0", description="The Home Assistant frontend", url="https://github.com/home-assistant/home-assistant-polymer", author="The Home Assistant Authors", diff --git a/src/common/entity/entity_filter.ts b/src/common/entity/entity_filter.ts index 6267b61f05..2b59721fe7 100644 --- a/src/common/entity/entity_filter.ts +++ b/src/common/entity/entity_filter.ts @@ -2,6 +2,20 @@ import computeDomain from "./compute_domain"; export type FilterFunc = (entityId: string) => boolean; +export interface EntityFilter { + include_domains: string[]; + include_entities: string[]; + exclude_domains: string[]; + exclude_entities: string[]; +} + +export const isEmptyFilter = (filter: EntityFilter) => + filter.include_domains.length + + filter.include_entities.length + + filter.exclude_domains.length + + filter.exclude_entities.length === + 0; + export const generateFilter = ( includeDomains?: string[], includeEntities?: string[], diff --git a/src/components/entity/state-info.js b/src/components/entity/state-info.js index d2859d15d5..c9d5033a48 100644 --- a/src/components/entity/state-info.js +++ b/src/components/entity/state-info.js @@ -91,13 +91,12 @@ class StateInfo extends PolymerElement { static get properties() { return { - detailed: { - type: Boolean, - value: false, - }, hass: Object, stateObj: Object, - inDialog: Boolean, + inDialog: { + type: Boolean, + value: () => false, + }, rtl: { type: Boolean, reflectToAttribute: true, diff --git a/src/data/cloud.ts b/src/data/cloud.ts index 502ec10cd5..5135867376 100644 --- a/src/data/cloud.ts +++ b/src/data/cloud.ts @@ -1,27 +1,33 @@ import { HomeAssistant } from "../types"; +import { EntityFilter } from "../common/entity/entity_filter"; -export interface EntityFilter { - include_domains: string[]; - include_entities: string[]; - exclude_domains: string[]; - exclude_entities: string[]; -} interface CloudStatusBase { logged_in: boolean; cloud: "disconnected" | "connecting" | "connected"; } +export interface GoogleEntityConfig { + should_expose?: boolean; + override_name?: string; + aliases?: string[]; + disable_2fa?: boolean; +} + export interface CertificateInformation { common_name: string; expire_date: string; fingerprint: string; } -interface CloudPreferences { +export interface CloudPreferences { google_enabled: boolean; alexa_enabled: boolean; + remote_enabled: boolean; google_secure_devices_pin: string | undefined; cloudhooks: { [webhookId: string]: CloudWebhook }; + google_entity_configs: { + [entityId: string]: GoogleEntityConfig; + }; } export type CloudStatusLoggedIn = CloudStatusBase & { @@ -49,6 +55,12 @@ export interface CloudWebhook { managed?: boolean; } +export interface GoogleEntity { + entity_id: string; + traits: string[]; + might_2fa: boolean; +} + export const fetchCloudStatus = (hass: HomeAssistant) => hass.callWS({ type: "cloud/status" }); @@ -89,3 +101,20 @@ export const updateCloudPref = ( type: "cloud/update_prefs", ...prefs, }); + +export const fetchCloudGoogleEntities = (hass: HomeAssistant) => + hass.callWS({ type: "cloud/google_assistant/entities" }); + +export const updateCloudGoogleEntityConfig = ( + hass: HomeAssistant, + entityId: string, + values: GoogleEntityConfig +) => + hass.callWS({ + type: "cloud/google_assistant/entities/update", + entity_id: entityId, + ...values, + }); + +export const cloudSyncGoogleAssistant = (hass: HomeAssistant) => + hass.callApi("POST", "cloud/google_actions/sync"); diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 28161f0e91..e137ef8ff9 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -16,8 +16,8 @@ export interface DeviceRegistryEntry { } export interface DeviceRegistryEntryMutableParams { - area_id?: string; - name_by_user?: string; + area_id?: string | null; + name_by_user?: string | null; } export const updateDeviceRegistryEntry = ( diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index fa638acb85..32bebb98ff 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -1,4 +1,5 @@ import { HomeAssistant } from "../types"; +import { Connection } from "home-assistant-js-websocket"; export interface LovelaceConfig { title?: string; @@ -76,3 +77,8 @@ export const saveConfig = ( type: "lovelace/config/save", config, }); + +export const subscribeLovelaceUpdates = ( + conn: Connection, + onChange: () => void +) => conn.subscribeEvents(onChange, "lovelace_updated"); diff --git a/src/layouts/hass-router-page.ts b/src/layouts/hass-router-page.ts index 4a39902dd8..ae66380088 100644 --- a/src/layouts/hass-router-page.ts +++ b/src/layouts/hass-router-page.ts @@ -34,6 +34,9 @@ export interface RouterOptions { showLoading?: boolean; // Promise that resolves when the initial data is loaded which is needed to show any route. initialLoad?: () => Promise; + // Hook that is called before rendering a new route. Allowing redirects. + // If string returned, that page will be rendered instead. + beforeRender?: (page: string) => string | undefined; routes: { // If it's a string, it is another route whose options should be adopted. [route: string]: RouteOptions | string; @@ -48,7 +51,7 @@ export class HassRouterPage extends UpdatingElement { protected routerOptions!: RouterOptions; - private _currentPage = ""; + protected _currentPage = ""; private _currentLoadProm?: Promise; private _cache = {}; private _initialLoadDone = false; @@ -101,6 +104,25 @@ export class HassRouterPage extends UpdatingElement { routeOptions = routerOptions.routes[newPage]; } + if (routerOptions.beforeRender) { + const result = routerOptions.beforeRender(newPage); + if (result !== undefined) { + newPage = result; + routeOptions = routerOptions.routes[newPage]; + + // Handle redirects + while (typeof routeOptions === "string") { + newPage = routeOptions; + routeOptions = routerOptions.routes[newPage]; + } + + // Update the url if we know where we're mounted. + if (route) { + navigate(this, `${route.prefix}/${result}`, true); + } + } + } + if (this._currentPage === newPage) { if (this.lastChild) { this.updatePageEl(this.lastChild, changedProps); @@ -245,6 +267,10 @@ export class HassRouterPage extends UpdatingElement { return this.updateComplete.then(() => this._currentLoadProm); } + protected createElement(tag: string) { + return document.createElement(tag); + } + protected updatePageEl(_pageEl, _changedProps?: PropertyValues) { // default we do nothing } @@ -262,8 +288,7 @@ export class HassRouterPage extends UpdatingElement { this.removeChild(this.lastChild); } - const panelEl = - this._cache[page] || document.createElement(routeOptions.tag); + const panelEl = this._cache[page] || this.createElement(routeOptions.tag); this.updatePageEl(panelEl); this.appendChild(panelEl); diff --git a/src/managers/notification-manager.ts b/src/managers/notification-manager.ts index 8ff0afb521..0cc3dda81d 100644 --- a/src/managers/notification-manager.ts +++ b/src/managers/notification-manager.ts @@ -1,30 +1,95 @@ +import { + LitElement, + query, + property, + TemplateResult, + html, + css, + CSSResult, +} from "lit-element"; import { computeRTL } from "../common/util/compute_rtl"; -import "../components/ha-toast"; -import { LitElement, query, property, TemplateResult, html } from "lit-element"; import { HomeAssistant } from "../types"; +import "@material/mwc-button"; +import "../components/ha-toast"; // Typing // tslint:disable-next-line: no-duplicate-imports import { HaToast } from "../components/ha-toast"; export interface ShowToastParams { message: string; + action?: ToastActionParams; + duration?: number; + dismissable?: boolean; +} + +export interface ToastActionParams { + action: () => void; + text: string; } class NotificationManager extends LitElement { @property() public hass!: HomeAssistant; + + @property() private _action?: ToastActionParams; + @property() private _noCancelOnOutsideClick: boolean = false; + @query("ha-toast") private _toast!: HaToast; - public showDialog({ message }: ShowToastParams) { + public showDialog({ + message, + action, + duration, + dismissable, + }: ShowToastParams) { const toast = this._toast; toast.setAttribute("dir", computeRTL(this.hass) ? "rtl" : "ltr"); - toast.show(message); + this._action = action || undefined; + this._noCancelOnOutsideClick = + dismissable === undefined ? false : !dismissable; + toast.hide(); + toast.show({ + text: message, + duration: duration === undefined ? 3000 : duration, + }); } protected render(): TemplateResult | void { return html` - + + ${this._action + ? html` + + ` + : ""} + + `; + } + + private buttonClicked() { + this._toast.hide(); + if (this._action) { + this._action.action(); + } + } + + static get styles(): CSSResult { + return css` + mwc-button { + color: var(--primary-color); + font-weight: bold; + } `; } } customElements.define("notification-manager", NotificationManager); + +declare global { + // for fire event + interface HASSDomEvents { + "hass-notification": ShowToastParams; + } +} diff --git a/src/panels/config/cloud/cloud-exposed-entities.ts b/src/panels/config/cloud/cloud-exposed-entities.ts index a1aca9a007..6035110396 100644 --- a/src/panels/config/cloud/cloud-exposed-entities.ts +++ b/src/panels/config/cloud/cloud-exposed-entities.ts @@ -16,8 +16,8 @@ import computeStateName from "../../../common/entity/compute_state_name"; import { FilterFunc, generateFilter, + EntityFilter, } from "../../../common/entity/entity_filter"; -import { EntityFilter } from "../../../data/cloud"; export class CloudExposedEntities extends LitElement { public hass?: HomeAssistant; diff --git a/src/panels/config/cloud/cloud-google-pref.ts b/src/panels/config/cloud/cloud-google-pref.ts index 52be2cb5a4..628d524704 100644 --- a/src/panels/config/cloud/cloud-google-pref.ts +++ b/src/panels/config/cloud/cloud-google-pref.ts @@ -16,7 +16,6 @@ import "../../../components/ha-card"; import { fireEvent } from "../../../common/dom/fire_event"; import { HomeAssistant } from "../../../types"; -import "./cloud-exposed-entities"; import { CloudStatusLoggedIn, updateCloudPref } from "../../../data/cloud"; import { PaperInputElement } from "@polymer/paper-input/paper-input"; @@ -89,12 +88,6 @@ export class CloudGooglePref extends LitElement { @change="${this._pinChanged}" > -

Exposed entities:

- ` : ""} @@ -103,8 +96,12 @@ export class CloudGooglePref extends LitElement { .hass="${this.hass}" .disabled="${!google_enabled}" path="cloud/google_actions/sync" - >Sync devices + Sync entities to Google + + + Manage Entities + `; @@ -154,6 +151,12 @@ export class CloudGooglePref extends LitElement { paper-input { width: 200px; } + .card-actions a { + text-decoration: none; + } + .card-actions mwc-button { + float: right; + } `; } } diff --git a/src/panels/config/cloud/ha-config-cloud-google-assistant.ts b/src/panels/config/cloud/ha-config-cloud-google-assistant.ts new file mode 100644 index 0000000000..eee1464fde --- /dev/null +++ b/src/panels/config/cloud/ha-config-cloud-google-assistant.ts @@ -0,0 +1,275 @@ +import { + LitElement, + TemplateResult, + html, + CSSResult, + css, + customElement, + property, +} from "lit-element"; +import "@polymer/paper-toggle-button"; +import "../../../layouts/hass-subpage"; +import "../../../layouts/hass-loading-screen"; +import "../../../components/ha-card"; +import "../../../components/entity/state-info"; +import { HomeAssistant } from "../../../types"; +import { + GoogleEntity, + fetchCloudGoogleEntities, + CloudStatusLoggedIn, + CloudPreferences, + updateCloudGoogleEntityConfig, + cloudSyncGoogleAssistant, + GoogleEntityConfig, +} from "../../../data/cloud"; +import memoizeOne from "memoize-one"; +import { + generateFilter, + isEmptyFilter, + EntityFilter, +} from "../../../common/entity/entity_filter"; +import { compare } from "../../../common/string/compare"; +import computeStateName from "../../../common/entity/compute_state_name"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { showToast } from "../../../util/toast"; +import { PolymerChangedEvent } from "../../../polymer-types"; + +@customElement("ha-config-cloud-google-assistant") +class CloudGoogleAssistant extends LitElement { + @property() public hass!: HomeAssistant; + @property() public cloudStatus!: CloudStatusLoggedIn; + @property() public isWide!: boolean; + @property() private _entities?: GoogleEntity[]; + @property() + private _entityConfigs: CloudPreferences["google_entity_configs"] = {}; + private _popstateSyncAttached = false; + private _popstateReloadStatusAttached = false; + + private _getEntityFilterFunc = memoizeOne((filter: EntityFilter) => + generateFilter( + filter.include_domains, + filter.include_entities, + filter.exclude_domains, + filter.exclude_entities + ) + ); + + protected render(): TemplateResult | void { + if (this._entities === undefined) { + return html` + + `; + } + const emptyFilter = true || isEmptyFilter(this.cloudStatus.google_entities); + const filterFunc = this._getEntityFilterFunc( + this.cloudStatus.google_entities + ); + let selected = 0; + const cards = this._entities.map((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + const config = this._entityConfigs[entity.entity_id] || {}; + const isExposed = emptyFilter + ? Boolean(config.should_expose) + : filterFunc(entity.entity_id); + if (isExposed) { + selected++; + } + + return html` + +
+ + ${entity.traits + .map((trait) => trait.substr(trait.lastIndexOf(".") + 1)) + .join(", ")} + + + Expose to Google Assistant + + ${entity.might_2fa + ? html` + + Disable two factor authentication + + ` + : ""} +
+
+ `; + }); + + return html` + + ${selected} selected + ${!emptyFilter + ? html` + + ` + : ""} +
+ ${cards} +
+
+ `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + this._fetchData(); + } + + protected updated(changedProps) { + super.updated(changedProps); + if (changedProps.has("cloudStatus")) { + this._entityConfigs = this.cloudStatus.prefs.google_entity_configs; + } + } + + private async _fetchData() { + const entities = await fetchCloudGoogleEntities(this.hass); + entities.sort((a, b) => { + const stateA = this.hass.states[a.entity_id]; + const stateB = this.hass.states[b.entity_id]; + return compare( + stateA ? computeStateName(stateA) : a.entity_id, + stateB ? computeStateName(stateB) : b.entity_id + ); + }); + this._entities = entities; + } + + private _showMoreInfo(ev) { + const entityId = ev.currentTarget.stateObj.entity_id; + fireEvent(this, "hass-more-info", { entityId }); + } + + private async _exposeChanged(ev: PolymerChangedEvent) { + const entityId = (ev.currentTarget as any).entityId; + const newExposed = ev.detail.value; + const curExposed = Boolean( + (this._entityConfigs[entityId] || {}).should_expose + ); + if (newExposed === curExposed) { + return; + } + await this._updateConfig(entityId, { + should_expose: newExposed, + }); + this._ensureEntitySync(); + } + + private async _disable2FAChanged(ev: PolymerChangedEvent) { + const entityId = (ev.currentTarget as any).entityId; + const newDisable2FA = ev.detail.value; + const curDisable2FA = Boolean( + (this._entityConfigs[entityId] || {}).disable_2fa + ); + if (newDisable2FA === curDisable2FA) { + return; + } + await this._updateConfig(entityId, { + disable_2fa: newDisable2FA, + }); + } + + private async _updateConfig(entityId: string, values: GoogleEntityConfig) { + const updatedConfig = await updateCloudGoogleEntityConfig( + this.hass, + entityId, + values + ); + this._entityConfigs = { + ...this._entityConfigs, + [entityId]: updatedConfig, + }; + this._ensureStatusReload(); + } + + private _ensureStatusReload() { + if (this._popstateReloadStatusAttached) { + return; + } + this._popstateReloadStatusAttached = true; + // Cache parent because by the time popstate happens, + // this element is detached + const parent = this.parentElement!; + this.addEventListener( + "popstate", + () => fireEvent(parent, "ha-refresh-cloud-status"), + { once: true } + ); + } + + private _ensureEntitySync() { + if (this._popstateSyncAttached) { + return; + } + this._popstateSyncAttached = true; + // Cache parent because by the time popstate happens, + // this element is detached + const parent = this.parentElement!; + window.addEventListener( + "popstate", + () => { + showToast(parent, { message: "Synchronizing changes to Google." }); + cloudSyncGoogleAssistant(this.hass); + }, + { once: true } + ); + } + + static get styles(): CSSResult { + return css` + .banner { + color: var(--primary-text-color); + background-color: var(--card-background-color); + padding: 16px 8px; + text-align: center; + } + .content { + display: flex; + flex-wrap: wrap; + padding: 4px; + --paper-toggle-button-label-spacing: 16px; + } + ha-card { + margin: 4px; + width: 100%; + max-width: 300px; + } + .card-content { + padding-bottom: 12px; + } + state-info { + cursor: pointer; + } + paper-toggle-button { + padding: 8px 0; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-cloud-google-assistant": CloudGoogleAssistant; + } +} diff --git a/src/panels/config/cloud/ha-config-cloud.js b/src/panels/config/cloud/ha-config-cloud.js deleted file mode 100644 index e85d7c030b..0000000000 --- a/src/panels/config/cloud/ha-config-cloud.js +++ /dev/null @@ -1,145 +0,0 @@ -import "@polymer/app-route/app-route"; -import { timeOut } from "@polymer/polymer/lib/utils/async"; -import { Debouncer } from "@polymer/polymer/lib/utils/debounce"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -import { PolymerElement } from "@polymer/polymer/polymer-element"; - -import "../ha-config-section"; -import "./ha-config-cloud-account"; -import "./ha-config-cloud-forgot-password"; -import "./ha-config-cloud-login"; -import "./ha-config-cloud-register"; -import NavigateMixin from "../../../mixins/navigate-mixin"; - -const LOGGED_IN_URLS = ["/account"]; -const NOT_LOGGED_IN_URLS = ["/login", "/register", "/forgot-password"]; - -/* - * @appliesMixin NavigateMixin - */ -class HaConfigCloud extends NavigateMixin(PolymerElement) { - static get template() { - return html` - - - - - - - - - - `; - } - - static get properties() { - return { - hass: Object, - isWide: Boolean, - loadingAccount: { - type: Boolean, - value: false, - }, - cloudStatus: { - type: Object, - }, - _flashMessage: { - type: String, - value: "", - }, - - route: Object, - - _routeData: Object, - _routeTail: Object, - _loginEmail: String, - }; - } - - static get observers() { - return ["_checkRoute(route, cloudStatus)"]; - } - - ready() { - super.ready(); - this.addEventListener("cloud-done", (ev) => { - this._flashMessage = ev.detail.flashMessage; - this.navigate("/config/cloud/login"); - }); - } - - _checkRoute(route) { - this._debouncer = Debouncer.debounce( - this._debouncer, - timeOut.after(0), - () => { - if ( - !this.cloudStatus || - (!this.cloudStatus.logged_in && - !NOT_LOGGED_IN_URLS.includes(route.path)) - ) { - this.navigate("/config/cloud/login", true); - } else if ( - this.cloudStatus.logged_in && - !LOGGED_IN_URLS.includes(route.path) - ) { - this.navigate("/config/cloud/account", true); - } - } - ); - } - - _equals(a, b) { - return a === b; - } -} - -customElements.define("ha-config-cloud", HaConfigCloud); diff --git a/src/panels/config/cloud/ha-config-cloud.ts b/src/panels/config/cloud/ha-config-cloud.ts new file mode 100644 index 0000000000..5a465c2395 --- /dev/null +++ b/src/panels/config/cloud/ha-config-cloud.ts @@ -0,0 +1,138 @@ +import "./ha-config-cloud-account"; +import "./ha-config-cloud-login"; +import { + HassRouterPage, + RouterOptions, +} from "../../../layouts/hass-router-page"; +import { property, customElement } from "lit-element"; +import { HomeAssistant, Route } from "../../../types"; +import { navigate } from "../../../common/navigate"; +import { CloudStatus } from "../../../data/cloud"; +import { PolymerChangedEvent } from "../../../polymer-types"; +import { PolymerElement } from "@polymer/polymer"; + +const LOGGED_IN_URLS = ["account", "google-assistant"]; +const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"]; + +@customElement("ha-config-cloud") +class HaConfigCloud extends HassRouterPage { + @property() public hass!: HomeAssistant; + @property() public isWide!: boolean; + @property() public route!: Route; + @property() public cloudStatus!: CloudStatus; + + protected routerOptions: RouterOptions = { + defaultPage: "login", + showLoading: true, + initialLoad: () => this._cloudStatusLoaded, + // Guard the different pages based on if we're logged in. + beforeRender: (page: string) => { + if (this.cloudStatus.logged_in) { + if (!LOGGED_IN_URLS.includes(page)) { + return "account"; + } + } else { + if (!NOT_LOGGED_IN_URLS.includes(page)) { + return "login"; + } + } + return undefined; + }, + routes: { + login: { + tag: "ha-config-cloud-login", + }, + register: { + tag: "ha-config-cloud-register", + load: () => import("./ha-config-cloud-register"), + }, + "forgot-password": { + tag: "ha-config-cloud-forgot-password", + load: () => import("./ha-config-cloud-forgot-password"), + }, + account: { + tag: "ha-config-cloud-account", + }, + "google-assistant": { + tag: "ha-config-cloud-google-assistant", + load: () => import("./ha-config-cloud-google-assistant"), + }, + }, + }; + + @property() private _flashMessage = ""; + @property() private _loginEmail = ""; + private _resolveCloudStatusLoaded!: () => void; + private _cloudStatusLoaded = new Promise((resolve) => { + this._resolveCloudStatusLoaded = resolve; + }); + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + this.addEventListener("cloud-done", (ev) => { + this._flashMessage = (ev as any).detail.flashMessage; + navigate(this, "/config/cloud/login"); + }); + } + + protected updated(changedProps) { + super.updated(changedProps); + + if (changedProps.has("cloudStatus")) { + const oldStatus = changedProps.get("cloudStatus") as + | CloudStatus + | undefined; + if (oldStatus === undefined) { + this._resolveCloudStatusLoaded(); + } else if (oldStatus.logged_in !== this.cloudStatus.logged_in) { + navigate(this, this.route.prefix, true); + } + } + } + + protected createElement(tag: string) { + const el = super.createElement(tag); + el.addEventListener("email-changed", (ev) => { + this._loginEmail = (ev as PolymerChangedEvent).detail.value; + }); + el.addEventListener("flash-message-changed", (ev) => { + this._flashMessage = (ev as PolymerChangedEvent).detail.value; + }); + return el; + } + + protected updatePageEl(el) { + // We are not going to update if the current page if we are not logged in + // and the current page requires being logged in. Happens when we log out. + if ( + this.cloudStatus && + !this.cloudStatus.logged_in && + LOGGED_IN_URLS.includes(this._currentPage) + ) { + return; + } + + if ("setProperties" in el) { + // As long as we have Polymer pages + (el as PolymerElement).setProperties({ + hass: this.hass, + email: this._loginEmail, + isWide: this.isWide, + cloudStatus: this.cloudStatus, + flashMessage: this._flashMessage, + }); + } else { + el.hass = this.hass; + el.email = this._loginEmail; + el.isWide = this.isWide; + el.cloudStatus = this.cloudStatus; + el.flashMessage = this._flashMessage; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-cloud": HaConfigCloud; + } +} diff --git a/src/panels/config/core/ha-config-core-form.ts b/src/panels/config/core/ha-config-core-form.ts index 7ccb572dcc..6d363fc4b6 100644 --- a/src/panels/config/core/ha-config-core-form.ts +++ b/src/panels/config/core/ha-config-core-form.ts @@ -33,8 +33,10 @@ class ConfigCoreForm extends LitElement { @property() private _timeZone!: string; protected render(): TemplateResult | void { - const isStorage = this.hass.config.config_source === "storage"; - const disabled = this._working || !isStorage; + const canEdit = ["storage", "default"].includes( + this.hass.config.config_source + ); + const disabled = this._working || !canEdit; return html`
- ${!isStorage + ${!canEdit ? html`

${this.hass.localize( diff --git a/src/panels/config/core/ha-config-name-form.ts b/src/panels/config/core/ha-config-name-form.ts index e2cef443fa..32355e575f 100644 --- a/src/panels/config/core/ha-config-name-form.ts +++ b/src/panels/config/core/ha-config-name-form.ts @@ -25,13 +25,15 @@ class ConfigNameForm extends LitElement { @property() private _name!: ConfigUpdateValues["location_name"]; protected render(): TemplateResult | void { - const isStorage = this.hass.config.config_source === "storage"; - const disabled = this._working || !isStorage; + const canEdit = ["storage", "default"].includes( + this.hass.config.config_source + ); + const disabled = this._working || !canEdit; return html`

- ${!isStorage + ${!canEdit ? html`

${this.hass.localize( diff --git a/src/panels/config/integrations/dialog-device-registry-detail.ts b/src/panels/config/integrations/dialog-device-registry-detail.ts new file mode 100644 index 0000000000..06da57b870 --- /dev/null +++ b/src/panels/config/integrations/dialog-device-registry-detail.ts @@ -0,0 +1,203 @@ +import { + LitElement, + html, + css, + CSSResult, + TemplateResult, + customElement, + property, +} from "lit-element"; +import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; +import "@polymer/paper-input/paper-input"; +import "@polymer/paper-listbox/paper-listbox"; +import "@polymer/paper-dropdown-menu/paper-dropdown-menu"; +import "@polymer/paper-item/paper-item"; +import "@material/mwc-button/mwc-button"; + +import "../../../components/dialog/ha-paper-dialog"; + +import { DeviceRegistryDetailDialogParams } from "./show-dialog-device-registry-detail"; +import { PolymerChangedEvent } from "../../../polymer-types"; +import { haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { + subscribeAreaRegistry, + AreaRegistryEntry, +} from "../../../data/area_registry"; + +@customElement("dialog-device-registry-detail") +class DialogDeviceRegistryDetail extends LitElement { + @property() public hass!: HomeAssistant; + + @property() private _nameByUser!: string; + @property() private _error?: string; + @property() private _params?: DeviceRegistryDetailDialogParams; + @property() private _areas?: AreaRegistryEntry[]; + @property() private _areaId?: string; + + private _submitting?: boolean; + private _unsubAreas?: any; + + public async showDialog( + params: DeviceRegistryDetailDialogParams + ): Promise { + this._params = params; + this._error = undefined; + this._nameByUser = this._params.device.name_by_user || ""; + this._areaId = this._params.device.area_id; + await this.updateComplete; + } + + public connectedCallback() { + super.connectedCallback(); + this._unsubAreas = subscribeAreaRegistry(this.hass.connection, (areas) => { + this._areas = areas; + }); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this._unsubAreas) { + this._unsubAreas(); + } + } + + protected render(): TemplateResult | void { + if (!this._params) { + return html``; + } + const device = this._params.device; + + return html` + +

${device.name}

+ + ${this._error + ? html` +
${this._error}
+ ` + : ""} +
+ +
+ + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.no_area" + )} + + ${this._renderAreas()} + + +
+
+
+
+ + ${this.hass.localize( + "ui.panel.config.entity_registry.editor.update" + )} + +
+ + `; + } + + private _nameChanged(ev: PolymerChangedEvent): void { + this._error = undefined; + this._nameByUser = ev.detail.value; + } + + private _renderAreas() { + if (!this._areas) { + return; + } + return this._areas!.map( + (area) => html` + ${area.name} + ` + ); + } + + private _computeSelectedArea() { + if (!this._params || !this._areas) { + return -1; + } + const device = this._params!.device; + if (!device.area_id) { + return 0; + } + // +1 because of "No Area" entry + return this._areas.findIndex((area) => area.area_id === device.area_id) + 1; + } + + private _areaIndexChanged(event): void { + const selectedAreaIdx = event.target!.selected; + this._areaId = + selectedAreaIdx < 1 + ? undefined + : this._areas![selectedAreaIdx - 1].area_id; + } + + private async _updateEntry(): Promise { + this._submitting = true; + try { + await this._params!.updateEntry({ + name_by_user: this._nameByUser.trim() || null, + area_id: this._areaId || null, + }); + this._params = undefined; + } catch (err) { + this._error = err.message || "Unknown error"; + } finally { + this._submitting = false; + } + } + + private _openedChanged(ev: PolymerChangedEvent): void { + if (!(ev.detail as any).value) { + this._params = undefined; + } + } + + static get styles(): CSSResult[] { + return [ + haStyleDialog, + css` + ha-paper-dialog { + min-width: 400px; + } + .form { + padding-bottom: 24px; + } + mwc-button.warning { + margin-right: auto; + } + .error { + color: var(--google-red-500); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-device-registry-detail": DialogDeviceRegistryDetail; + } +} diff --git a/src/panels/config/integrations/ha-device-card.js b/src/panels/config/integrations/ha-device-card.js index b886334707..345274d0cd 100644 --- a/src/panels/config/integrations/ha-device-card.js +++ b/src/panels/config/integrations/ha-device-card.js @@ -14,7 +14,16 @@ 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 { updateDeviceRegistryEntry } from "../../../data/device_registry"; +import { + subscribeDeviceRegistry, + updateDeviceRegistryEntry, +} from "../../../data/device_registry"; +import { subscribeAreaRegistry } from "../../../data/area_registry"; + +import { + showDeviceRegistryDetailDialog, + loadDeviceRegistryDetailDialog, +} from "./show-dialog-device-registry-detail"; function computeEntityName(hass, entity) { if (entity.name) return entity.name; @@ -38,6 +47,13 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) { padding-bottom: 10px; min-width: 0; } + .card-header { + display: flex; + justify-content: space-between; + } + .card-header .name { + width: 90%; + } .device { width: 30%; } @@ -45,9 +61,13 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) { font-weight: bold; } .device .model, - .device .manuf { + .device .manuf, + .device .area { color: var(--secondary-text-color); } + .area .extra-info .name { + color: var(--primary-text-color); + } .extra-info { margin-top: 8px; } @@ -57,39 +77,34 @@ class HaDeviceCard extends EventsMixin(LocalizeMixin(PolymerElement)) { padding-bottom: 4px; } .manuf, - .entity-id { + .entity-id, + .area { color: var(--secondary-text-color); } - + +
+
[[_deviceName(device)]]
+ +
-
[[device.model]]
[[localize('ui.panel.config.integrations.config_entry.manuf', 'manufacturer', device.manufacturer)]]
-
- - - - [[localize('ui.panel.config.integrations.config_entry.no_area')]] - - - - -
+