diff --git a/build-scripts/gulp/service-worker.js b/build-scripts/gulp/service-worker.js index dd46b6f1d2..01e7a97323 100644 --- a/build-scripts/gulp/service-worker.js +++ b/build-scripts/gulp/service-worker.js @@ -19,6 +19,8 @@ gulp.task("gen-service-worker-app-dev", (done) => { console.debug('Service worker disabled in development'); self.addEventListener('install', (event) => { + // This will activate the dev service worker, + // removing any prod service worker the dev might have running self.skipWaiting(); }); ` @@ -27,6 +29,28 @@ self.addEventListener('install', (event) => { }); gulp.task("gen-service-worker-app-prod", async () => { + // Read bundled source file + const bundleManifestLatest = require(path.resolve( + paths.output, + "manifest.json" + )); + let serviceWorkerContent = fs.readFileSync( + paths.root + bundleManifestLatest["service_worker.js"], + "utf-8" + ); + + // Delete old file from frontend_latest so manifest won't pick it up + fs.removeSync(paths.root + bundleManifestLatest["service_worker.js"]); + fs.removeSync(paths.root + bundleManifestLatest["service_worker.js.map"]); + + // Remove ES5 + const bundleManifestES5 = require(path.resolve( + paths.output_es5, + "manifest.json" + )); + fs.removeSync(paths.root + bundleManifestES5["service_worker.js"]); + fs.removeSync(paths.root + bundleManifestES5["service_worker.js.map"]); + const workboxManifest = await workboxBuild.getManifest({ // Files that mach this pattern will be considered unique and skip revision check // ignore JS files + translation files @@ -37,7 +61,8 @@ gulp.task("gen-service-worker-app-prod", async () => { "frontend_latest/*.js", // Cache all English translations because we catch them as fallback // Using pattern to match hash instead of * to avoid caching en-GB - "static/translations/**/en-+([a-f0-9]).json", + // 'v' added as valid hash letter because in dev we hash with 'dev' + "static/translations/**/en-+([a-fv0-9]).json", // Icon shown on splash screen "static/icons/favicon-192x192.png", "static/icons/favicon.ico", @@ -53,20 +78,6 @@ gulp.task("gen-service-worker-app-prod", async () => { console.warn(warning); } - // Replace `null` with 0 for better compression - for (const entry of workboxManifest.manifestEntries) { - if (entry.revision === null) { - entry.revision = 0; - } - } - - const manifest = require(path.resolve(paths.output, "manifest.json")); - - // Write bundled source file - let serviceWorkerContent = fs.readFileSync( - paths.root + manifest["service_worker.js"], - "utf-8" - ); // remove source map and add WB manifest serviceWorkerContent = sourceMapUrl.removeFrom(serviceWorkerContent); serviceWorkerContent = serviceWorkerContent.replace( @@ -76,8 +87,4 @@ gulp.task("gen-service-worker-app-prod", async () => { // Write new file to root fs.writeFileSync(swDest, serviceWorkerContent); - - // Delete old file from frontend_latest - fs.removeSync(paths.root + manifest["service_worker.js"]); - fs.removeSync(paths.root + manifest["service_worker.js.map"]); }); diff --git a/build-scripts/webpack.js b/build-scripts/webpack.js index cdd7c4eb06..5cbb12bfea 100644 --- a/build-scripts/webpack.js +++ b/build-scripts/webpack.js @@ -20,7 +20,9 @@ const createWebpackConfig = ({ } return { mode: isProdBuild ? "production" : "development", - devtool: isProdBuild ? "source-map" : "inline-cheap-module-source-map", + devtool: isProdBuild + ? "cheap-module-source-map" + : "eval-cheap-module-source-map", entry, module: { rules: [ @@ -74,6 +76,10 @@ const createWebpackConfig = ({ /@polymer\/font-roboto\/roboto\.js$/, path.resolve(paths.polymer_dir, "src/util/empty.js") ), + new webpack.NormalModuleReplacementPlugin( + /@vaadin\/vaadin-material-styles\/font-roboto\.js$/, + path.resolve(paths.polymer_dir, "src/util/empty.js") + ), // Ignore mwc icons pointing at CDN. new webpack.NormalModuleReplacementPlugin( /@material\/mwc-icon\/mwc-icon-font\.js$/, diff --git a/cast/src/launcher/layout/hc-connect.ts b/cast/src/launcher/layout/hc-connect.ts index 9660f4755a..048a69f403 100644 --- a/cast/src/launcher/layout/hc-connect.ts +++ b/cast/src/launcher/layout/hc-connect.ts @@ -184,7 +184,7 @@ export class HcConnect extends LitElement { this.castManager = null; } ); - registerServiceWorker(false); + registerServiceWorker(this, false); } private async _handleDemo() { diff --git a/demo/script/size_stats b/demo/script/size_stats index 3afbe6c9a0..6d785f36b3 100755 --- a/demo/script/size_stats +++ b/demo/script/size_stats @@ -7,5 +7,5 @@ set -e cd "$(dirname "$0")/.." STATS=1 NODE_ENV=production ../node_modules/.bin/webpack --profile --json > compilation-stats.json -npx webpack-bundle-analyzer compilation-stats.json dist +npx webpack-bundle-analyzer compilation-stats.json dist/frontend_latest rm compilation-stats.json diff --git a/setup.py b/setup.py index 579608a0b0..ede0929337 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="home-assistant-frontend", - version="20200513.0", + version="20200514.0", description="The Home Assistant frontend", url="https://github.com/home-assistant/home-assistant-polymer", author="The Home Assistant Authors", diff --git a/src/auth/ha-authorize.ts b/src/auth/ha-authorize.ts index f811a21186..db9023efab 100644 --- a/src/auth/ha-authorize.ts +++ b/src/auth/ha-authorize.ts @@ -121,7 +121,7 @@ class HaAuthorize extends litLocalizeLiteMixin(LitElement) { const tempA = document.createElement("a"); tempA.href = this.redirectUri!; if (tempA.host === location.host) { - registerServiceWorker(false); + registerServiceWorker(this, false); } } diff --git a/src/components/ha-cover-controls.js b/src/components/ha-cover-controls.js index 781cc3c5b3..3cf69923d3 100644 --- a/src/components/ha-cover-controls.js +++ b/src/components/ha-cover-controls.js @@ -30,7 +30,7 @@ class HaCoverControls extends PolymerElement { icon="hass:stop" on-click="onStopTap" invisible$="[[!entityObj.supportsStop]]" - disabled="[[computStopDisabled(stateObj)]]" + disabled="[[computeStopDisabled(stateObj)]]" > { event.waitUntil(caches.delete(cacheName)); }); +self.addEventListener("activate", () => { + // Attach the service worker to any page of the app + // that didn't have a service worker loaded. + // Happens the first time they open the app without any + // service worker registered. + // This will serve code splitted bundles from SW. + clients.claim(); +}); + self.addEventListener("message", (message) => { if (message.data.type === "skipWaiting") { self.skipWaiting(); - clients.claim(); } }); diff --git a/src/layouts/home-assistant.ts b/src/layouts/home-assistant.ts index c8e0f87f90..c9594d1d54 100644 --- a/src/layouts/home-assistant.ts +++ b/src/layouts/home-assistant.ts @@ -46,7 +46,7 @@ export class HomeAssistantAppEl extends HassElement { protected firstUpdated(changedProps) { super.firstUpdated(changedProps); this._initialize(); - setTimeout(registerServiceWorker, 1000); + setTimeout(() => registerServiceWorker(this), 1000); /* polyfill for paper-dropdown */ import( /* webpackChunkName: "polyfill-web-animations-next" */ "web-animations-js/web-animations-next-lite.min" diff --git a/src/onboarding/ha-onboarding.ts b/src/onboarding/ha-onboarding.ts index 414731adf7..fee79c3a78 100644 --- a/src/onboarding/ha-onboarding.ts +++ b/src/onboarding/ha-onboarding.ts @@ -96,7 +96,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) { import( /* webpackChunkName: "onboarding-core-config" */ "./onboarding-core-config" ); - registerServiceWorker(false); + registerServiceWorker(this, false); this.addEventListener("onboarding-step", (ev) => this._handleStepDone(ev)); } diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index 9fb6aa140d..2b6268ca59 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -14,10 +14,7 @@ import memoizeOne from "memoize-one"; import * as Fuse from "fuse.js"; import { caseInsensitiveCompare } from "../../../common/string/compare"; import { computeRTL } from "../../../common/util/compute_rtl"; -import { - afterNextRender, - nextRender, -} from "../../../common/util/render-status"; +import { nextRender } from "../../../common/util/render-status"; import "../../../components/entity/ha-state-icon"; import "../../../components/ha-card"; import "@material/mwc-fab"; @@ -46,6 +43,7 @@ import { domainToName } from "../../../data/integration"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-tabs-subpage"; +import "../../../layouts/hass-loading-screen"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; import { HomeAssistant, Route } from "../../../types"; @@ -96,7 +94,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { @property() public route!: Route; - @property() private _configEntries: ConfigEntryExtended[] = []; + @property() private _configEntries?: ConfigEntryExtended[]; @property() private _configEntriesInProgress: DataEntryFlowProgressExtended[] = []; @@ -217,32 +215,17 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { if ( this._searchParms.has("config_entry") && changed.has("_configEntries") && - !(changed.get("_configEntries") as ConfigEntry[]).length && - this._configEntries.length + !changed.get("_configEntries") && + this._configEntries ) { - afterNextRender(() => { - const entryId = this._searchParms.get("config_entry")!; - const configEntry = this._configEntries.find( - (entry) => entry.entry_id === entryId - ); - if (!configEntry) { - return; - } - const card: HaIntegrationCard = this.shadowRoot!.querySelector( - `[data-domain=${configEntry?.domain}]` - ) as HaIntegrationCard; - if (card) { - card.scrollIntoView({ - block: "center", - }); - card.classList.add("highlight"); - card.selectedConfigEntryId = entryId; - } - }); + this._highlightEntry(); } } protected render(): TemplateResult { + if (!this._configEntries) { + return html``; + } const [ groupedConfigEntries, ignoredConfigEntries, @@ -428,7 +411,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {

${this.hass.localize( - "ui.panel.config.integrations.add" + "ui.panel.config.integrations.add_integration" )} @@ -491,7 +474,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { } private _handleRemoved(ev: HASSDomEvent) { - this._configEntries = this._configEntries.filter( + this._configEntries = this._configEntries!.filter( (entry) => entry.entry_id !== ev.detail.entryId ); } @@ -594,6 +577,27 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { ev.target.style.visibility = "hidden"; } + private async _highlightEntry() { + await nextRender(); + const entryId = this._searchParms.get("config_entry")!; + const configEntry = this._configEntries!.find( + (entry) => entry.entry_id === entryId + ); + if (!configEntry) { + return; + } + const card: HaIntegrationCard = this.shadowRoot!.querySelector( + `[data-domain=${configEntry?.domain}]` + ) as HaIntegrationCard; + if (card) { + card.scrollIntoView({ + block: "center", + }); + card.classList.add("highlight"); + card.selectedConfigEntryId = entryId; + } + } + static get styles(): CSSResult[] { return [ haStyle, diff --git a/src/panels/config/integrations/ha-integration-card.ts b/src/panels/config/integrations/ha-integration-card.ts index 2ff65084fe..b0af490a74 100644 --- a/src/panels/config/integrations/ha-integration-card.ts +++ b/src/panels/config/integrations/ha-integration-card.ts @@ -93,7 +93,11 @@ export class HaIntegrationCard extends LitElement { html`${item.title}${item.title || + this.hass.localize( + "ui.panel.config.integrations.config_entry.unnamed_entry" + )}` )} diff --git a/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts b/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts index b8616528af..8bdbbd67a9 100644 --- a/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts +++ b/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts @@ -1,51 +1,407 @@ +import "@material/mwc-button"; +import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; +import "../../../../components/ha-icon-button"; +import "@polymer/paper-spinner/paper-spinner"; +import "@polymer/paper-tabs/paper-tab"; +import "@polymer/paper-tabs/paper-tabs"; import { + css, + CSSResult, customElement, html, LitElement, property, TemplateResult, } from "lit-element"; -import { HASSDomEvent } from "../../../../common/dom/fire_event"; -import { HomeAssistant } from "../../../../types"; -import "./hui-edit-view"; +import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event"; +import { navigate } from "../../../../common/navigate"; +import "../../../../components/dialog/ha-paper-dialog"; +import type { HaPaperDialog } from "../../../../components/dialog/ha-paper-dialog"; +import type { + LovelaceBadgeConfig, + LovelaceCardConfig, + LovelaceViewConfig, +} from "../../../../data/lovelace"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../../dialogs/generic/show-dialog-box"; +import { haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import "../../components/hui-entity-editor"; +import { addView, deleteView, replaceView } from "../config-util"; +import "../hui-badge-preview"; +import { processEditorEntities } from "../process-editor-entities"; +import { + EntitiesEditorEvent, + ViewEditEvent, + ViewVisibilityChangeEvent, +} from "../types"; +import "./hui-view-editor"; +import "./hui-view-visibility-editor"; import { EditViewDialogParams } from "./show-edit-view-dialog"; -declare global { - // for fire event - interface HASSDomEvents { - "reload-lovelace": undefined; - } - // for add event listener - interface HTMLElementEventMap { - "reload-lovelace": HASSDomEvent; - } -} - @customElement("hui-dialog-edit-view") export class HuiDialogEditView extends LitElement { - @property() protected hass?: HomeAssistant; + @property() public hass?: HomeAssistant; @property() private _params?: EditViewDialogParams; + @property() private _config?: LovelaceViewConfig; + + @property() private _badges?: LovelaceBadgeConfig[]; + + @property() private _cards?: LovelaceCardConfig[]; + + @property() private _saving = false; + + @property() private _curTab?: string; + + private _curTabIndex = 0; + public async showDialog(params: EditViewDialogParams): Promise { + // Wait till dialog is rendered. this._params = params; - await this.updateComplete; - (this.shadowRoot!.children[0] as any).showDialog(); + + if (this._dialog == null) { + await this.updateComplete; + } + + if (this._params.viewIndex === undefined) { + this._config = {}; + this._badges = []; + this._cards = []; + } else { + const { + cards, + badges, + ...viewConfig + } = this._params.lovelace!.config.views[this._params.viewIndex]; + this._config = viewConfig; + this._badges = badges ? processEditorEntities(badges) : []; + this._cards = cards; + } + + this._dialog.open(); + } + + private get _dialog(): HaPaperDialog { + return this.shadowRoot!.querySelector("ha-paper-dialog")!; + } + + private get _viewConfigTitle(): string { + if (!this._config || !this._config.title) { + return this.hass!.localize("ui.panel.lovelace.editor.edit_view.header"); + } + + return this.hass!.localize( + "ui.panel.lovelace.editor.edit_view.header_name", + "name", + this._config.title + ); } protected render(): TemplateResult { if (!this._params) { return html``; } + + let content; + switch (this._curTab) { + case "tab-settings": + content = html` + + `; + break; + case "tab-badges": + content = html` + ${this._badges?.length + ? html` +
+ ${this._badges.map((badgeConfig) => { + return html` + + `; + })} +
+ ` + : ""} + + `; + break; + case "tab-visibility": + content = html` + + `; + break; + case "tab-cards": + content = html` Cards `; + break; + } return html` - - + +

+ ${this._viewConfigTitle} +

+ + ${this.hass!.localize( + "ui.panel.lovelace.editor.edit_view.tab_settings" + )} + ${this.hass!.localize( + "ui.panel.lovelace.editor.edit_view.tab_badges" + )} + ${this.hass!.localize( + "ui.panel.lovelace.editor.edit_view.tab_visibility" + )} + + ${content} +
+ ${this._params.viewIndex !== undefined + ? html` + + ${this.hass!.localize( + "ui.panel.lovelace.editor.edit_view.delete" + )} + + ` + : ""} + ${this.hass!.localize("ui.common.cancel")} + + + ${this.hass!.localize("ui.common.save")} +
+
`; } + + private async _delete(): Promise { + if (!this._params) { + return; + } + try { + await this._params.lovelace!.saveConfig( + deleteView(this._params.lovelace!.config, this._params.viewIndex!) + ); + this._closeDialog(); + navigate(this, `/${window.location.pathname.split("/")[1]}`); + } catch (err) { + showAlertDialog(this, { + text: `Deleting failed: ${err.message}`, + }); + } + } + + private _deleteConfirm(): void { + showConfirmationDialog(this, { + title: this.hass!.localize( + `ui.panel.lovelace.views.confirm_delete${ + this._cards?.length ? `_existing_cards` : "" + }` + ), + text: this.hass!.localize( + `ui.panel.lovelace.views.confirm_delete${ + this._cards?.length ? `_existing_cards` : "" + }_text`, + "name", + this._config?.title || "Unnamed view", + "number", + this._cards?.length || 0 + ), + confirm: () => this._delete(), + }); + } + + private async _resizeDialog(): Promise { + await this.updateComplete; + fireEvent(this._dialog as HTMLElement, "iron-resize"); + } + + private _closeDialog(): void { + this._curTabIndex = 0; + this._params = undefined; + this._config = {}; + this._badges = []; + this._dialog.close(); + } + + private _handleTabSelected(ev: CustomEvent): void { + if (!ev.detail.value) { + return; + } + this._curTab = ev.detail.value.id; + this._resizeDialog(); + } + + private async _save(): Promise { + if (!this._params || !this._config) { + return; + } + if (!this._isConfigChanged()) { + this._closeDialog(); + return; + } + + this._saving = true; + + const viewConf: LovelaceViewConfig = { + ...this._config, + badges: this._badges, + cards: this._cards, + }; + + const lovelace = this._params.lovelace!; + + try { + await lovelace.saveConfig( + this._creatingView + ? addView(lovelace.config, viewConf) + : replaceView(lovelace.config, this._params.viewIndex!, viewConf) + ); + if (this._params.saveCallback) { + this._params.saveCallback( + this._params.viewIndex || lovelace.config.views.length, + viewConf + ); + } + this._closeDialog(); + } catch (err) { + showAlertDialog(this, { + text: `Saving failed: ${err.message}`, + }); + } finally { + this._saving = false; + } + } + + private _viewConfigChanged(ev: ViewEditEvent): void { + if (ev.detail && ev.detail.config) { + this._config = ev.detail.config; + } + } + + private _viewVisibilityChanged( + ev: HASSDomEvent + ): void { + if (ev.detail.visible && this._config) { + this._config.visible = ev.detail.visible; + } + } + + private _badgesChanged(ev: EntitiesEditorEvent): void { + if (!this._badges || !this.hass || !ev.detail || !ev.detail.entities) { + return; + } + this._badges = processEditorEntities(ev.detail.entities); + this._resizeDialog(); + } + + private _isConfigChanged(): boolean { + return ( + this._creatingView || + JSON.stringify(this._config) !== + JSON.stringify( + this._params!.lovelace!.config.views[this._params!.viewIndex!] + ) + ); + } + + private get _creatingView(): boolean { + return this._params!.viewIndex === undefined; + } + + static get styles(): CSSResult[] { + return [ + haStyleDialog, + css` + @media all and (max-width: 450px), all and (max-height: 500px) { + /* overrule the ha-style-dialog max-height on small screens */ + ha-paper-dialog { + max-height: 100%; + height: 100%; + } + } + @media all and (min-width: 660px) { + ha-paper-dialog { + width: 650px; + } + } + ha-paper-dialog { + max-width: 650px; + } + paper-tabs { + --paper-tabs-selection-bar-color: var(--primary-color); + text-transform: uppercase; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + mwc-button paper-spinner { + width: 14px; + height: 14px; + margin-right: 20px; + } + mwc-button.warning { + margin-right: auto; + } + paper-spinner { + display: none; + } + paper-spinner[active] { + display: block; + } + paper-dialog-scrollable { + margin-top: 0; + } + .hidden { + display: none; + } + .error { + color: var(--error-color); + border-bottom: 1px solid var(--error-color); + } + .preview-badges { + display: flex; + justify-content: center; + margin: 12px 16px; + flex-wrap: wrap; + } + `, + ]; + } } declare global { diff --git a/src/panels/lovelace/editor/view-editor/hui-edit-view.ts b/src/panels/lovelace/editor/view-editor/hui-edit-view.ts deleted file mode 100644 index 2d8ef67175..0000000000 --- a/src/panels/lovelace/editor/view-editor/hui-edit-view.ts +++ /dev/null @@ -1,400 +0,0 @@ -import "@material/mwc-button"; -import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; -import "../../../../components/ha-icon-button"; -import "@polymer/paper-spinner/paper-spinner"; -import "@polymer/paper-tabs/paper-tab"; -import "@polymer/paper-tabs/paper-tabs"; -import { - css, - CSSResult, - customElement, - html, - LitElement, - property, - TemplateResult, -} from "lit-element"; -import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event"; -import { navigate } from "../../../../common/navigate"; -import "../../../../components/dialog/ha-paper-dialog"; -import type { HaPaperDialog } from "../../../../components/dialog/ha-paper-dialog"; -import type { - LovelaceBadgeConfig, - LovelaceCardConfig, - LovelaceViewConfig, -} from "../../../../data/lovelace"; -import { - showAlertDialog, - showConfirmationDialog, -} from "../../../../dialogs/generic/show-dialog-box"; -import { haStyleDialog } from "../../../../resources/styles"; -import type { HomeAssistant } from "../../../../types"; -import "../../components/hui-entity-editor"; -import type { Lovelace } from "../../types"; -import { addView, deleteView, replaceView } from "../config-util"; -import "../hui-badge-preview"; -import { processEditorEntities } from "../process-editor-entities"; -import { - EntitiesEditorEvent, - ViewEditEvent, - ViewVisibilityChangeEvent, -} from "../types"; -import "./hui-view-editor"; -import "./hui-view-visibility-editor"; - -@customElement("hui-edit-view") -export class HuiEditView extends LitElement { - @property() public lovelace?: Lovelace; - - @property() public viewIndex?: number; - - @property() public hass?: HomeAssistant; - - @property() private _config?: LovelaceViewConfig; - - @property() private _badges?: LovelaceBadgeConfig[]; - - @property() private _cards?: LovelaceCardConfig[]; - - @property() private _saving: boolean; - - @property() private _curTab?: string; - - private _curTabIndex: number; - - public constructor() { - super(); - this._saving = false; - this._curTabIndex = 0; - } - - public async showDialog(): Promise { - // Wait till dialog is rendered. - if (this._dialog == null) { - await this.updateComplete; - } - - if (this.viewIndex === undefined) { - this._config = {}; - this._badges = []; - this._cards = []; - } else { - const { cards, badges, ...viewConfig } = this.lovelace!.config.views[ - this.viewIndex - ]; - this._config = viewConfig; - this._badges = badges ? processEditorEntities(badges) : []; - this._cards = cards; - } - - this._dialog.open(); - } - - private get _dialog(): HaPaperDialog { - return this.shadowRoot!.querySelector("ha-paper-dialog")!; - } - - private get _viewConfigTitle(): string { - if (!this._config || !this._config.title) { - return this.hass!.localize("ui.panel.lovelace.editor.edit_view.header"); - } - - return this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.header_name", - "name", - this._config.title - ); - } - - protected render(): TemplateResult { - let content; - switch (this._curTab) { - case "tab-settings": - content = html` - - `; - break; - case "tab-badges": - content = html` - ${this._badges?.length - ? html` -
- ${this._badges.map((badgeConfig) => { - return html` - - `; - })} -
- ` - : ""} - - `; - break; - case "tab-visibility": - content = html` - - `; - break; - case "tab-cards": - content = html` Cards `; - break; - } - return html` - -

- ${this._viewConfigTitle} -

- - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.tab_settings" - )} - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.tab_badges" - )} - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.tab_visibility" - )} - - ${content} -
- ${this.viewIndex !== undefined - ? html` - - ${this.hass!.localize( - "ui.panel.lovelace.editor.edit_view.delete" - )} - - ` - : ""} - ${this.hass!.localize("ui.common.cancel")} - - - ${this.hass!.localize("ui.common.save")} -
-
- `; - } - - private async _delete(): Promise { - try { - await this.lovelace!.saveConfig( - deleteView(this.lovelace!.config, this.viewIndex!) - ); - this._closeDialog(); - navigate(this, `/${window.location.pathname.split("/")[1]}`); - } catch (err) { - showAlertDialog(this, { - text: `Deleting failed: ${err.message}`, - }); - } - } - - private _deleteConfirm(): void { - showConfirmationDialog(this, { - title: this.hass!.localize( - `ui.panel.lovelace.views.confirm_delete${ - this._cards?.length ? `_existing_cards` : "" - }` - ), - text: this.hass!.localize( - `ui.panel.lovelace.views.confirm_delete${ - this._cards?.length ? `_existing_cards` : "" - }_text`, - "name", - this._config?.title || "Unnamed view", - "number", - this._cards?.length || 0 - ), - confirm: () => this._delete(), - }); - } - - private async _resizeDialog(): Promise { - await this.updateComplete; - fireEvent(this._dialog as HTMLElement, "iron-resize"); - } - - private _closeDialog(): void { - this._curTabIndex = 0; - this.lovelace = undefined; - this._config = {}; - this._badges = []; - this._dialog.close(); - } - - private _handleTabSelected(ev: CustomEvent): void { - if (!ev.detail.value) { - return; - } - this._curTab = ev.detail.value.id; - this._resizeDialog(); - } - - private async _save(): Promise { - if (!this._config) { - return; - } - if (!this._isConfigChanged()) { - this._closeDialog(); - return; - } - - this._saving = true; - - const viewConf: LovelaceViewConfig = { - ...this._config, - badges: this._badges, - cards: this._cards, - }; - - const lovelace = this.lovelace!; - - try { - await lovelace.saveConfig( - this._creatingView - ? addView(lovelace.config, viewConf) - : replaceView(lovelace.config, this.viewIndex!, viewConf) - ); - this._closeDialog(); - } catch (err) { - showAlertDialog(this, { - text: `Saving failed: ${err.message}`, - }); - } finally { - this._saving = false; - } - } - - private _viewConfigChanged(ev: ViewEditEvent): void { - if (ev.detail && ev.detail.config) { - this._config = ev.detail.config; - } - } - - private _viewVisibilityChanged( - ev: HASSDomEvent - ): void { - if (ev.detail.visible && this._config) { - this._config.visible = ev.detail.visible; - } - } - - private _badgesChanged(ev: EntitiesEditorEvent): void { - if (!this._badges || !this.hass || !ev.detail || !ev.detail.entities) { - return; - } - this._badges = processEditorEntities(ev.detail.entities); - this._resizeDialog(); - } - - private _isConfigChanged(): boolean { - return ( - this._creatingView || - JSON.stringify(this._config) !== - JSON.stringify(this.lovelace!.config.views[this.viewIndex!]) - ); - } - - private get _creatingView(): boolean { - return this.viewIndex === undefined; - } - - static get styles(): CSSResult[] { - return [ - haStyleDialog, - css` - @media all and (max-width: 450px), all and (max-height: 500px) { - /* overrule the ha-style-dialog max-height on small screens */ - ha-paper-dialog { - max-height: 100%; - height: 100%; - } - } - @media all and (min-width: 660px) { - ha-paper-dialog { - width: 650px; - } - } - ha-paper-dialog { - max-width: 650px; - } - paper-tabs { - --paper-tabs-selection-bar-color: var(--primary-color); - text-transform: uppercase; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - } - mwc-button paper-spinner { - width: 14px; - height: 14px; - margin-right: 20px; - } - mwc-button.warning { - margin-right: auto; - } - paper-spinner { - display: none; - } - paper-spinner[active] { - display: block; - } - paper-dialog-scrollable { - margin-top: 0; - } - .hidden { - display: none; - } - .error { - color: var(--error-color); - border-bottom: 1px solid var(--error-color); - } - .preview-badges { - display: flex; - justify-content: center; - margin: 12px 16px; - flex-wrap: wrap; - } - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "hui-edit-view": HuiEditView; - } -} diff --git a/src/panels/lovelace/editor/view-editor/show-edit-view-dialog.ts b/src/panels/lovelace/editor/view-editor/show-edit-view-dialog.ts index c752aa3eb2..00d2153fb9 100644 --- a/src/panels/lovelace/editor/view-editor/show-edit-view-dialog.ts +++ b/src/panels/lovelace/editor/view-editor/show-edit-view-dialog.ts @@ -1,5 +1,6 @@ import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event"; import { Lovelace } from "../../types"; +import { LovelaceViewConfig } from "../../../../data/lovelace"; declare global { // for fire event @@ -20,6 +21,7 @@ const dialogTag = "hui-dialog-edit-view"; export interface EditViewDialogParams { lovelace: Lovelace; viewIndex?: number; + saveCallback?: (viewIndex: number, viewConfig: LovelaceViewConfig) => void; } const registerEditViewDialog = (element: HTMLElement): Event => diff --git a/src/panels/lovelace/entity-rows/hui-sensor-entity-row.ts b/src/panels/lovelace/entity-rows/hui-sensor-entity-row.ts index a842909823..e59f9028e8 100644 --- a/src/panels/lovelace/entity-rows/hui-sensor-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-sensor-entity-row.ts @@ -15,9 +15,14 @@ import { hasConfigOrEntityChanged } from "../common/has-changed"; import "../components/hui-generic-entity-row"; import "../components/hui-timestamp-display"; import "../components/hui-warning"; -import { EntityConfig, LovelaceRow } from "./types"; +import { LovelaceRow } from "./types"; +import { EntitiesCardEntityConfig } from "../cards/types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { hasAction } from "../common/has-action"; +import { ActionHandlerEvent } from "../../../data/lovelace"; +import { handleAction } from "../common/handle-action"; -interface SensorEntityConfig extends EntityConfig { +interface SensorEntityConfig extends EntitiesCardEntityConfig { format?: "relative" | "date" | "time" | "datetime"; } @@ -59,7 +64,14 @@ class HuiSensorEntityRow extends LitElement implements LovelaceRow { return html` -
+
${stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP && stateObj.state !== "unavailable" && @@ -81,6 +93,10 @@ class HuiSensorEntityRow extends LitElement implements LovelaceRow { `; } + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action); + } + static get styles(): CSSResult { return css` div { diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index e801a0c751..3b1ba96e00 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -31,7 +31,11 @@ import "../../components/ha-icon"; import "../../components/ha-menu-button"; import "../../components/ha-icon-button-arrow-next"; import "../../components/ha-icon-button-arrow-prev"; -import type { LovelaceConfig, LovelacePanelConfig } from "../../data/lovelace"; +import type { + LovelaceConfig, + LovelacePanelConfig, + LovelaceViewConfig, +} from "../../data/lovelace"; import { showAlertDialog, showConfirmationDialog, @@ -424,18 +428,24 @@ class HUIRoot extends LitElement { } if (!oldLovelace || oldLovelace.editMode !== this.lovelace!.editMode) { + const views = this.config && this.config.views; + + // Adjust for higher header + if (!views || views.length < 2) { + fireEvent(this, "iron-resize"); + } + // Leave unused entities when leaving edit mode if ( this.lovelace!.mode === "storage" && viewPath === "hass-unused-entities" ) { - const views = this.config && this.config.views; navigate(this, `${this.route?.prefix}/${views[0]?.path || 0}`); newSelectView = 0; } } - if (!force) { + if (!force && huiView) { huiView.lovelace = this.lovelace; } } @@ -552,6 +562,10 @@ class HUIRoot extends LitElement { private _addView() { showEditViewDialog(this, { lovelace: this.lovelace!, + saveCallback: (viewIndex: number, viewConfig: LovelaceViewConfig) => { + const path = viewConfig.path || viewIndex; + navigate(this, `${this.route?.prefix}/${path}`); + }, }); } diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index dd5511eac3..0799e1c7ea 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -34,7 +34,6 @@ export interface LovelaceCard extends HTMLElement { hass?: HomeAssistant; isPanel?: boolean; editMode?: boolean; - index?: number; getCardSize(): number; setConfig(config: LovelaceCardConfig): void; } diff --git a/src/panels/lovelace/views/hui-view.ts b/src/panels/lovelace/views/hui-view.ts index a3b9d1e8ba..f09529e1c0 100644 --- a/src/panels/lovelace/views/hui-view.ts +++ b/src/panels/lovelace/views/hui-view.ts @@ -26,6 +26,7 @@ import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog" import { Lovelace, LovelaceBadge, LovelaceCard } from "../types"; import "../../../components/ha-svg-icon"; import { mdiPlus } from "@mdi/js"; +import { nextRender } from "../../../common/util/render-status"; let editCodeLoaded = false; @@ -60,6 +61,13 @@ export class HUIView extends LitElement { @property() private _badges: LovelaceBadge[] = []; + private _createColumnsIteration = 0; + + public constructor() { + super(); + this.addEventListener("iron-resize", (ev) => ev.stopPropagation()); + } + // Public to make demo happy public createCardElement(cardConfig: LovelaceCardConfig) { const element = createCardElement(cardConfig) as LovelaceCard; @@ -148,9 +156,7 @@ export class HUIView extends LitElement { if (configChanged) { this._createCards(lovelace.config.views[this.index!]); - } else if (editModeChanged) { - this._switchEditMode(); - } else if (changedProperties.has("columns")) { + } else if (editModeChanged || changedProperties.has("columns")) { this._recreateColumns(); } @@ -211,62 +217,75 @@ export class HUIView extends LitElement { root.style.display = elements.length > 0 ? "block" : "none"; } - private _switchEditMode() { - if (this.lovelace!.editMode) { - const wrappedCards = this._cards.map((element) => { - const wrapper = document.createElement("hui-card-options"); - wrapper.hass = this.hass; - wrapper.lovelace = this.lovelace; - wrapper.path = [this.index!, (element as LovelaceCard).index!]; - (element as LovelaceCard).editMode = true; - wrapper.appendChild(element); - return wrapper; - }); - this._createColumns(wrappedCards); - } else { - this._cards.forEach((card) => { - (card as LovelaceCard).editMode = false; - }); - this._createColumns(this._cards); - } + private async _recreateColumns() { + this._createColumns(); } - private _recreateColumns() { - this._createColumns(this._cards); - } - - private _createColumns(elements: HTMLElement[]) { + private _createColumns() { + this._createColumnsIteration++; + const iteration = this._createColumnsIteration; const root = this.shadowRoot!.getElementById("columns")!; while (root.lastChild) { root.removeChild(root.lastChild); } - let columns: HTMLElement[][] = []; + let columns: [number, number][][] = []; const columnEntityCount: number[] = []; for (let i = 0; i < this.columns!; i++) { columns.push([]); columnEntityCount.push(0); } - elements.forEach((el) => { + this._cards.forEach((el, index) => { const cardSize = computeCardSize( (el.tagName === "HUI-CARD-OPTIONS" ? el.firstChild : el) as LovelaceCard ); - columns[getColumnIndex(columnEntityCount, cardSize)].push(el); + columns[getColumnIndex(columnEntityCount, cardSize)].push([ + index, + cardSize, + ]); }); // Remove empty columns columns = columns.filter((val) => val.length > 0); - columns.forEach((column) => { + columns.forEach((indexes) => { const columnEl = document.createElement("div"); columnEl.classList.add("column"); - column.forEach((el) => columnEl.appendChild(el)); + this._addToColumn(columnEl, indexes, this.lovelace!.editMode, iteration); root.appendChild(columnEl); }); } + private async _addToColumn(columnEl, indexes, editMode, iteration) { + let i = 0; + for (const [index, cardSize] of indexes) { + const card: LovelaceCard = this._cards[index]; + if (!editMode) { + card.editMode = false; + columnEl.appendChild(card); + } else { + const wrapper = document.createElement("hui-card-options"); + wrapper.hass = this.hass; + wrapper.lovelace = this.lovelace; + wrapper.path = [this.index!, index]; + card.editMode = true; + wrapper.appendChild(card); + columnEl.appendChild(wrapper); + } + i += cardSize; + if (i > 5) { + // eslint-disable-next-line no-await-in-loop + await nextRender(); + if (iteration !== this._createColumnsIteration) { + return; + } + i = 0; + } + } + } + private _createCards(config: LovelaceViewConfig): void { if (!config || !config.cards || !Array.isArray(config.cards)) { this._cards = []; @@ -274,19 +293,14 @@ export class HUIView extends LitElement { } const elements: LovelaceCard[] = []; - config.cards.forEach((cardConfig, index) => { + config.cards.forEach((cardConfig) => { const element = this.createCardElement(cardConfig); - element.index = index; elements.push(element); }); this._cards = elements; - if (this.lovelace!.editMode) { - this._switchEditMode(); - } else { - this._createColumns(this._cards); - } + this._createColumns(); } private _rebuildCard( @@ -294,7 +308,9 @@ export class HUIView extends LitElement { config: LovelaceCardConfig ): void { const newCardEl = this.createCardElement(config); - cardElToReplace.parentElement!.replaceChild(newCardEl, cardElToReplace); + if (cardElToReplace.parentElement) { + cardElToReplace.parentElement!.replaceChild(newCardEl, cardElToReplace); + } this._cards = this._cards!.map((curCardEl) => curCardEl === cardElToReplace ? newCardEl : curCardEl ); diff --git a/src/translations/en.json b/src/translations/en.json index 18d2068be9..2186d88d5f 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1391,6 +1391,7 @@ "manuf": "by {manufacturer}", "hub": "Connected via", "firmware": "Firmware: {version}", + "unnamed_entry": "Unnamed entry", "device_unavailable": "device unavailable", "entity_unavailable": "entity unavailable", "area": "In {area}", diff --git a/src/util/register-service-worker.ts b/src/util/register-service-worker.ts index 6014b0e55a..709c8f2a6c 100644 --- a/src/util/register-service-worker.ts +++ b/src/util/register-service-worker.ts @@ -1,49 +1,57 @@ -import { HassElement } from "../state/hass-element"; import { showToast } from "./toast"; export const supportsServiceWorker = () => "serviceWorker" in navigator && (location.protocol === "https:" || location.hostname === "localhost"); -export const registerServiceWorker = (notifyUpdate = true) => { +export const registerServiceWorker = async ( + rootEl: HTMLElement, + notifyUpdate = true +) => { if (!supportsServiceWorker()) { return; } - navigator.serviceWorker.register("/service_worker.js").then((reg) => { - reg.addEventListener("updatefound", () => { - const installingWorker = reg.installing; - if (!installingWorker || !notifyUpdate) { - return; - } - installingWorker.addEventListener("statechange", () => { - if ( - installingWorker.state === "installed" && - navigator.serviceWorker.controller && - !__DEV__ && - !__DEMO__ - ) { - // Notify users here of a new frontend being available. - const haElement = window.document.querySelector( - "home-assistant, ha-onboarding" - )! as HassElement; - showToast(haElement, { - message: "A new version of the frontend is available.", - action: { - action: () => - installingWorker.postMessage({ type: "skipWaiting" }), - text: "reload", - }, - duration: 0, - dismissable: false, - }); - } - }); - }); - }); - // If the active service worker changes, refresh the page because the cache has changed navigator.serviceWorker.addEventListener("controllerchange", () => { location.reload(); }); + + const reg = await navigator.serviceWorker.register("/service_worker.js"); + + if (!notifyUpdate || __DEV__ || __DEMO__) { + return; + } + + reg.addEventListener("updatefound", () => { + const installingWorker = reg.installing; + + if (!installingWorker) { + return; + } + + installingWorker.addEventListener("statechange", () => { + if ( + installingWorker.state !== "installed" || + !navigator.serviceWorker.controller + ) { + return; + } + + // Notify users a new frontend is available. + // When + showToast(rootEl, { + message: "A new version of the frontend is available.", + action: { + // We tell the service worker to call skipWaiting, which activates + // the new service worker. Above we listen for `controllerchange` + // so we reload the page once a new servic worker activates. + action: () => installingWorker.postMessage({ type: "skipWaiting" }), + text: "reload", + }, + duration: 0, + dismissable: false, + }); + }); + }); };