From 1b2b62f04c55db607c4c72e96312650cc1b8ff73 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 11 Aug 2018 08:45:11 +0200 Subject: [PATCH] Reorg root (#1559) * Extract element from entrypoint * Reorg root * Extract more * Lint * Extract connection * Extract notification * Lint * Also split out more info dialog * Consolidate dynamic element creation --- src/dialogs/dialog-manager.js | 23 -- src/entrypoints/app.js | 421 +----------------------- src/entrypoints/core.js | 4 +- src/layouts/app/auth-mixin.js | 25 ++ src/layouts/app/connection-mixin.js | 199 +++++++++++ src/layouts/app/dialog-manager-mixin.js | 23 ++ src/layouts/app/hass-base-mixin.js | 35 ++ src/layouts/app/home-assistant.js | 111 +++++++ src/layouts/app/more-info-mixin.js | 22 ++ src/layouts/app/notification-mixin.js | 11 + src/layouts/app/sidebar-mixin.js | 15 + src/layouts/app/themes-mixin.js | 46 +++ src/layouts/app/translations-mixin.js | 80 +++++ src/layouts/home-assistant-main.js | 2 - src/layouts/login-form.js | 12 +- src/managers/notification-manager.js | 2 +- src/util/ha-pref-storage.js | 67 ++-- 17 files changed, 604 insertions(+), 494 deletions(-) delete mode 100644 src/dialogs/dialog-manager.js create mode 100644 src/layouts/app/auth-mixin.js create mode 100644 src/layouts/app/connection-mixin.js create mode 100644 src/layouts/app/dialog-manager-mixin.js create mode 100644 src/layouts/app/hass-base-mixin.js create mode 100644 src/layouts/app/home-assistant.js create mode 100644 src/layouts/app/more-info-mixin.js create mode 100644 src/layouts/app/notification-mixin.js create mode 100644 src/layouts/app/sidebar-mixin.js create mode 100644 src/layouts/app/themes-mixin.js create mode 100644 src/layouts/app/translations-mixin.js diff --git a/src/dialogs/dialog-manager.js b/src/dialogs/dialog-manager.js deleted file mode 100644 index e26f0ddea7..0000000000 --- a/src/dialogs/dialog-manager.js +++ /dev/null @@ -1,23 +0,0 @@ -// Allows registering dialogs and makes sure they are appended to the root element. -export default (root) => { - root.addEventListener('register-dialog', (regEv) => { - let loaded = null; - - const { - dialogShowEvent, - dialogTag, - dialogImport, - } = regEv.detail; - - root.addEventListener(dialogShowEvent, (showEv) => { - if (!loaded) { - loaded = dialogImport().then(() => { - const dialogEl = document.createElement(dialogTag); - root.shadowRoot.appendChild(dialogEl); - return dialogEl; - }); - } - loaded.then(dialogEl => dialogEl.showDialog(showEv.detail)); - }); - }); -}; diff --git a/src/entrypoints/app.js b/src/entrypoints/app.js index a8de26ce7a..4cf50a382a 100644 --- a/src/entrypoints/app.js +++ b/src/entrypoints/app.js @@ -1,439 +1,24 @@ -/* eslint-disable import/first */ // Load polyfill first so HTML imports start resolving +/* eslint-disable import/first */ import '../resources/html-import/polyfill.js'; import '@polymer/app-route/app-location.js'; import '@polymer/app-route/app-route.js'; import '@polymer/iron-flex-layout/iron-flex-layout-classes.js'; import '@polymer/paper-styles/typography.js'; -import { html } from '@polymer/polymer/lib/utils/html-tag.js'; import { setPassiveTouchGestures } from '@polymer/polymer/lib/utils/settings.js'; -import { PolymerElement } from '@polymer/polymer/polymer-element.js'; -import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js'; -import LocalizeMixin from '../mixins/localize-mixin.js'; - -import { - ERR_INVALID_AUTH, - subscribeEntities, - subscribeConfig, -} from 'home-assistant-js-websocket'; - -import translationMetadata from '../../build-translations/translationMetadata.json'; -import '../layouts/home-assistant-main.js'; -import '../resources/ha-style.js'; -import '../util/ha-pref-storage.js'; -import { getActiveTranslation, getTranslation } from '../util/hass-translation.js'; import '../util/legacy-support'; import '../resources/roboto.js'; -import hassCallApi from '../util/hass-call-api.js'; -import makeDialogManager from '../dialogs/dialog-manager.js'; -import registerServiceWorker from '../util/register-service-worker.js'; -import computeStateName from '../common/entity/compute_state_name.js'; -import applyThemesOnElement from '../common/dom/apply_themes_on_element.js'; // For MDI icons. Needs to be part of main bundle or else it won't hook // properly into iron-meta, which is used to transfer iconsets to iron-icon. import '../components/ha-iconset-svg.js'; +import '../layouts/app/home-assistant.js'; + /* polyfill for paper-dropdown */ import(/* webpackChunkName: "polyfill-web-animations-next" */ 'web-animations-js/web-animations-next-lite.min.js'); -import(/* webpackChunkName: "login-form" */ '../layouts/login-form.js'); -import(/* webpackChunkName: "notification-manager" */ '../managers/notification-manager.js'); - setPassiveTouchGestures(true); /* LastPass createElement workaround. See #428 */ document.createElement = Document.prototype.createElement; - -class HomeAssistant extends LocalizeMixin(PolymerElement) { - static get template() { - return html` - - - - - - - -`; - } - - static get properties() { - return { - connectionPromise: { - type: Object, - value: window.hassConnection || null, - observer: 'handleConnectionPromise', - }, - connection: { - type: Object, - value: null, - observer: 'connectionChanged', - }, - hass: { - type: Object, - value: null, - }, - showMain: { - type: Boolean, - computed: 'computeShowMain(hass)', - }, - route: Object, - routeData: Object, - panelUrl: { - type: String, - computed: 'computePanelUrl(routeData)', - observer: 'panelUrlChanged', - }, - }; - } - - constructor() { - super(); - makeDialogManager(this); - } - - ready() { - super.ready(); - this.addEventListener('settheme', e => this.setTheme(e)); - this.addEventListener('hass-language-select', e => this.selectLanguage(e)); - this.loadResources(); - afterNextRender(null, registerServiceWorker); - } - - computeShowMain(hass) { - return hass && hass.states && hass.config && hass.panels; - } - - computeShowLoading(connectionPromise, hass) { - // Show loading when connecting or when connected but not all pieces loaded yet - return (connectionPromise != null - || (hass && hass.connection && (!hass.states || !hass.config))); - } - - async loadResources(fragment) { - const result = await getTranslation(fragment); - this._updateResources(result.language, result.data); - } - - async loadBackendTranslations() { - if (!this.hass.language) return; - - const language = this.hass.selectedLanguage || this.hass.language; - - const { resources } = await this.hass.callWS({ - type: 'frontend/get_translations', - language, - }); - - // If we've switched selected languages just ignore this response - if ((this.hass.selectedLanguage || this.hass.language) !== language) return; - - this._updateResources(language, resources); - } - - _updateResources(language, data) { - // Update the language in hass, and update the resources with the newly - // loaded resources. This merges the new data on top of the old data for - // this language, so that the full translation set can be loaded across - // multiple fragments. - this._updateHass({ - language: language, - resources: { - [language]: Object.assign({}, this.hass - && this.hass.resources && this.hass.resources[language], data), - }, - }); - } - - connectionChanged(conn, oldConn) { - if (oldConn) { - this.unsubConnection(); - this.unsubConnection = null; - } - if (!conn) { - this._updateHass({ - connection: null, - connected: false, - states: null, - config: null, - themes: null, - dockedSidebar: false, - moreInfoEntityId: null, - callService: null, - callApi: null, - sendWS: null, - callWS: null, - user: null, - }); - return; - } - var notifications = this.$.notifications; - this.hass = Object.assign({ - connection: conn, - connected: true, - states: null, - config: null, - themes: null, - panels: null, - panelUrl: this.panelUrl, - - language: getActiveTranslation(), - // If resources are already loaded, don't discard them - resources: (this.hass && this.hass.resources) || null, - - translationMetadata: translationMetadata, - dockedSidebar: false, - moreInfoEntityId: null, - callService: async (domain, service, serviceData) => { - try { - await conn.callService(domain, service, serviceData || {}); - - let message; - let name; - if (serviceData.entity_id && this.hass.states && - this.hass.states[serviceData.entity_id]) { - name = computeStateName(this.hass.states[serviceData.entity_id]); - } - if (service === 'turn_on' && serviceData.entity_id) { - message = this.localize( - 'ui.notification_toast.entity_turned_on', - 'entity', name || serviceData.entity_id - ); - } else if (service === 'turn_off' && serviceData.entity_id) { - message = this.localize( - 'ui.notification_toast.entity_turned_off', - 'entity', name || serviceData.entity_id - ); - } else { - message = this.localize( - 'ui.notification_toast.service_called', - 'service', `${domain}/${service}` - ); - } - notifications.showNotification(message); - } catch (err) { - const msg = this.localize( - 'ui.notification_toast.service_call_failed', - 'service', `${domain}/${service}` - ); - notifications.showNotification(msg); - throw err; - } - }, - callApi: async (method, path, parameters) => { - const host = window.location.protocol + '//' + window.location.host; - const auth = conn.options; - try { - // Refresh token if it will expire in 30 seconds - if (auth.accessToken && Date.now() + 30000 > auth.expires) { - const accessToken = await window.refreshToken(); - conn.options.accessToken = accessToken.access_token; - conn.options.expires = accessToken.expires; - } - return await hassCallApi(host, auth, method, path, parameters); - } catch (err) { - if (!err || err.status_code !== 401 || !auth.accessToken) throw err; - - // If we connect with access token and get 401, refresh token and try again - const accessToken = await window.refreshToken(); - conn.options.accessToken = accessToken.access_token; - conn.options.expires = accessToken.expires; - return await hassCallApi(host, auth, method, path, parameters); - } - }, - // For messages that do not get a response - sendWS: (msg) => { - // eslint-disable-next-line - if (__DEV__) console.log('Sending', msg); - conn.sendMessage(msg); - }, - // For messages that expect a response - callWS: (msg) => { - /* eslint-disable no-console */ - if (__DEV__) console.log('Sending', msg); - - const resp = conn.sendMessagePromise(msg); - - if (__DEV__) { - resp.then( - result => console.log('Received', result), - err => console.log('Error', err), - ); - } - // In the future we'll do this as a breaking change - // inside home-assistant-js-websocket - return resp.then(result => result.result); - }, - }, this.$.storage.getStoredState()); - - var reconnected = () => { - this._updateHass({ connected: true }); - this.loadBackendTranslations(); - this._loadPanels(); - }; - - const disconnected = () => { - this._updateHass({ connected: false }); - }; - - conn.addEventListener('ready', reconnected); - - // If we reconnect after losing connection and access token is no longer - // valid. - conn.addEventListener('reconnect-error', async (_conn, err) => { - if (err !== ERR_INVALID_AUTH) return; - disconnected(); - this.unsubConnection(); - const accessToken = await window.refreshToken(); - this.handleConnectionPromise(window.createHassConnection(null, accessToken)); - }); - conn.addEventListener('disconnected', disconnected); - - let unsubEntities; - - subscribeEntities(conn, (states) => { - this._updateHass({ states: states }); - }).then(function (unsub) { - unsubEntities = unsub; - }); - - let unsubConfig; - - subscribeConfig(conn, (config) => { - this._updateHass({ config: config }); - }).then(function (unsub) { - unsubConfig = unsub; - }); - - this._loadPanels(); - - let unsubThemes; - - this.hass.callWS({ - type: 'frontend/get_themes', - }).then((themes) => { - this._updateHass({ themes }); - applyThemesOnElement( - document.documentElement, - themes, - this.hass.selectedTheme, - true - ); - }); - - // only for new auth - if (conn.options.accessToken) { - this.hass.callWS({ - type: 'auth/current_user', - }).then(user => this._updateHass({ user }), () => {}); - } - - conn.subscribeEvents((event) => { - this._updateHass({ themes: event.data }); - applyThemesOnElement( - document.documentElement, - event.data, - this.hass.selectedTheme, - true - ); - }, 'themes_updated').then(function (unsub) { - unsubThemes = unsub; - }); - - this.loadBackendTranslations(); - - this.unsubConnection = function () { - conn.removeEventListener('ready', reconnected); - conn.removeEventListener('disconnected', disconnected); - unsubEntities(); - unsubConfig(); - unsubThemes(); - }; - } - - computePanelUrl(routeData) { - return (routeData && routeData.panel) || 'states'; - } - - panelUrlChanged(newPanelUrl) { - this._updateHass({ panelUrl: newPanelUrl }); - this.loadTranslationFragment(newPanelUrl); - } - - async handleConnectionPromise(prom) { - if (!prom) return; - - try { - this.connection = await prom; - } catch (err) { - this.connectionPromise = null; - } - } - - handleMoreInfo(ev) { - ev.stopPropagation(); - - this._updateHass({ moreInfoEntityId: ev.detail.entityId }); - } - - handleDockSidebar(ev) { - ev.stopPropagation(); - this._updateHass({ dockedSidebar: ev.detail.dock }); - this.$.storage.storeState(); - } - - handleNotification(ev) { - this.$.notifications.showNotification(ev.detail.message); - } - - handleLogout() { - this.connection.close(); - localStorage.clear(); - document.location = '/'; - } - - setTheme(event) { - this._updateHass({ selectedTheme: event.detail }); - applyThemesOnElement( - document.documentElement, - this.hass.themes, - this.hass.selectedTheme, - true - ); - this.$.storage.storeState(); - } - - selectLanguage(event) { - this._updateHass({ selectedLanguage: event.detail.language }); - this.$.storage.storeState(); - this.loadResources(); - this.loadBackendTranslations(); - this.loadTranslationFragment(this.panelUrl); - } - - loadTranslationFragment(panelUrl) { - if (translationMetadata.fragments.includes(panelUrl)) { - this.loadResources(panelUrl); - } - } - - async _loadPanels() { - const panels = await this.hass.callWS({ - type: 'get_panels' - }); - this._updateHass({ panels }); - } - - - _updateHass(obj) { - this.hass = Object.assign({}, this.hass, obj); - } -} - -customElements.define('home-assistant', HomeAssistant); diff --git a/src/entrypoints/core.js b/src/entrypoints/core.js index 2c1adfbf40..32f963b90b 100644 --- a/src/entrypoints/core.js +++ b/src/entrypoints/core.js @@ -37,7 +37,7 @@ function redirectLogin() { document.location = `${__PUBLIC_PATH__}authorize.html?response_type=code&client_id=${encodeURIComponent(clientId())}&redirect_uri=${encodeURIComponent(location.toString())}`; } -window.refreshToken = () => +window.refreshToken = () => (window.tokens ? refreshToken_(clientId(), window.tokens.refresh_token).then((accessTokenResp) => { window.tokens = Object.assign({}, window.tokens, accessTokenResp); localStorage.tokens = JSON.stringify(window.tokens); @@ -45,7 +45,7 @@ window.refreshToken = () => access_token: accessTokenResp.access_token, expires: window.tokens.expires }; - }, () => redirectLogin()); + }, () => redirectLogin()) : redirectLogin()); function resolveCode(code) { fetchToken(clientId(), code).then((tokens) => { diff --git a/src/layouts/app/auth-mixin.js b/src/layouts/app/auth-mixin.js new file mode 100644 index 0000000000..eafa3e35ea --- /dev/null +++ b/src/layouts/app/auth-mixin.js @@ -0,0 +1,25 @@ +import { clearState } from '../../util/ha-pref-storage.js'; + +export default superClass => class extends superClass { + ready() { + super.ready(); + this.addEventListener('hass-logout', () => this._handleLogout()); + } + + hassConnected() { + super.hassConnected(); + + // only for new auth + if (this.hass.connection.options.accessToken) { + this.hass.callWS({ + type: 'auth/current_user', + }).then(user => this._updateHass({ user }), () => {}); + } + } + + _handleLogout() { + this.hass.connection.close(); + clearState(); + document.location.href = '/'; + } +}; diff --git a/src/layouts/app/connection-mixin.js b/src/layouts/app/connection-mixin.js new file mode 100644 index 0000000000..69dba37d6e --- /dev/null +++ b/src/layouts/app/connection-mixin.js @@ -0,0 +1,199 @@ +import { + ERR_INVALID_AUTH, + subscribeEntities, + subscribeConfig, +} from 'home-assistant-js-websocket'; + +import translationMetadata from '../../../build-translations/translationMetadata.json'; + +import LocalizeMixin from '../../mixins/localize-mixin.js'; +import EventsMixin from '../../mixins/events-mixin.js'; + +import { getState } from '../../util/ha-pref-storage.js'; +import { getActiveTranslation } from '../../util/hass-translation.js'; +import hassCallApi from '../../util/hass-call-api.js'; +import computeStateName from '../../common/entity/compute_state_name.js'; + +export default superClass => + class extends EventsMixin(LocalizeMixin(superClass)) { + constructor() { + super(); + this.unsubFuncs = []; + } + + ready() { + super.ready(); + this.addEventListener('try-connection', e => + this._handleNewConnProm(e.detail.connProm)); + if (window.hassConnection) { + this._handleNewConnProm(window.hassConnection); + } + } + + async _handleNewConnProm(connProm) { + this.connectionPromise = connProm; + + let conn; + + try { + conn = await connProm; + } catch (err) { + this.connectionPromise = null; + return; + } + this._setConnection(conn); + } + + _setConnection(conn) { + this.hass = Object.assign({ + connection: conn, + connected: true, + states: null, + config: null, + themes: null, + panels: null, + panelUrl: this.panelUrl, + + language: getActiveTranslation(), + // If resources are already loaded, don't discard them + resources: (this.hass && this.hass.resources) || null, + + translationMetadata: translationMetadata, + dockedSidebar: false, + moreInfoEntityId: null, + callService: async (domain, service, serviceData = {}) => { + try { + await conn.callService(domain, service, serviceData); + + let message; + let name; + if (serviceData.entity_id && this.hass.states && + this.hass.states[serviceData.entity_id]) { + name = computeStateName(this.hass.states[serviceData.entity_id]); + } + if (service === 'turn_on' && serviceData.entity_id) { + message = this.localize( + 'ui.notification_toast.entity_turned_on', + 'entity', name || serviceData.entity_id + ); + } else if (service === 'turn_off' && serviceData.entity_id) { + message = this.localize( + 'ui.notification_toast.entity_turned_off', + 'entity', name || serviceData.entity_id + ); + } else { + message = this.localize( + 'ui.notification_toast.service_called', + 'service', `${domain}/${service}` + ); + } + this.fire('hass-notification', { message }); + } catch (err) { + const message = this.localize( + 'ui.notification_toast.service_call_failed', + 'service', `${domain}/${service}` + ); + this.fire('hass-notification', { message }); + throw err; + } + }, + callApi: async (method, path, parameters) => { + const host = window.location.protocol + '//' + window.location.host; + const auth = conn.options; + try { + // Refresh token if it will expire in 30 seconds + if (auth.accessToken && Date.now() + 30000 > auth.expires) { + const accessToken = await window.refreshToken(); + conn.options.accessToken = accessToken.access_token; + conn.options.expires = accessToken.expires; + } + return await hassCallApi(host, auth, method, path, parameters); + } catch (err) { + if (!err || err.status_code !== 401 || !auth.accessToken) throw err; + + // If we connect with access token and get 401, refresh token and try again + const accessToken = await window.refreshToken(); + conn.options.accessToken = accessToken.access_token; + conn.options.expires = accessToken.expires; + return await hassCallApi(host, auth, method, path, parameters); + } + }, + // For messages that do not get a response + sendWS: (msg) => { + // eslint-disable-next-line + if (__DEV__) console.log('Sending', msg); + conn.sendMessage(msg); + }, + // For messages that expect a response + callWS: (msg) => { + /* eslint-disable no-console */ + if (__DEV__) console.log('Sending', msg); + + const resp = conn.sendMessagePromise(msg); + + if (__DEV__) { + resp.then( + result => console.log('Received', result), + err => console.log('Error', err), + ); + } + // In the future we'll do this as a breaking change + // inside home-assistant-js-websocket + return resp.then(result => result.result); + }, + }, getState()); + + this.hassConnected(); + } + + hassConnected() { + super.hassConnected(); + + const conn = this.hass.connection; + + const reconnected = () => this.hassReconnected(); + const disconnected = () => this._updateHass({ connected: false }); + const reconnectError = async (_conn, err) => { + if (err !== ERR_INVALID_AUTH) return; + disconnected(); + while (this.unsubFuncs.length) { + this.unsubFuncs.pop()(); + } + const accessToken = await window.refreshToken(); + this._handleNewConnProm(window.createHassConnection(null, accessToken)); + }; + + conn.addEventListener('ready', reconnected); + conn.addEventListener('disconnected', disconnected); + // If we reconnect after losing connection and access token is no longer + // valid. + conn.addEventListener('reconnect-error', reconnectError); + + this.unsubFuncs.push(() => { + conn.removeEventListener('ready', reconnected); + conn.removeEventListener('disconnected', disconnected); + conn.removeEventListener('reconnect-error', reconnectError); + }); + + subscribeEntities(conn, states => this._updateHass({ states })) + .then(unsub => this.unsubFuncs.push(unsub)); + + subscribeConfig(conn, config => this._updateHass({ config })) + .then(unsub => this.unsubFuncs.push(unsub)); + + this._loadPanels(); + } + + hassReconnected() { + super.hassReconnected(); + this._updateHass({ connected: true }); + this._loadPanels(); + } + + async _loadPanels() { + const panels = await this.hass.callWS({ + type: 'get_panels' + }); + this._updateHass({ panels }); + } + }; diff --git a/src/layouts/app/dialog-manager-mixin.js b/src/layouts/app/dialog-manager-mixin.js new file mode 100644 index 0000000000..a99a5aa37f --- /dev/null +++ b/src/layouts/app/dialog-manager-mixin.js @@ -0,0 +1,23 @@ +export default superClass => + class extends superClass { + ready() { + super.ready(); + this.addEventListener('register-dialog', e => this.registerDialog(e.detail)); + } + + registerDialog({ dialogShowEvent, dialogTag, dialogImport }) { + let loaded = null; + + this.addEventListener(dialogShowEvent, (showEv) => { + if (!loaded) { + loaded = dialogImport().then(() => { + const dialogEl = document.createElement(dialogTag); + this.shadowRoot.appendChild(dialogEl); + this.provideHass(dialogEl); + return dialogEl; + }); + } + loaded.then(dialogEl => dialogEl.showDialog(showEv.detail)); + }); + } + }; diff --git a/src/layouts/app/hass-base-mixin.js b/src/layouts/app/hass-base-mixin.js new file mode 100644 index 0000000000..7572491b57 --- /dev/null +++ b/src/layouts/app/hass-base-mixin.js @@ -0,0 +1,35 @@ +/* eslint-disable no-unused-vars */ +export default superClass => class extends superClass { + constructor() { + super(); + this.__pendingHass = false; + this.__provideHass = []; + } + + // Exists so all methods can safely call super method + hassConnected() {} + hassReconnected() {} + panelUrlChanged(newPanelUrl) {} + hassChanged(hass, oldHass) { + this.__provideHass.forEach((el) => { + el.hass = hass; + }); + } + + provideHass(el) { + this.__provideHass.push(el); + } + + async _updateHass(obj) { + const oldHass = this.hass; + this.hass = Object.assign({}, this.hass, obj); + this.__pendingHass = true; + + await 0; + + if (!this.__pendingHass) return; + + this.__pendingHass = false; + this.hassChanged(this.hass, oldHass); + } +}; diff --git a/src/layouts/app/home-assistant.js b/src/layouts/app/home-assistant.js new file mode 100644 index 0000000000..716b67edeb --- /dev/null +++ b/src/layouts/app/home-assistant.js @@ -0,0 +1,111 @@ +import '@polymer/app-route/app-location.js'; +import '@polymer/app-route/app-route.js'; +import '@polymer/iron-flex-layout/iron-flex-layout-classes.js'; +import { html } from '@polymer/polymer/lib/utils/html-tag.js'; +import { PolymerElement } from '@polymer/polymer/polymer-element.js'; +import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js'; + +import '../../layouts/home-assistant-main.js'; +import '../../resources/ha-style.js'; +import registerServiceWorker from '../../util/register-service-worker.js'; + +import HassBaseMixin from './hass-base-mixin.js'; +import AuthMixin from './auth-mixin.js'; +import TranslationsMixin from './translations-mixin.js'; +import ThemesMixin from './themes-mixin.js'; +import MoreInfoMixin from './more-info-mixin.js'; +import SidebarMixin from './sidebar-mixin.js'; +import DialogManagerMixin from './dialog-manager-mixin.js'; +import ConnectionMixin from './connection-mixin.js'; +import NotificationMixin from './notification-mixin.js'; + +import(/* webpackChunkName: "login-form" */ '../../layouts/login-form.js'); + +const ext = (baseClass, mixins) => mixins.reduceRight((base, mixin) => mixin(base), baseClass); + +class HomeAssistant extends ext(PolymerElement, [ + AuthMixin, + ThemesMixin, + TranslationsMixin, + MoreInfoMixin, + SidebarMixin, + ConnectionMixin, + NotificationMixin, + DialogManagerMixin, + HassBaseMixin +]) { + static get template() { + return html` + + + + + +`; + } + + static get properties() { + return { + connectionPromise: { + type: Object, + value: null, + }, + hass: { + type: Object, + value: null, + }, + showMain: { + type: Boolean, + computed: 'computeShowMain(hass)', + }, + route: Object, + routeData: Object, + panelUrl: { + type: String, + computed: 'computePanelUrl(routeData)', + observer: 'panelUrlChanged', + }, + }; + } + + ready() { + super.ready(); + afterNextRender(null, registerServiceWorker); + } + + computeShowMain(hass) { + return hass && hass.states && hass.config && hass.panels; + } + + computeShowLoading(connectionPromise, hass) { + // Show loading when connecting or when connected but not all pieces loaded yet + return (connectionPromise != null + || (hass && hass.connection && (!hass.states || !hass.config))); + } + + computePanelUrl(routeData) { + return (routeData && routeData.panel) || 'states'; + } + + panelUrlChanged(newPanelUrl) { + super.panelUrlChanged(newPanelUrl); + this._updateHass({ panelUrl: newPanelUrl }); + } +} + +customElements.define('home-assistant', HomeAssistant); diff --git a/src/layouts/app/more-info-mixin.js b/src/layouts/app/more-info-mixin.js new file mode 100644 index 0000000000..821e42b65b --- /dev/null +++ b/src/layouts/app/more-info-mixin.js @@ -0,0 +1,22 @@ +import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js'; + +export default superClass => + class extends superClass { + ready() { + super.ready(); + this.addEventListener('hass-more-info', e => this._handleMoreInfo(e)); + + // Load it once we are having the initial rendering done. + afterNextRender(null, () => + import(/* webpackChunkName: "more-info-dialog" */ '../../dialogs/ha-more-info-dialog.js')); + } + + async _handleMoreInfo(ev) { + if (!this.__moreInfoEl) { + this.__moreInfoEl = document.createElement('ha-more-info-dialog'); + this.shadowRoot.appendChild(this.__moreInfoEl); + this.provideHass(this.__moreInfoEl); + } + this._updateHass({ moreInfoEntityId: ev.detail.entityId }); + } + }; diff --git a/src/layouts/app/notification-mixin.js b/src/layouts/app/notification-mixin.js new file mode 100644 index 0000000000..ac32352722 --- /dev/null +++ b/src/layouts/app/notification-mixin.js @@ -0,0 +1,11 @@ +export default superClass => + class extends superClass { + ready() { + super.ready(); + this.registerDialog({ + dialogShowEvent: 'hass-notification', + dialogTag: 'notification-manager', + dialogImport: () => import(/* webpackChunkName: "notification-manager" */ '../../managers/notification-manager.js'), + }); + } + }; diff --git a/src/layouts/app/sidebar-mixin.js b/src/layouts/app/sidebar-mixin.js new file mode 100644 index 0000000000..55cda0bffc --- /dev/null +++ b/src/layouts/app/sidebar-mixin.js @@ -0,0 +1,15 @@ +import { storeState } from '../../util/ha-pref-storage.js'; + +export default superClass => + class extends superClass { + ready() { + super.ready(); + this.addEventListener('hass-dock-sidebar', e => + this._handleDockSidebar(e)); + } + + _handleDockSidebar(ev) { + this._updateHass({ dockedSidebar: ev.detail.dock }); + storeState(this.hass); + } + }; diff --git a/src/layouts/app/themes-mixin.js b/src/layouts/app/themes-mixin.js new file mode 100644 index 0000000000..c35a1ff717 --- /dev/null +++ b/src/layouts/app/themes-mixin.js @@ -0,0 +1,46 @@ +import applyThemesOnElement from '../../common/dom/apply_themes_on_element.js'; +import { storeState } from '../../util/ha-pref-storage.js'; + +export default superClass => class extends superClass { + ready() { + super.ready(); + this.addEventListener('settheme', e => this._setTheme(e)); + } + + hassConnected() { + super.hassConnected(); + + this.hass.callWS({ + type: 'frontend/get_themes', + }).then((themes) => { + this._updateHass({ themes }); + applyThemesOnElement( + document.documentElement, + themes, + this.hass.selectedTheme, + true + ); + }); + + this.hass.connection.subscribeEvents((event) => { + this._updateHass({ themes: event.data }); + applyThemesOnElement( + document.documentElement, + event.data, + this.hass.selectedTheme, + true + ); + }, 'themes_updated').then(unsub => this.unsubFuncs.push(unsub)); + } + + _setTheme(event) { + this._updateHass({ selectedTheme: event.detail }); + applyThemesOnElement( + document.documentElement, + this.hass.themes, + this.hass.selectedTheme, + true + ); + storeState(this.hass); + } +}; diff --git a/src/layouts/app/translations-mixin.js b/src/layouts/app/translations-mixin.js new file mode 100644 index 0000000000..140c592120 --- /dev/null +++ b/src/layouts/app/translations-mixin.js @@ -0,0 +1,80 @@ +import translationMetadata from '../../../build-translations/translationMetadata.json'; +import { getTranslation } from '../../util/hass-translation.js'; + +import { storeState } from '../../util/ha-pref-storage.js'; + +/* + * superClass needs to contain `this.hass` and `this._updateHass`. + */ + +export default superClass => class extends superClass { + ready() { + super.ready(); + this.addEventListener('hass-language-select', e => this._selectLanguage(e)); + this._loadResources(); + } + + hassConnected() { + super.hassConnected(); + this._loadBackendTranslations(); + } + + hassReconnected() { + super.hassReconnected(); + this._loadBackendTranslations(); + } + + panelUrlChanged(newPanelUrl) { + super.panelUrlChanged(newPanelUrl); + this._loadTranslationFragment(newPanelUrl); + } + + async _loadBackendTranslations() { + if (!this.hass.language) return; + + const language = this.hass.selectedLanguage || this.hass.language; + + const { resources } = await this.hass.callWS({ + type: 'frontend/get_translations', + language, + }); + + // If we've switched selected languages just ignore this response + if ((this.hass.selectedLanguage || this.hass.language) !== language) return; + + this._updateResources(language, resources); + } + + _loadTranslationFragment(panelUrl) { + if (translationMetadata.fragments.includes(panelUrl)) { + this._loadResources(panelUrl); + } + } + + async _loadResources(fragment) { + const result = await getTranslation(fragment); + this._updateResources(result.language, result.data); + } + + _updateResources(language, data) { + // Update the language in hass, and update the resources with the newly + // loaded resources. This merges the new data on top of the old data for + // this language, so that the full translation set can be loaded across + // multiple fragments. + this._updateHass({ + language: language, + resources: { + [language]: Object.assign({}, this.hass + && this.hass.resources && this.hass.resources[language], data), + }, + }); + } + + _selectLanguage(event) { + this._updateHass({ selectedLanguage: event.detail.language }); + storeState(this.hass); + this._loadResources(); + this._loadBackendTranslations(); + this._loadTranslationFragment(this.panelUrl); + } +}; diff --git a/src/layouts/home-assistant-main.js b/src/layouts/home-assistant-main.js index d6f6752443..6f40ba42fa 100644 --- a/src/layouts/home-assistant-main.js +++ b/src/layouts/home-assistant-main.js @@ -14,7 +14,6 @@ import EventsMixin from '../mixins/events-mixin.js'; import NavigateMixin from '../mixins/navigate-mixin.js'; import(/* webpackChunkName: "ha-sidebar" */ '../components/ha-sidebar.js'); -import(/* webpackChunkName: "more-info-dialog" */ '../dialogs/ha-more-info-dialog.js'); import(/* webpackChunkName: "voice-command-dialog" */ '../dialogs/ha-voice-command-dialog.js'); const NON_SWIPABLE_PANELS = ['kiosk', 'map']; @@ -36,7 +35,6 @@ class HomeAssistantMain extends NavigateMixin(EventsMixin(PolymerElement)) { height: 100%; } - diff --git a/src/layouts/login-form.js b/src/layouts/login-form.js index 1ac2b15c05..ddaefd3a41 100644 --- a/src/layouts/login-form.js +++ b/src/layouts/login-form.js @@ -9,11 +9,12 @@ import { ERR_CANNOT_CONNECT, ERR_INVALID_AUTH } from 'home-assistant-js-websocke import LocalizeMixin from '../mixins/localize-mixin.js'; +import EventsMixin from '../mixins/events-mixin.js'; /* * @appliesMixin LocalizeMixin */ -class LoginForm extends LocalizeMixin(PolymerElement) { +class LoginForm extends EventsMixin(LocalizeMixin(PolymerElement)) { static get template() { return html` @@ -114,10 +115,6 @@ class LoginForm extends LocalizeMixin(PolymerElement) { this.addEventListener('keydown', ev => this.passwordKeyDown(ev)); } - connectedCallback() { - super.connectedCallback(); - } - computeLoadingMsg(isValidating) { return isValidating ? 'Connecting' : 'Loading data'; } @@ -150,10 +147,11 @@ class LoginForm extends LocalizeMixin(PolymerElement) { validatePassword() { var auth = this.password; this.$.hideKeyboardOnFocus.focus(); - this.connectionPromise = window.createHassConnection(auth); + const connProm = window.createHassConnection(auth); + this.fire('try-connection', { connProm }); if (this.$.rememberLogin.checked) { - this.connectionPromise.then(function () { + connProm.then(function () { localStorage.authToken = auth; }); } diff --git a/src/managers/notification-manager.js b/src/managers/notification-manager.js index a3820862df..99b754a959 100644 --- a/src/managers/notification-manager.js +++ b/src/managers/notification-manager.js @@ -84,7 +84,7 @@ class NotificationManager extends LocalizeMixin(PolymerElement) { this.$.connToast.classList.toggle('fit-bottom', ev.matches); } - showNotification(message) { + showDialog({ message }) { this.$.toast.show(message); } } diff --git a/src/util/ha-pref-storage.js b/src/util/ha-pref-storage.js index 8e103eeb57..caf23fb0fe 100644 --- a/src/util/ha-pref-storage.js +++ b/src/util/ha-pref-storage.js @@ -1,47 +1,32 @@ -import { PolymerElement } from '@polymer/polymer/polymer-element.js'; - -const STORED_STATE = [ - 'dockedSidebar', - 'selectedTheme', - 'selectedLanguage', -]; - -class HaPrefStorage extends PolymerElement { - static get properties() { - return { - hass: Object, - storage: { - type: Object, - value: window.localStorage || {}, - }, - }; - } - - storeState() { - if (!this.hass) return; - - try { - for (var i = 0; i < STORED_STATE.length; i++) { - var key = STORED_STATE[i]; - var value = this.hass[key]; - this.storage[key] = JSON.stringify(value === undefined ? null : value); - } - } catch (err) { - // Safari throws exception in private mode - } - } - - getStoredState() { - var state = {}; +const STORED_STATE = ['dockedSidebar', 'selectedTheme', 'selectedLanguage']; +const STORAGE = window.localStorage || {}; +export function storeState(hass) { + try { for (var i = 0; i < STORED_STATE.length; i++) { var key = STORED_STATE[i]; - if (key in this.storage) { - state[key] = JSON.parse(this.storage[key]); - } + var value = hass[key]; + STORAGE[key] = JSON.stringify(value === undefined ? null : value); } - - return state; + } catch (err) { + // Safari throws exception in private mode } } -customElements.define('ha-pref-storage', HaPrefStorage); + +export function getState() { + var state = {}; + + for (var i = 0; i < STORED_STATE.length; i++) { + var key = STORED_STATE[i]; + if (key in STORAGE) { + state[key] = JSON.parse(STORAGE[key]); + } + } + + return state; +} + +export function clearState() { + // STORAGE is an object if localStorage not available. + if (STORAGE.clear) STORAGE.clear(); +}