diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 99f9047671..977620c9dd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "dockerfile": "Dockerfile", "context": ".." }, - "appPort": 8123, + "appPort": "8124:8123", "context": "..", "postCreateCommand": "script/bootstrap", "extensions": [ diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE.md similarity index 100% rename from .github/ISSUE_TEMPLATE/BUG_REPORT.md rename to .github/ISSUE_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..2737b9a696 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,138 @@ +name: Report a bug with the UI, Frontend or Lovelace +about: Report an issue related to the Home Assistant frontend. +labels: bug +title: "" +issue_body: true +body: + - type: markdown + attributes: + value: | + Make sure you are running the [latest version of Home Assistant][releases] before reporting an issue. + + If you have a feature or enhancement request for the frontend, please [start an discussion][fr] instead of creating an issue. + + **Please not not report issues for custom Lovelace cards.** + + [fr]: https://github.com/home-assistant/frontend/discussions + [releases]: https://github.com/home-assistant/home-assistant/releases + - type: checkboxes + attributes: + label: Checklist + description: Please verify that you've followed these steps + options: + - label: I have updated to the latest available Home Assistant version. + required: true + - label: I have cleared the cache of my browser. + required: true + - label: I have tried a different browser to see if it is related to my browser. + required: true + - type: markdown + attributes: + value: | + ## The problem + - type: textarea + validations: + required: true + attributes: + label: Describe the issue you are experiencing + description: Provide a clear and concise description of what the bug is. + - type: textarea + validations: + required: true + attributes: + label: Describe the behavior you expected + description: Describe what you expected to happen or it should look/behave. + - type: textarea + validations: + required: true + attributes: + label: Steps to reproduce the issue + description: | + Please tell us exactly how to reproduce your issue. + Provide clear and concise step by step instructions and add code snippets if needed. + value: | + 1. + 2. + 3. + ... + - type: markdown + attributes: + value: | + ## Environment + - type: input + validations: + required: true + attributes: + label: What version of Home Assistant Core has the issue? + placeholder: core- + description: > + Can be found in the Configuration panel -> Info. + - type: input + attributes: + label: What was the last working version of Home Assistant Core? + placeholder: core- + description: > + If known, otherwise leave blank. + - type: input + attributes: + label: In which browser are you experiencing the issue with? + placeholder: Google Chrome 88.0.4324.150 + description: > + Provide the full name and don't forget to add the version! + - type: input + attributes: + label: Which operating system are you using to run this browser? + placeholder: macOS Big Sur (1.11) + description: > + Don't forget to add the version! + - type: markdown + attributes: + value: | + # Details + + - type: textarea + attributes: + label: State of relevant entities + description: > + If your issue is about how an entity is shown in the UI, please add the + state and attributes for all situations. You can find this information + at Developer Tools -> States. + value: | + ```yaml + # Paste your state here. + + ``` + - type: textarea + attributes: + label: Problem-relevant frontend configuration + description: > + An example configuration that caused the problem for you, e.g., the YAML + configuration of the used cards. Fill this out even if it seems + unimportant to you. Please be sure to remove personal information like + passwords, private URLs and other credentials. + value: | + ```yaml + # Paste your YAML here. + + ``` + - type: textarea + attributes: + label: Javascript errors shown in your browser console/inspector + description: > + If you come across any Javascript or other error logs, e.g., in your + browser console/inspector please provide them. + value: | + ```txt + # Paste your logs here. + + ``` + - type: markdown + attributes: + value: | + ## Additional information + - type: markdown + attributes: + value: | + If you have any additional information for us, use the field below. + Please note, you can attach screenshots or screen recordings here, + by dragging and dropping files in the field below. diff --git a/build-scripts/webpack.js b/build-scripts/webpack.js index fb4f572748..e39c3d9ea7 100644 --- a/build-scripts/webpack.js +++ b/build-scripts/webpack.js @@ -1,7 +1,7 @@ const webpack = require("webpack"); const path = require("path"); const TerserPlugin = require("terser-webpack-plugin"); -const ManifestPlugin = require("webpack-manifest-plugin"); +const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); const paths = require("./paths.js"); const bundle = require("./bundle"); const log = require("fancy-log"); @@ -68,7 +68,7 @@ const createWebpackConfig = ({ ], }, plugins: [ - new ManifestPlugin({ + new WebpackManifestPlugin({ // Only include the JS of entrypoints filter: (file) => file.isInitial && !file.name.endsWith(".map"), }), diff --git a/cast/src/launcher/layout/hc-cast.ts b/cast/src/launcher/layout/hc-cast.ts index 1a00cd62dd..7bcbe70d59 100644 --- a/cast/src/launcher/layout/hc-cast.ts +++ b/cast/src/launcher/layout/hc-cast.ts @@ -48,7 +48,7 @@ class HcCast extends LitElement { protected render(): TemplateResult { if (this.lovelaceConfig === undefined) { - return html` > `; + return html``; } const error = diff --git a/hassio/src/addon-store/hassio-addon-store.ts b/hassio/src/addon-store/hassio-addon-store.ts index 2e21b19f84..6add263da7 100644 --- a/hassio/src/addon-store/hassio-addon-store.ts +++ b/hassio/src/addon-store/hassio-addon-store.ts @@ -11,19 +11,18 @@ import { PropertyValues, } from "lit-element"; import { html, TemplateResult } from "lit-html"; +import memoizeOne from "memoize-one"; import { atLeastVersion } from "../../../src/common/config/version"; import { fireEvent } from "../../../src/common/dom/fire_event"; import "../../../src/common/search/search-input"; import "../../../src/components/ha-button-menu"; import "../../../src/components/ha-svg-icon"; import { - fetchHassioAddonsInfo, HassioAddonInfo, HassioAddonRepository, reloadHassioAddons, } from "../../../src/data/hassio/addon"; -import { extractApiErrorMessage } from "../../../src/data/hassio/common"; -import { fetchHassioSupervisorInfo } from "../../../src/data/hassio/supervisor"; +import { Supervisor } from "../../../src/data/supervisor/supervisor"; import "../../../src/layouts/hass-loading-screen"; import "../../../src/layouts/hass-tabs-subpage"; import { HomeAssistant, Route } from "../../../src/types"; @@ -51,46 +50,27 @@ const sortRepos = (a: HassioAddonRepository, b: HassioAddonRepository) => { class HassioAddonStore extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) public supervisor!: Supervisor; + @property({ type: Boolean }) public narrow!: boolean; @property({ attribute: false }) public route!: Route; - @property({ attribute: false }) private _addons?: HassioAddonInfo[]; - - @property({ attribute: false }) private _repos?: HassioAddonRepository[]; - @internalProperty() private _filter?: string; public async refreshData() { - this._repos = undefined; - this._addons = undefined; - this._filter = undefined; await reloadHassioAddons(this.hass); await this._loadData(); } protected render(): TemplateResult { - const repos: TemplateResult[] = []; + let repos: TemplateResult[] = []; - if (this._repos) { - for (const repo of this._repos) { - const addons = this._addons!.filter( - (addon) => addon.repository === repo.slug - ); - - if (addons.length === 0) { - continue; - } - - repos.push(html` - - `); - } + if (this.supervisor.addon.repositories) { + repos = this.addonRepositories( + this.supervisor.addon.repositories, + this.supervisor.addon.addons + ); } return html` @@ -159,6 +139,27 @@ class HassioAddonStore extends LitElement { this._loadData(); } + private addonRepositories = memoizeOne( + (repositories: HassioAddonRepository[], addons: HassioAddonInfo[]) => { + return repositories.sort(sortRepos).map((repo) => { + const filteredAddons = addons.filter( + (addon) => addon.repository === repo.slug + ); + + return filteredAddons.length !== 0 + ? html` + + ` + : html``; + }); + } + ); + private _handleAction(ev: CustomEvent) { switch (ev.detail.index) { case 0: @@ -181,7 +182,7 @@ class HassioAddonStore extends LitElement { private async _manageRepositories() { showRepositoriesDialog(this, { - repos: this._repos!, + repos: this.supervisor.addon.repositories, loadData: () => this._loadData(), }); } @@ -191,18 +192,8 @@ class HassioAddonStore extends LitElement { } private async _loadData() { - try { - const [addonsInfo, supervisor] = await Promise.all([ - fetchHassioAddonsInfo(this.hass), - fetchHassioSupervisorInfo(this.hass), - ]); - fireEvent(this, "supervisor-update", { supervisor }); - this._repos = addonsInfo.repositories; - this._repos.sort(sortRepos); - this._addons = addonsInfo.addons; - } catch (err) { - alert(extractApiErrorMessage(err)); - } + fireEvent(this, "supervisor-store-refresh", { store: "addon" }); + fireEvent(this, "supervisor-store-refresh", { store: "supervisor" }); } private async _filterChanged(e) { diff --git a/hassio/src/addon-view/config/hassio-addon-config-tab.ts b/hassio/src/addon-view/config/hassio-addon-config-tab.ts index 323d50df73..2e8d4d9b6c 100644 --- a/hassio/src/addon-view/config/hassio-addon-config-tab.ts +++ b/hassio/src/addon-view/config/hassio-addon-config-tab.ts @@ -29,7 +29,7 @@ class HassioAddonConfigDashboard extends LitElement { const hasOptions = this.addon.options && Object.keys(this.addon.options).length; const hasSchema = - this.addon.schema && Object.keys(this.addon.schema).length; + hasOptions && this.addon.schema && Object.keys(this.addon.schema).length; return html`
diff --git a/hassio/src/addon-view/config/hassio-addon-config.ts b/hassio/src/addon-view/config/hassio-addon-config.ts index 5762d17da9..83e5976231 100644 --- a/hassio/src/addon-view/config/hassio-addon-config.ts +++ b/hassio/src/addon-view/config/hassio-addon-config.ts @@ -109,8 +109,8 @@ class HassioAddonConfig extends LitElement { protected firstUpdated(changedProps) { super.firstUpdated(changedProps); this._canShowSchema = - this.addon.schema !== null && - !this.addon.schema.find( + Object.keys(this.addon.options).length !== 0 && + !this.addon.schema!.find( // @ts-ignore (entry) => !SUPPORTED_UI_TYPES.includes(entry.type) || entry.multiple ); @@ -150,13 +150,11 @@ class HassioAddonConfig extends LitElement { if (this.addon.schema && this._canShowSchema && !this._yamlMode) { this._valid = true; this._configHasChanged = true; + this._options! = ev.detail.value; } else { this._configHasChanged = true; this._valid = ev.detail.isValid; } - if (this._valid) { - this._options! = ev.detail.value; - } } private async _resetTapped(ev: CustomEvent): Promise { @@ -204,8 +202,9 @@ class HassioAddonConfig extends LitElement { try { await setHassioAddonOption(this.hass, this.addon.slug, { - options: this._options!, + options: this._yamlMode ? this._editor?.value : this._options, }); + this._configHasChanged = false; const eventdata = { success: true, diff --git a/hassio/src/addon-view/hassio-addon-dashboard.ts b/hassio/src/addon-view/hassio-addon-dashboard.ts index 4260ea4f92..4c01e88c87 100644 --- a/hassio/src/addon-view/hassio-addon-dashboard.ts +++ b/hassio/src/addon-view/hassio-addon-dashboard.ts @@ -9,17 +9,24 @@ import { CSSResult, customElement, html, + internalProperty, LitElement, property, TemplateResult, } from "lit-element"; import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../src/common/dom/fire_event"; +import { navigate } from "../../../src/common/navigate"; +import { extractSearchParam } from "../../../src/common/url/search-params"; import "../../../src/components/ha-circular-progress"; import { fetchHassioAddonInfo, HassioAddonDetails, } from "../../../src/data/hassio/addon"; +import { extractApiErrorMessage } from "../../../src/data/hassio/common"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; +import "../../../src/layouts/hass-error-screen"; +import "../../../src/layouts/hass-loading-screen"; import "../../../src/layouts/hass-tabs-subpage"; import type { PageNavigation } from "../../../src/layouts/hass-tabs-subpage"; import { haStyle } from "../../../src/resources/styles"; @@ -44,6 +51,8 @@ class HassioAddonDashboard extends LitElement { @property({ type: Boolean }) public narrow!: boolean; + @internalProperty() _error?: string; + private _computeTail = memoizeOne((route: Route) => { const dividerPos = route.path.indexOf("/", 1); return dividerPos === -1 @@ -58,8 +67,14 @@ class HassioAddonDashboard extends LitElement { }); protected render(): TemplateResult { + if (this._error) { + return html``; + } + if (!this.addon) { - return html``; + return html``; } const addonTabs: PageNavigation[] = [ @@ -156,30 +171,51 @@ class HassioAddonDashboard extends LitElement { } protected async firstUpdated(): Promise { - await this._routeDataChanged(this.route); + if (this.route.path === "") { + const addon = extractSearchParam("addon"); + if (addon) { + navigate(this, `/hassio/addon/${addon}`, true); + } + } this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev)); } private async _apiCalled(ev): Promise { - const path: string = ev.detail.path; + const pathSplit: string[] = ev.detail.path?.split("/"); - if (!path) { + if (!pathSplit || pathSplit.length === 0) { return; } + const path: string = pathSplit[pathSplit.length - 1]; + + if (["uninstall", "install", "update", "start", "stop"].includes(path)) { + fireEvent(this, "supervisor-store-refresh", { store: "supervisor" }); + } + if (path === "uninstall") { - history.back(); + window.history.back(); } else { - await this._routeDataChanged(this.route); + await this._routeDataChanged(); } } - private async _routeDataChanged(routeData: Route): Promise { - const addon = routeData.path.split("/")[1]; + protected updated(changedProperties) { + if (changedProperties.has("route") && !this.addon) { + this._routeDataChanged(); + } + } + + private async _routeDataChanged(): Promise { + const addon = this.route.path.split("/")[1]; + if (!addon) { + return; + } try { const addoninfo = await fetchHassioAddonInfo(this.hass, addon); this.addon = addoninfo; - } catch { + } catch (err) { + this._error = `Error fetching addon info: ${extractApiErrorMessage(err)}`; this.addon = undefined; } } diff --git a/hassio/src/addon-view/info/hassio-addon-info.ts b/hassio/src/addon-view/info/hassio-addon-info.ts index fd93dfaa18..dfb1386662 100644 --- a/hassio/src/addon-view/info/hassio-addon-info.ts +++ b/hassio/src/addon-view/info/hassio-addon-info.ts @@ -43,10 +43,13 @@ import { HassioAddonSetOptionParams, HassioAddonSetSecurityParams, installHassioAddon, + restartHassioAddon, setHassioAddonOption, setHassioAddonSecurity, startHassioAddon, + stopHassioAddon, uninstallHassioAddon, + updateHassioAddon, validateHassioAddonOption, } from "../../../../src/data/hassio/addon"; import { @@ -196,13 +199,9 @@ class HassioAddonInfo extends LitElement { : ""}
- + Update - + ${this.addon.changelog ? html` @@ -579,20 +578,18 @@ class HassioAddonInfo extends LitElement { ${this.addon.version ? this._computeIsRunning ? html` - Stop - - + Restart - + ` : html` @@ -883,6 +880,82 @@ class HassioAddonInfo extends LitElement { button.progress = false; } + private async _stopClicked(ev: CustomEvent): Promise { + const button = ev.currentTarget as any; + button.progress = true; + + try { + await stopHassioAddon(this.hass, this.addon.slug); + const eventdata = { + success: true, + response: undefined, + path: "stop", + }; + fireEvent(this, "hass-api-called", eventdata); + } catch (err) { + showAlertDialog(this, { + title: "Failed to stop addon", + text: extractApiErrorMessage(err), + }); + } + button.progress = false; + } + + private async _restartClicked(ev: CustomEvent): Promise { + const button = ev.currentTarget as any; + button.progress = true; + + try { + await restartHassioAddon(this.hass, this.addon.slug); + const eventdata = { + success: true, + response: undefined, + path: "stop", + }; + fireEvent(this, "hass-api-called", eventdata); + } catch (err) { + showAlertDialog(this, { + title: "Failed to restart addon", + text: extractApiErrorMessage(err), + }); + } + button.progress = false; + } + + private async _updateClicked(ev: CustomEvent): Promise { + const button = ev.currentTarget as any; + button.progress = true; + + const confirmed = await showConfirmationDialog(this, { + title: this.addon.name, + text: "Are you sure you want to update this add-on?", + confirmText: "update add-on", + dismissText: "no", + }); + + if (!confirmed) { + button.progress = false; + return; + } + + this._error = undefined; + try { + await updateHassioAddon(this.hass, this.addon.slug); + const eventdata = { + success: true, + response: undefined, + path: "update", + }; + fireEvent(this, "hass-api-called", eventdata); + } catch (err) { + showAlertDialog(this, { + title: "Failed to update addon", + text: extractApiErrorMessage(err), + }); + } + button.progress = false; + } + private async _startClicked(ev: CustomEvent): Promise { const button = ev.currentTarget as any; button.progress = true; @@ -891,10 +964,10 @@ class HassioAddonInfo extends LitElement { this.hass, this.addon.slug ); - if (!validate.data.valid) { + if (!validate.valid) { await showConfirmationDialog(this, { title: "Failed to start addon - configuration validation failed!", - text: validate.data.message.split(" Got ")[0], + text: validate.message.split(" Got ")[0], confirm: () => this._openConfiguration(), confirmText: "Go to configuration", dismissText: "Cancel", @@ -914,6 +987,12 @@ class HassioAddonInfo extends LitElement { try { await startHassioAddon(this.hass, this.addon.slug); this.addon = await fetchHassioAddonInfo(this.hass, this.addon.slug); + const eventdata = { + success: true, + response: undefined, + path: "start", + }; + fireEvent(this, "hass-api-called", eventdata); } catch (err) { showAlertDialog(this, { title: "Failed to start addon", diff --git a/hassio/src/dashboard/hassio-update.ts b/hassio/src/dashboard/hassio-update.ts index b2c8bb8bd9..488e541f2f 100644 --- a/hassio/src/dashboard/hassio-update.ts +++ b/hassio/src/dashboard/hassio-update.ts @@ -10,6 +10,7 @@ import { TemplateResult, } from "lit-element"; import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../src/common/dom/fire_event"; import "../../../src/components/buttons/ha-progress-button"; import "../../../src/components/ha-card"; import "../../../src/components/ha-svg-icon"; @@ -64,6 +65,7 @@ export class HassioUpdate extends LitElement {
${this._renderUpdateCard( "Home Assistant Core", + "core", this.supervisor.core, "hassio/homeassistant/update", `https://${ @@ -72,6 +74,7 @@ export class HassioUpdate extends LitElement { )} ${this._renderUpdateCard( "Supervisor", + "supervisor", this.supervisor.supervisor, "hassio/supervisor/update", `https://github.com//home-assistant/hassio/releases/tag/${this.supervisor.supervisor.version_latest}` @@ -79,6 +82,7 @@ export class HassioUpdate extends LitElement { ${this.supervisor.host.features.includes("hassos") ? this._renderUpdateCard( "Operating System", + "os", this.supervisor.os, "hassio/os/update", `https://github.com//home-assistant/hassos/releases/tag/${this.supervisor.os.version_latest}` @@ -91,6 +95,7 @@ export class HassioUpdate extends LitElement { private _renderUpdateCard( name: string, + key: string, object: HassioHomeAssistantInfo | HassioSupervisorInfo | HassioHassOSInfo, apiPath: string, releaseNotesUrl: string @@ -116,6 +121,7 @@ export class HassioUpdate extends LitElement { @@ -142,6 +148,7 @@ export class HassioUpdate extends LitElement { } try { await this.hass.callApi>("POST", item.apiPath); + fireEvent(this, "supervisor-store-refresh", { store: item.key }); } catch (err) { // Only show an error if the status code was not expected (user behind proxy) // or no status at all(connection terminated) diff --git a/hassio/src/dialogs/snapshot/dialog-hassio-snapshot.ts b/hassio/src/dialogs/snapshot/dialog-hassio-snapshot.ts index adf2612b9f..9bdeec7793 100755 --- a/hassio/src/dialogs/snapshot/dialog-hassio-snapshot.ts +++ b/hassio/src/dialogs/snapshot/dialog-hassio-snapshot.ts @@ -22,7 +22,11 @@ import { fetchHassioSnapshotInfo, HassioSnapshotDetail, } from "../../../../src/data/hassio/snapshot"; -import { showConfirmationDialog } from "../../../../src/dialogs/generic/show-dialog-box"; +import { Supervisor } from "../../../../src/data/supervisor/supervisor"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../../src/dialogs/generic/show-dialog-box"; import { PolymerChangedEvent } from "../../../../src/polymer-types"; import { haStyle, haStyleDialog } from "../../../../src/resources/styles"; import { HomeAssistant } from "../../../../src/types"; @@ -75,6 +79,8 @@ interface FolderItem { class HassioSnapshotDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) public supervisor?: Supervisor; + @internalProperty() private _error?: string; @internalProperty() private _onboarding = false; @@ -102,6 +108,7 @@ class HassioSnapshotDialog extends LitElement { this._dialogParams = params; this._onboarding = params.onboarding ?? false; + this.supervisor = params.supervisor; } protected render(): TemplateResult { @@ -298,6 +305,16 @@ class HassioSnapshotDialog extends LitElement { } private async _partialRestoreClicked() { + if ( + this.supervisor !== undefined && + this.supervisor.info.state !== "running" + ) { + await showAlertDialog(this, { + title: "Could not restore snapshot", + text: `Restoring a snapshot is not possible right now because the system is in ${this.supervisor.info.state} state.`, + }); + return; + } if ( !(await showConfirmationDialog(this, { title: "Are you sure you want partially to restore this snapshot?", @@ -359,6 +376,16 @@ class HassioSnapshotDialog extends LitElement { } private async _fullRestoreClicked() { + if ( + this.supervisor !== undefined && + this.supervisor.info.state !== "running" + ) { + await showAlertDialog(this, { + title: "Could not restore snapshot", + text: `Restoring a snapshot is not possible right now because the system is in ${this.supervisor.info.state} state.`, + }); + return; + } if ( !(await showConfirmationDialog(this, { title: diff --git a/hassio/src/dialogs/snapshot/show-dialog-hassio-snapshot.ts b/hassio/src/dialogs/snapshot/show-dialog-hassio-snapshot.ts index b5f6d964e9..8c7bcd2be7 100644 --- a/hassio/src/dialogs/snapshot/show-dialog-hassio-snapshot.ts +++ b/hassio/src/dialogs/snapshot/show-dialog-hassio-snapshot.ts @@ -1,9 +1,11 @@ import { fireEvent } from "../../../../src/common/dom/fire_event"; +import { Supervisor } from "../../../../src/data/supervisor/supervisor"; export interface HassioSnapshotDialogParams { slug: string; onDelete?: () => void; onboarding?: boolean; + supervisor?: Supervisor; } export const showHassioSnapshotDialog = ( diff --git a/hassio/src/hassio-main.ts b/hassio/src/hassio-main.ts index 81d736e881..31de2006d7 100644 --- a/hassio/src/hassio-main.ts +++ b/hassio/src/hassio-main.ts @@ -3,7 +3,9 @@ import { atLeastVersion } from "../../src/common/config/version"; import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element"; import { fireEvent } from "../../src/common/dom/fire_event"; import { HassioPanelInfo } from "../../src/data/hassio/supervisor"; +import { supervisorStore } from "../../src/data/supervisor/supervisor"; import { makeDialogManager } from "../../src/dialogs/make-dialog-manager"; +import "../../src/layouts/hass-loading-screen"; import { HomeAssistant, Route } from "../../src/types"; import "./hassio-router"; import { SupervisorBaseElement } from "./supervisor-base-element"; @@ -71,8 +73,15 @@ export class HassioMain extends SupervisorBaseElement { protected render() { if (!this.supervisor || !this.hass) { - return html``; + return html``; } + + if ( + Object.keys(supervisorStore).some((store) => !this.supervisor![store]) + ) { + return html``; + } + return html` My Home Assistant FAQ + for the supported redirects and the version they where introduced.`; + return; + } + + let url: string; + try { + url = this._createRedirectUrl(redirect); + } catch (err) { + this._error = "An unknown error occured"; + return; + } + + navigate(this, url, true); + } + + protected render(): TemplateResult { + if (this._error) { + return html``; + } + return html``; + } + + private _createRedirectUrl(redirect: Redirect): string { + const params = this._createRedirectParams(redirect); + return `${redirect.redirect}${params}`; + } + + private _createRedirectParams(redirect: Redirect): string { + const params = extractSearchParamsObject(); + if (!redirect.params && !Object.keys(params).length) { + return ""; + } + const resultParams = {}; + Object.entries(redirect.params || {}).forEach(([key, type]) => { + if (!params[key] || !this._checkParamType(type, params[key])) { + throw Error(); + } + resultParams[key] = params[key]; + }); + return `?${createSearchParam(resultParams)}`; + } + + private _checkParamType(type: ParamType, value: string) { + if (type === "string") { + return true; + } + if (type === "url") { + return value && value === sanitizeUrl(value); + } + return false; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hassio-my-redirect": HassioMyRedirect; + } +} diff --git a/hassio/src/hassio-router.ts b/hassio/src/hassio-router.ts index 50bd69cd3a..4118e82387 100644 --- a/hassio/src/hassio-router.ts +++ b/hassio/src/hassio-router.ts @@ -41,6 +41,10 @@ class HassioRouter extends HassRouterPage { tag: "hassio-ingress-view", load: () => import("./ingress-view/hassio-ingress-view"), }, + _my_redirect: { + tag: "hassio-my-redirect", + load: () => import("./hassio-my-redirect"), + }, }, }; @@ -49,12 +53,13 @@ class HassioRouter extends HassRouterPage { const route = el.nodeName === "HASSIO-PANEL" ? this.route : this.routeTail; el.hass = this.hass; - el.supervisor = this.supervisor; el.narrow = this.narrow; el.route = route; if (el.localName === "hassio-ingress-view") { el.ingressPanel = this.panel.config && this.panel.config.ingress; + } else { + el.supervisor = this.supervisor; } } diff --git a/hassio/src/snapshots/hassio-snapshots.ts b/hassio/src/snapshots/hassio-snapshots.ts index 4cce8cc0ff..1fd11ed1ff 100644 --- a/hassio/src/snapshots/hassio-snapshots.ts +++ b/hassio/src/snapshots/hassio-snapshots.ts @@ -41,6 +41,7 @@ import { reloadHassioSnapshots, } from "../../../src/data/hassio/snapshot"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; +import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box"; import "../../../src/layouts/hass-tabs-subpage"; import { PolymerChangedEvent } from "../../../src/polymer-types"; import { haStyle } from "../../../src/resources/styles"; @@ -211,7 +212,13 @@ class HassioSnapshots extends LitElement { : undefined}
- + Create
@@ -325,6 +332,12 @@ class HassioSnapshots extends LitElement { } private async _createSnapshot(ev: CustomEvent): Promise { + if (this.supervisor.info.state !== "running") { + await showAlertDialog(this, { + title: "Could not create snapshot", + text: `Creating a snapshot is not possible right now because the system is in ${this.supervisor.info.state} state.`, + }); + } const button = ev.currentTarget as any; button.progress = true; @@ -386,6 +399,7 @@ class HassioSnapshots extends LitElement { private _snapshotClicked(ev) { showHassioSnapshotDialog(this, { slug: ev.currentTarget!.snapshot.slug, + supervisor: this.supervisor, onDelete: () => this._updateSnapshots(), }); } @@ -395,6 +409,7 @@ class HassioSnapshots extends LitElement { showSnapshot: (slug: string) => showHassioSnapshotDialog(this, { slug, + supervisor: this.supervisor, onDelete: () => this._updateSnapshots(), }), reloadSnapshot: () => this.refreshData(), diff --git a/hassio/src/supervisor-base-element.ts b/hassio/src/supervisor-base-element.ts index b6b301175a..9abdf473a5 100644 --- a/hassio/src/supervisor-base-element.ts +++ b/hassio/src/supervisor-base-element.ts @@ -1,4 +1,13 @@ -import { LitElement, property, PropertyValues } from "lit-element"; +import { Collection, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { + internalProperty, + LitElement, + property, + PropertyValues, +} from "lit-element"; +import { atLeastVersion } from "../../src/common/config/version"; +import { fetchHassioAddonsInfo } from "../../src/data/hassio/addon"; +import { HassioResponse } from "../../src/data/hassio/common"; import { fetchHassioHassOsInfo, fetchHassioHostInfo, @@ -10,13 +19,20 @@ import { fetchHassioInfo, fetchHassioSupervisorInfo, } from "../../src/data/hassio/supervisor"; -import { Supervisor } from "../../src/data/supervisor/supervisor"; +import { + getSupervisorEventCollection, + subscribeSupervisorEvents, + Supervisor, + SupervisorObject, + supervisorStore, +} from "../../src/data/supervisor/supervisor"; import { ProvideHassLitMixin } from "../../src/mixins/provide-hass-lit-mixin"; import { urlSyncMixin } from "../../src/state/url-sync-mixin"; declare global { interface HASSDomEvents { "supervisor-update": Partial; + "supervisor-store-refresh": { store: SupervisorObject }; } } @@ -25,6 +41,20 @@ export class SupervisorBaseElement extends urlSyncMixin( ) { @property({ attribute: false }) public supervisor?: Supervisor; + @internalProperty() private _unsubs: Record = {}; + + @internalProperty() private _collections: Record< + string, + Collection + > = {}; + + public disconnectedCallback() { + super.disconnectedCallback(); + Object.keys(this._unsubs).forEach((unsub) => { + this._unsubs[unsub](); + }); + } + protected _updateSupervisor(obj: Partial): void { this.supervisor = { ...this.supervisor!, ...obj }; } @@ -32,13 +62,59 @@ export class SupervisorBaseElement extends urlSyncMixin( protected firstUpdated(changedProps: PropertyValues): void { super.firstUpdated(changedProps); this._initSupervisor(); - this.addEventListener("supervisor-update", (ev) => - this._updateSupervisor(ev.detail) + } + + private async _handleSupervisorStoreRefreshEvent(ev) { + const store = ev.detail.store; + if (atLeastVersion(this.hass.config.version, 2021, 2, 4)) { + this._collections[store].refresh(); + return; + } + + const response = await this.hass.callApi>( + "GET", + `hassio${supervisorStore[store]}` ); + this._updateSupervisor({ [store]: response.data }); } private async _initSupervisor(): Promise { + this.addEventListener( + "supervisor-store-refresh", + this._handleSupervisorStoreRefreshEvent + ); + + if (atLeastVersion(this.hass.config.version, 2021, 2, 4)) { + Object.keys(supervisorStore).forEach((store) => { + this._unsubs[store] = subscribeSupervisorEvents( + this.hass, + (data) => this._updateSupervisor({ [store]: data }), + store, + supervisorStore[store] + ); + if (this._collections[store]) { + this._collections[store].refresh(); + } else { + this._collections[store] = getSupervisorEventCollection( + this.hass.connection, + store, + supervisorStore[store] + ); + } + }); + + if (this.supervisor === undefined) { + Object.keys(this._collections).forEach((collection) => + this._updateSupervisor({ + [collection]: this._collections[collection].state, + }) + ); + } + return; + } + const [ + addon, supervisor, host, core, @@ -47,6 +123,7 @@ export class SupervisorBaseElement extends urlSyncMixin( network, resolution, ] = await Promise.all([ + fetchHassioAddonsInfo(this.hass), fetchHassioSupervisorInfo(this.hass), fetchHassioHostInfo(this.hass), fetchHassioHomeAssistantInfo(this.hass), @@ -57,6 +134,7 @@ export class SupervisorBaseElement extends urlSyncMixin( ]); this.supervisor = { + addon, supervisor, host, core, @@ -65,5 +143,9 @@ export class SupervisorBaseElement extends urlSyncMixin( network, resolution, }; + + this.addEventListener("supervisor-update", (ev) => + this._updateSupervisor(ev.detail) + ); } } diff --git a/hassio/src/system/hassio-core-info.ts b/hassio/src/system/hassio-core-info.ts index 96c3aad65f..985a242773 100644 --- a/hassio/src/system/hassio-core-info.ts +++ b/hassio/src/system/hassio-core-info.ts @@ -10,6 +10,7 @@ import { property, TemplateResult, } from "lit-element"; +import { fireEvent } from "../../../src/common/dom/fire_event"; import "../../../src/components/buttons/ha-progress-button"; import "../../../src/components/ha-button-menu"; import "../../../src/components/ha-card"; @@ -166,6 +167,7 @@ class HassioCoreInfo extends LitElement { try { await updateCore(this.hass); + fireEvent(this, "supervisor-store-refresh", { store: "core" }); } catch (err) { showAlertDialog(this, { title: "Failed to update Home Assistant Core", diff --git a/hassio/src/system/hassio-host-info.ts b/hassio/src/system/hassio-host-info.ts index b4fd17f9b2..69245d8663 100644 --- a/hassio/src/system/hassio-host-info.ts +++ b/hassio/src/system/hassio-host-info.ts @@ -13,6 +13,7 @@ import { TemplateResult, } from "lit-element"; import memoizeOne from "memoize-one"; +import { atLeastVersion } from "../../../src/common/config/version"; import { fireEvent } from "../../../src/common/dom/fire_event"; import "../../../src/components/buttons/ha-progress-button"; import "../../../src/components/ha-button-menu"; @@ -26,7 +27,6 @@ import { fetchHassioHardwareInfo } from "../../../src/data/hassio/hardware"; import { changeHostOptions, configSyncOS, - fetchHassioHostInfo, rebootHost, shutdownHost, updateOS, @@ -340,6 +340,7 @@ class HassioHostInfo extends LitElement { try { await updateOS(this.hass); + fireEvent(this, "supervisor-store-refresh", { store: "os" }); } catch (err) { showAlertDialog(this, { title: "Failed to update", @@ -368,8 +369,7 @@ class HassioHostInfo extends LitElement { if (hostname && hostname !== curHostname) { try { await changeHostOptions(this.hass, { hostname }); - const host = await fetchHassioHostInfo(this.hass); - fireEvent(this, "supervisor-update", { host }); + fireEvent(this, "supervisor-store-refresh", { store: "host" }); } catch (err) { showAlertDialog(this, { title: "Setting hostname failed", @@ -382,8 +382,7 @@ class HassioHostInfo extends LitElement { private async _importFromUSB(): Promise { try { await configSyncOS(this.hass); - const host = await fetchHassioHostInfo(this.hass); - fireEvent(this, "supervisor-update", { host }); + fireEvent(this, "supervisor-store-refresh", { store: "host" }); } catch (err) { showAlertDialog(this, { title: "Failed to import from USB", @@ -393,8 +392,12 @@ class HassioHostInfo extends LitElement { } private async _loadData(): Promise { - const network = await fetchNetworkInfo(this.hass); - fireEvent(this, "supervisor-update", { network }); + if (atLeastVersion(this.hass.config.version, 2021, 2, 4)) { + fireEvent(this, "supervisor-store-refresh", { store: "network" }); + } else { + const network = await fetchNetworkInfo(this.hass); + fireEvent(this, "supervisor-update", { network }); + } } static get styles(): CSSResult[] { diff --git a/hassio/src/system/hassio-supervisor-info.ts b/hassio/src/system/hassio-supervisor-info.ts index b29252d2ad..ebc96ab32a 100644 --- a/hassio/src/system/hassio-supervisor-info.ts +++ b/hassio/src/system/hassio-supervisor-info.ts @@ -19,7 +19,6 @@ import { HassioStats, } from "../../../src/data/hassio/common"; import { - fetchHassioSupervisorInfo, reloadSupervisor, restartSupervisor, setSupervisorOption, @@ -318,8 +317,7 @@ class HassioSupervisorInfo extends LitElement { private async _reloadSupervisor(): Promise { await reloadSupervisor(this.hass); - const supervisor = await fetchHassioSupervisorInfo(this.hass); - fireEvent(this, "supervisor-update", { supervisor }); + fireEvent(this, "supervisor-store-refresh", { store: "supervisor" }); } private async _supervisorRestart(ev: CustomEvent): Promise { @@ -368,6 +366,7 @@ class HassioSupervisorInfo extends LitElement { try { await updateSupervisor(this.hass); + fireEvent(this, "supervisor-store-refresh", { store: "supervisor" }); } catch (err) { showAlertDialog(this, { title: "Failed to update the supervisor", diff --git a/package.json b/package.json index 3ff0c23af5..0956efd2c2 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "fuse.js": "^6.0.0", "google-timezones-json": "^1.0.2", "hls.js": "^0.13.2", - "home-assistant-js-websocket": "^5.4.1", + "home-assistant-js-websocket": "^5.9.0", "idb-keyval": "^3.2.0", "intl-messageformat": "^8.3.9", "js-yaml": "^3.13.1", @@ -110,7 +110,7 @@ "lit-element": "^2.4.0", "lit-html": "^1.3.0", "lit-virtualizer": "^0.4.2", - "marked": "^1.1.1", + "marked": "2.0.0", "mdn-polyfills": "^5.16.0", "memoize-one": "^5.0.2", "node-vibrant": "3.2.1-alpha.1", @@ -161,7 +161,7 @@ "@types/js-yaml": "^3.12.1", "@types/leaflet": "^1.4.3", "@types/leaflet-draw": "^1.0.1", - "@types/marked": "^1.1.0", + "@types/marked": "^1.2.2", "@types/memoize-one": "4.1.0", "@types/mocha": "^7.0.2", "@types/resize-observer-browser": "^0.1.3", @@ -222,7 +222,7 @@ "webpack": "5.1.3", "webpack-cli": "4.1.0", "webpack-dev-server": "^3.11.0", - "webpack-manifest-plugin": "3.0.0-rc.0", + "webpack-manifest-plugin": "~3.0.0", "workbox-build": "^5.1.3" }, "_comment": "Polymer fixed to 3.1 because 3.2 throws on logbook page", diff --git a/setup.py b/setup.py index efdb01ab43..f63d078d8b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="home-assistant-frontend", - version="20210208.0", + version="20210222.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/config/version.ts b/src/common/config/version.ts index cc6ccf7f7f..affdf9d94d 100644 --- a/src/common/config/version.ts +++ b/src/common/config/version.ts @@ -1,11 +1,15 @@ export const atLeastVersion = ( version: string, major: number, - minor: number + minor: number, + patch?: number ): boolean => { - const [haMajor, haMinor] = version.split(".", 2); + const [haMajor, haMinor, haPatch] = version.split(".", 3); return ( Number(haMajor) > major || - (Number(haMajor) === major && Number(haMinor) >= minor) + (Number(haMajor) === major && Number(haMinor) >= minor) || + (patch !== undefined && + Number(haMajor) === major && Number(haMinor) === minor && + Number(haPatch) >= patch) ); }; diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index 0139a08c42..201efb3164 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -1,16 +1,12 @@ -import "@material/mwc-icon-button/mwc-icon-button"; -import { mdiClose, mdiMenuDown, mdiMenuUp } from "@mdi/js"; -import "@polymer/paper-input/paper-input"; import "@polymer/paper-item/paper-item"; import "@polymer/paper-item/paper-item-body"; -import "@polymer/paper-listbox/paper-listbox"; -import "@vaadin/vaadin-combo-box/theme/material/vaadin-combo-box-light"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResult, customElement, html, + internalProperty, LitElement, property, PropertyValues, @@ -38,7 +34,7 @@ import { import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { PolymerChangedEvent } from "../../polymer-types"; import { HomeAssistant } from "../../types"; -import "../ha-svg-icon"; +import { HaComboBox } from "../ha-combo-box"; interface Device { name: string; @@ -112,10 +108,11 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { @property() public deviceFilter?: HaDevicePickerDeviceFilterFunc; - @property({ type: Boolean }) - private _opened?: boolean; + @property({ type: Boolean }) public disabled?: boolean; - @query("vaadin-combo-box-light", true) private _comboBox!: HTMLElement; + @internalProperty() private _opened?: boolean; + + @query("ha-combo-box", true) private _comboBox!: HaComboBox; private _init = false; @@ -244,15 +241,11 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { ); public open() { - this.updateComplete.then(() => { - (this.shadowRoot?.querySelector("vaadin-combo-box-light") as any)?.open(); - }); + this._comboBox?.open(); } public focus() { - this.updateComplete.then(() => { - this.shadowRoot?.querySelector("paper-input")?.focus(); - }); + this._comboBox?.focus(); } public hassSubscribe(): UnsubscribeFunc[] { @@ -292,70 +285,29 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { return html``; } return html` - - - ${this.value - ? html` - - - - ` - : ""} - - - - - - + > `; } - private _clearValue(ev: Event) { - ev.stopPropagation(); - this._setValue(""); - } - private get _value() { return this.value || ""; } - private _openedChanged(ev: PolymerChangedEvent) { - this._opened = ev.detail.value; - } - private _deviceChanged(ev: PolymerChangedEvent) { + ev.stopPropagation(); const newValue = ev.detail.value; if (newValue !== this._value) { @@ -363,6 +315,10 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { } } + private _openedChanged(ev: PolymerChangedEvent) { + this._opened = ev.detail.value; + } + private _setValue(value: string) { this.value = value; setTimeout(() => { diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index c6b263fd54..793a426e8f 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -117,6 +117,8 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { @property() public entityFilter?: (entity: EntityRegistryEntry) => boolean; + @property({ type: Boolean }) public disabled?: boolean; + @internalProperty() private _areas?: AreaRegistryEntry[]; @internalProperty() private _devices?: DeviceRegistryEntry[]; @@ -339,6 +341,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { item-label-path="name" .value=${this._value} .renderer=${rowRenderer} + .disabled=${this.disabled} @opened-changed=${this._openedChanged} @value-changed=${this._areaChanged} > @@ -349,6 +352,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { .placeholder=${this.placeholder ? this._area(this.placeholder)?.name : undefined} + .disabled=${this.disabled} class="input" autocapitalize="none" autocomplete="off" diff --git a/src/components/ha-combo-box.js b/src/components/ha-combo-box.js deleted file mode 100644 index b844d27496..0000000000 --- a/src/components/ha-combo-box.js +++ /dev/null @@ -1,116 +0,0 @@ -import "@polymer/paper-input/paper-input"; -import "@polymer/paper-item/paper-item"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import "@vaadin/vaadin-combo-box/theme/material/vaadin-combo-box-light"; -import { EventsMixin } from "../mixins/events-mixin"; -import "./ha-icon-button"; - -class HaComboBox extends EventsMixin(PolymerElement) { - static get template() { - return html` - - - - Clear - Toggle - - - - `; - } - - static get properties() { - return { - allowCustomValue: Boolean, - items: { - type: Object, - observer: "_itemsChanged", - }, - _items: Object, - itemLabelPath: String, - itemValuePath: String, - autofocus: Boolean, - label: String, - opened: { - type: Boolean, - value: false, - observer: "_openedChanged", - }, - value: { - type: String, - notify: true, - }, - }; - } - - _openedChanged(newVal) { - if (!newVal) { - this._items = this.items; - } - } - - _itemsChanged(newVal) { - if (!this.opened) { - this._items = newVal; - } - } - - _computeToggleIcon(opened) { - return opened ? "hass:menu-up" : "hass:menu-down"; - } - - _computeItemLabel(item, itemLabelPath) { - return itemLabelPath ? item[itemLabelPath] : item; - } - - _fireChanged(ev) { - ev.stopPropagation(); - this.fire("change"); - } -} - -customElements.define("ha-combo-box", HaComboBox); diff --git a/src/components/ha-combo-box.ts b/src/components/ha-combo-box.ts new file mode 100644 index 0000000000..6f6acd3346 --- /dev/null +++ b/src/components/ha-combo-box.ts @@ -0,0 +1,181 @@ +import "@material/mwc-icon-button/mwc-icon-button"; +import { mdiClose, mdiMenuDown, mdiMenuUp } from "@mdi/js"; +import "@polymer/paper-input/paper-input"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-item/paper-item-body"; +import "@polymer/paper-listbox/paper-listbox"; +import "@vaadin/vaadin-combo-box/theme/material/vaadin-combo-box-light"; +import { + css, + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, + query, + TemplateResult, +} from "lit-element"; +import { fireEvent } from "../common/dom/fire_event"; +import { PolymerChangedEvent } from "../polymer-types"; +import { HomeAssistant } from "../types"; +import "./ha-svg-icon"; + +const defaultRowRenderer = ( + root: HTMLElement, + _owner, + model: { item: any } +) => { + if (!root.firstElementChild) { + root.innerHTML = ` + + + `; + } + + root.querySelector("paper-item")!.textContent = model.item; +}; + +@customElement("ha-combo-box") +export class HaComboBox extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property() public value?: string; + + @property() public items?: []; + + @property() public filteredItems?: []; + + @property({ attribute: "allow-custom-value", type: Boolean }) + public allowCustomValue?: boolean; + + @property({ attribute: "item-value-path" }) public itemValuePath?: string; + + @property({ attribute: "item-label-path" }) public itemLabelPath?: string; + + @property({ attribute: "item-id-path" }) public itemIdPath?: string; + + @property() public renderer?: ( + root: HTMLElement, + owner: HTMLElement, + model: { item: any } + ) => void; + + @property({ type: Boolean }) public disabled?: boolean; + + @internalProperty() private _opened?: boolean; + + @query("vaadin-combo-box-light", true) private _comboBox!: HTMLElement; + + public open() { + this.updateComplete.then(() => { + (this._comboBox as any)?.open(); + }); + } + + public focus() { + this.updateComplete.then(() => { + this.shadowRoot?.querySelector("paper-input")?.focus(); + }); + } + + protected render(): TemplateResult { + return html` + + + ${this.value + ? html` + + + + ` + : ""} + + + + + + + `; + } + + private _clearValue(ev: Event) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { value: undefined }); + } + + private _openedChanged(ev: PolymerChangedEvent) { + this._opened = ev.detail.value; + // @ts-ignore + fireEvent(this, ev.type, ev.detail); + } + + private _filterChanged(ev: PolymerChangedEvent) { + // @ts-ignore + fireEvent(this, ev.type, ev.detail); + } + + private _valueChanged(ev: PolymerChangedEvent) { + ev.stopPropagation(); + const newValue = ev.detail.value; + + if (newValue !== this.value) { + fireEvent(this, "value-changed", { value: newValue }); + } + } + + static get styles(): CSSResult { + return css` + paper-input > mwc-icon-button { + --mdc-icon-button-size: 24px; + padding: 2px; + color: var(--secondary-text-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-combo-box": HaComboBox; + } +} diff --git a/src/components/ha-form/ha-form-string.ts b/src/components/ha-form/ha-form-string.ts index 5775051dce..a376520783 100644 --- a/src/components/ha-form/ha-form-string.ts +++ b/src/components/ha-form/ha-form-string.ts @@ -1,6 +1,9 @@ +import { mdiEye, mdiEyeOff } from "@mdi/js"; import "@polymer/paper-input/paper-input"; import type { PaperInputElement } from "@polymer/paper-input/paper-input"; import { + css, + CSSResult, customElement, html, internalProperty, @@ -10,12 +13,13 @@ import { TemplateResult, } from "lit-element"; import { fireEvent } from "../../common/dom/fire_event"; -import "../ha-icon-button"; +import "../ha-svg-icon"; import type { HaFormElement, HaFormStringData, HaFormStringSchema, } from "./ha-form"; +import "@material/mwc-icon-button/mwc-icon-button"; @customElement("ha-form-string") export class HaFormString extends LitElement implements HaFormElement { @@ -48,16 +52,17 @@ export class HaFormString extends LitElement implements HaFormElement { .autoValidate=${this.schema.required} @value-changed=${this._valueChanged} > - - + > + ` : html` @@ -98,6 +103,15 @@ export class HaFormString extends LitElement implements HaFormElement { } return "text"; } + + static get styles(): CSSResult { + return css` + mwc-icon-button { + --mdc-icon-button-size: 24px; + color: var(--secondary-text-color); + } + `; + } } declare global { diff --git a/src/components/ha-form/ha-form.ts b/src/components/ha-form/ha-form.ts index 25cf8cd18d..adfe4fd71f 100644 --- a/src/components/ha-form/ha-form.ts +++ b/src/components/ha-form/ha-form.ts @@ -202,9 +202,8 @@ export class HaForm extends LitElement implements HaFormElement { ev.stopPropagation(); const schema = (ev.target as HaFormElement).schema as HaFormSchema; const data = this.data as HaFormDataContainer; - data[schema.name] = ev.detail.value; fireEvent(this, "value-changed", { - value: { ...data }, + value: { ...data, [schema.name]: ev.detail.value }, }); } diff --git a/src/components/ha-selector/ha-selector-action.ts b/src/components/ha-selector/ha-selector-action.ts index be91204c7f..8b2ca84fca 100644 --- a/src/components/ha-selector/ha-selector-action.ts +++ b/src/components/ha-selector/ha-selector-action.ts @@ -21,8 +21,11 @@ export class HaActionSelector extends LitElement { @property() public label?: string; + @property({ type: Boolean, reflect: true }) public disabled = false; + protected render() { return html``; @@ -34,6 +37,10 @@ export class HaActionSelector extends LitElement { display: block; margin-bottom: 16px; } + :host([disabled]) ha-automation-action { + opacity: var(--light-disabled-opacity); + pointer-events: none; + } `; } } diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index 8023dc4844..c3443291d6 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -24,6 +24,8 @@ export class HaAreaSelector extends LitElement { @internalProperty() public _configEntries?: ConfigEntry[]; + @property({ type: Boolean }) public disabled = false; + protected updated(changedProperties) { if (changedProperties.has("selector")) { const oldSelector = changedProperties.get("selector"); @@ -50,6 +52,7 @@ export class HaAreaSelector extends LitElement { .includeDomains=${this.selector.area.entity?.domain ? [this.selector.area.entity.domain] : undefined} + .disabled=${this.disabled} >`; } diff --git a/src/components/ha-selector/ha-selector-boolean.ts b/src/components/ha-selector/ha-selector-boolean.ts index 8339763f81..0f56180fe5 100644 --- a/src/components/ha-selector/ha-selector-boolean.ts +++ b/src/components/ha-selector/ha-selector-boolean.ts @@ -19,11 +19,14 @@ export class HaBooleanSelector extends LitElement { @property() public label?: string; + @property({ type: Boolean }) public disabled = false; + protected render() { return html` `; } diff --git a/src/components/ha-selector/ha-selector-device.ts b/src/components/ha-selector/ha-selector-device.ts index d9ec80655c..9446c16a51 100644 --- a/src/components/ha-selector/ha-selector-device.ts +++ b/src/components/ha-selector/ha-selector-device.ts @@ -23,10 +23,12 @@ export class HaDeviceSelector extends LitElement { @internalProperty() public _configEntries?: ConfigEntry[]; + @property({ type: Boolean }) public disabled = false; + protected updated(changedProperties) { if (changedProperties.has("selector")) { const oldSelector = changedProperties.get("selector"); - if (oldSelector !== this.selector && this.selector.device.integration) { + if (oldSelector !== this.selector && this.selector.device?.integration) { this._loadConfigEntries(); } } @@ -44,24 +46,25 @@ export class HaDeviceSelector extends LitElement { .includeDomains=${this.selector.device.entity?.domain ? [this.selector.device.entity.domain] : undefined} + .disabled=${this.disabled} allow-custom-entity >`; } private _filterDevices(device: DeviceRegistryEntry): boolean { if ( - this.selector.device.manufacturer && + this.selector.device?.manufacturer && device.manufacturer !== this.selector.device.manufacturer ) { return false; } if ( - this.selector.device.model && + this.selector.device?.model && device.model !== this.selector.device.model ) { return false; } - if (this.selector.device.integration) { + if (this.selector.device?.integration) { if ( this._configEntries && !this._configEntries.some((entry) => diff --git a/src/components/ha-selector/ha-selector-entity.ts b/src/components/ha-selector/ha-selector-entity.ts index 78c7003e1f..21977aa46c 100644 --- a/src/components/ha-selector/ha-selector-entity.ts +++ b/src/components/ha-selector/ha-selector-entity.ts @@ -25,12 +25,15 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { @property() public label?: string; + @property({ type: Boolean }) public disabled = false; + protected render() { return html` this._filterEntities(entity)} + .disabled=${this.disabled} allow-custom-entity >`; } @@ -51,12 +54,12 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { } private _filterEntities(entity: HassEntity): boolean { - if (this.selector.entity.domain) { + if (this.selector.entity?.domain) { if (computeStateDomain(entity) !== this.selector.entity.domain) { return false; } } - if (this.selector.entity.device_class) { + if (this.selector.entity?.device_class) { if ( !entity.attributes.device_class || entity.attributes.device_class !== this.selector.entity.device_class @@ -64,7 +67,7 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { return false; } } - if (this.selector.entity.integration) { + if (this.selector.entity?.integration) { if ( !this._entityPlaformLookup || this._entityPlaformLookup[entity.entity_id] !== diff --git a/src/components/ha-selector/ha-selector-number.ts b/src/components/ha-selector/ha-selector-number.ts index 3a819cf9c5..6360ed1568 100644 --- a/src/components/ha-selector/ha-selector-number.ts +++ b/src/components/ha-selector/ha-selector-number.ts @@ -21,8 +21,12 @@ export class HaNumberSelector extends LitElement { @property() public value?: number; + @property() public placeholder?: number; + @property() public label?: string; + @property({ type: Boolean }) public disabled = false; + protected render() { return html`${this.label} ${this.selector.number.mode === "slider" @@ -31,6 +35,7 @@ export class HaNumberSelector extends LitElement { .max=${this.selector.number.max} .value=${this._value} .step=${this.selector.number.step} + .disabled=${this.disabled} pin ignore-bar-touch @change=${this._handleSliderChange} @@ -42,12 +47,14 @@ export class HaNumberSelector extends LitElement { .label=${this.selector.number.mode === "slider" ? undefined : this.label} + .placeholder=${this.placeholder} .noLabelFloat=${this.selector.number.mode === "slider"} class=${classMap({ single: this.selector.number.mode === "box" })} .min=${this.selector.number.min} .max=${this.selector.number.max} - .value=${this._value} + .value=${this.value} .step=${this.selector.number.step} + .disabled=${this.disabled} type="number" auto-validate @value-changed=${this._handleInputChange} @@ -65,16 +72,21 @@ export class HaNumberSelector extends LitElement { } private _handleInputChange(ev) { - const value = ev.detail.value; - if (this._value === value) { + ev.stopPropagation(); + const value = + ev.detail.value === "" || isNaN(ev.detail.value) + ? undefined + : Number(ev.detail.value); + if (this.value === value) { return; } fireEvent(this, "value-changed", { value }); } private _handleSliderChange(ev) { - const value = ev.target.value; - if (this._value === value) { + ev.stopPropagation(); + const value = Number(ev.target.value); + if (this.value === value) { return; } fireEvent(this, "value-changed", { value }); diff --git a/src/components/ha-selector/ha-selector-object.ts b/src/components/ha-selector/ha-selector-object.ts index 29159e3e8f..208bbaa6d4 100644 --- a/src/components/ha-selector/ha-selector-object.ts +++ b/src/components/ha-selector/ha-selector-object.ts @@ -11,8 +11,14 @@ export class HaObjectSelector extends LitElement { @property() public label?: string; + @property() public placeholder?: string; + + @property({ type: Boolean }) public disabled = false; + protected render() { return html``; diff --git a/src/components/ha-selector/ha-selector-select.ts b/src/components/ha-selector/ha-selector-select.ts new file mode 100644 index 0000000000..448138234d --- /dev/null +++ b/src/components/ha-selector/ha-selector-select.ts @@ -0,0 +1,78 @@ +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, +} from "lit-element"; +import { fireEvent } from "../../common/dom/fire_event"; +import { HomeAssistant } from "../../types"; +import { SelectSelector } from "../../data/selector"; +import "../ha-paper-dropdown-menu"; + +@customElement("ha-selector-select") +export class HaSelectSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: SelectSelector; + + @property() public value?: string; + + @property() public label?: string; + + @property({ type: Boolean }) public disabled = false; + + protected render() { + return html` + + ${this.selector.select.options.map( + (item: string) => html` + + ${item} + + ` + )} + + `; + } + + private _valueChanged(ev) { + if (this.disabled || !ev.detail.value) { + return; + } + fireEvent(this, "value-changed", { + value: ev.detail.value.itemValue, + }); + } + + static get styles(): CSSResult { + return css` + ha-paper-dropdown-menu { + width: 100%; + min-width: 200px; + display: block; + } + paper-listbox { + min-width: 200px; + } + paper-item { + cursor: pointer; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-select": HaSelectSelector; + } +} diff --git a/src/components/ha-selector/ha-selector-target.ts b/src/components/ha-selector/ha-selector-target.ts index 23c383e647..60c93c8857 100644 --- a/src/components/ha-selector/ha-selector-target.ts +++ b/src/components/ha-selector/ha-selector-target.ts @@ -3,7 +3,11 @@ import "@material/mwc-list/mwc-list-item"; import "@material/mwc-tab-bar/mwc-tab-bar"; import "@material/mwc-tab/mwc-tab"; import "@polymer/paper-input/paper-input"; -import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { + HassEntity, + HassServiceTarget, + UnsubscribeFunc, +} from "home-assistant-js-websocket"; import { css, CSSResult, @@ -20,7 +24,6 @@ import { subscribeEntityRegistry, } from "../../data/entity_registry"; import { TargetSelector } from "../../data/selector"; -import { Target } from "../../data/target"; import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { HomeAssistant } from "../../types"; import "../ha-target-picker"; @@ -31,7 +34,7 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { @property() public selector!: TargetSelector; - @property() public value?: Target; + @property() public value?: HassServiceTarget; @property() public label?: string; @@ -39,6 +42,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { @internalProperty() private _configEntries?: ConfigEntry[]; + @property({ type: Boolean }) public disabled = false; + public hassSubscribe(): UnsubscribeFunc[] { return [ subscribeEntityRegistry(this.hass.connection!, (entities) => { @@ -59,7 +64,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { const oldSelector = changedProperties.get("selector"); if ( oldSelector !== this.selector && - this.selector.target.device?.integration + (this.selector.target.device?.integration || + this.selector.target.entity?.integration) ) { this._loadConfigEntries(); } @@ -80,15 +86,20 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { .includeDomains=${this.selector.target.entity?.domain ? [this.selector.target.entity.domain] : undefined} + .disabled=${this.disabled} >`; } private _filterEntities(entity: HassEntity): boolean { - if (this.selector.target.entity?.integration) { + if ( + this.selector.target.entity?.integration || + this.selector.target.device?.integration + ) { if ( !this._entityPlaformLookup || this._entityPlaformLookup[entity.entity_id] !== - this.selector.target.entity.integration + (this.selector.target.entity?.integration || + this.selector.target.device?.integration) ) { return false; } @@ -118,7 +129,10 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { ) { return false; } - if (this.selector.target.device?.integration) { + if ( + this.selector.target.device?.integration || + this.selector.target.entity?.integration + ) { if ( !this._configEntries?.some((entry) => device.config_entries.includes(entry.entry_id) @@ -132,14 +146,16 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { private async _loadConfigEntries() { this._configEntries = (await getConfigEntries(this.hass)).filter( - (entry) => entry.domain === this.selector.target.device?.integration + (entry) => + entry.domain === + (this.selector.target.device?.integration || + this.selector.target.entity?.integration) ); } static get styles(): CSSResult { return css` ha-target-picker { - margin: 0 -8px; display: block; } `; diff --git a/src/components/ha-selector/ha-selector-text.ts b/src/components/ha-selector/ha-selector-text.ts index 32fa638ff0..9d2fbbd248 100644 --- a/src/components/ha-selector/ha-selector-text.ts +++ b/src/components/ha-selector/ha-selector-text.ts @@ -13,14 +13,20 @@ export class HaTextSelector extends LitElement { @property() public label?: string; + @property() public placeholder?: string; + @property() public selector!: StringSelector; + @property({ type: Boolean }) public disabled = false; + protected render() { if (this.selector.text?.multiline) { return html``; diff --git a/src/components/ha-selector/ha-selector-time.ts b/src/components/ha-selector/ha-selector-time.ts index 688b23dad3..f573773868 100644 --- a/src/components/ha-selector/ha-selector-time.ts +++ b/src/components/ha-selector/ha-selector-time.ts @@ -17,6 +17,8 @@ export class HaTimeSelector extends LitElement { @property() public label?: string; + @property({ type: Boolean }) public disabled = false; + protected render() { const parts = this.value?.split(":") || []; const hours = useAMPM ? parts[0] ?? "12" : parts[0] ?? "0"; @@ -29,6 +31,7 @@ export class HaTimeSelector extends LitElement { .sec=${parts[2] ?? "00"} .format=${useAMPM ? 12 : 24} .amPm=${useAMPM && (Number(hours) > 12 ? "PM" : "AM")} + .disabled=${this.disabled} @change=${this._timeChanged} @am-pm-changed=${this._timeChanged} hide-label diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 88d702c9d1..db071febdc 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -12,6 +12,7 @@ import "./ha-selector-target"; import "./ha-selector-time"; import "./ha-selector-object"; import "./ha-selector-text"; +import "./ha-selector-select"; @customElement("ha-selector") export class HaSelector extends LitElement { @@ -23,6 +24,10 @@ export class HaSelector extends LitElement { @property() public label?: string; + @property() public placeholder?: any; + + @property({ type: Boolean }) public disabled = false; + public focus() { const input = this.shadowRoot!.getElementById("selector"); if (!input) { @@ -42,6 +47,8 @@ export class HaSelector extends LitElement { selector: this.selector, value: this.value, label: this.label, + placeholder: this.placeholder, + disabled: this.disabled, id: "selector", })} `; diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts new file mode 100644 index 0000000000..3997bfcf52 --- /dev/null +++ b/src/components/ha-service-control.ts @@ -0,0 +1,407 @@ +import { HassService, HassServiceTarget } from "home-assistant-js-websocket"; +import { + css, + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, + PropertyValues, + query, +} from "lit-element"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { computeDomain } from "../common/entity/compute_domain"; +import { computeObjectId } from "../common/entity/compute_object_id"; +import { ENTITY_COMPONENT_DOMAINS } from "../data/entity"; +import { Selector } from "../data/selector"; +import { PolymerChangedEvent } from "../polymer-types"; +import { HomeAssistant } from "../types"; +import "./ha-selector/ha-selector"; +import "./ha-service-picker"; +import "./ha-settings-row"; +import "./ha-yaml-editor"; +import "./ha-checkbox"; +import type { HaYamlEditor } from "./ha-yaml-editor"; + +interface ExtHassService extends Omit { + fields: { + key: string; + name?: string; + description: string; + required?: boolean; + advanced?: boolean; + default?: any; + example?: any; + selector?: Selector; + }[]; +} + +@customElement("ha-service-control") +export class HaServiceControl extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: { + service: string; + target?: HassServiceTarget; + data?: Record; + }; + + @property({ reflect: true, type: Boolean }) public narrow!: boolean; + + @property({ type: Boolean }) public showAdvanced?: boolean; + + @internalProperty() private _serviceData?: ExtHassService; + + @internalProperty() private _checkedKeys = new Set(); + + @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; + + protected updated(changedProperties: PropertyValues) { + if (!changedProperties.has("value")) { + return; + } + const oldValue = changedProperties.get("value") as + | undefined + | this["value"]; + + if (oldValue?.service !== this.value?.service) { + this._checkedKeys = new Set(); + } + + this._serviceData = this.value?.service + ? this._getServiceInfo(this.value.service) + : undefined; + + if ( + this._serviceData && + "target" in this._serviceData && + (this.value?.data?.entity_id || + this.value?.data?.area_id || + this.value?.data?.device_id) + ) { + const target = { + ...this.value.target, + }; + + if (this.value.data.entity_id && !this.value.target?.entity_id) { + target.entity_id = this.value.data.entity_id; + } + if (this.value.data.area_id && !this.value.target?.area_id) { + target.area_id = this.value.data.area_id; + } + if (this.value.data.device_id && !this.value.target?.device_id) { + target.device_id = this.value.data.device_id; + } + + this.value = { + ...this.value, + target, + data: { ...this.value.data }, + }; + + delete this.value.data!.entity_id; + delete this.value.data!.device_id; + delete this.value.data!.area_id; + } + + if (this.value?.data) { + const yamlEditor = this._yamlEditor; + if (yamlEditor && yamlEditor.value !== this.value.data) { + yamlEditor.setValue(this.value.data); + } + } + } + + private _domainFilter = memoizeOne((service: string) => { + const domain = computeDomain(service); + return ENTITY_COMPONENT_DOMAINS.includes(domain) ? [domain] : null; + }); + + private _getServiceInfo = memoizeOne((service: string): + | ExtHassService + | undefined => { + if (!service) { + return undefined; + } + const domain = computeDomain(service); + const serviceName = computeObjectId(service); + const serviceDomains = this.hass.services; + if (!(domain in serviceDomains)) { + return undefined; + } + if (!(serviceName in serviceDomains[domain])) { + return undefined; + } + + const fields = Object.entries( + serviceDomains[domain][serviceName].fields + ).map(([key, value]) => { + return { + key, + ...value, + selector: value.selector as Selector | undefined, + }; + }); + return { + ...serviceDomains[domain][serviceName], + fields, + }; + }); + + protected render() { + const legacy = + this._serviceData?.fields.length && + !this._serviceData.fields.some((field) => field.selector); + + const entityId = + legacy && + this._serviceData?.fields.find((field) => field.key === "entity_id"); + + const hasOptional = Boolean( + !legacy && + this._serviceData?.fields.some( + (field) => field.selector && !field.required + ) + ); + + return html` +

${this._serviceData?.description}

+ ${this._serviceData && "target" in this._serviceData + ? html` + ${hasOptional + ? html`
` + : ""} + ${this.hass.localize( + "ui.components.service-control.target" + )} + ${this.hass.localize( + "ui.components.service-control.target_description" + )}
` + : entityId + ? html`` + : ""} + ${legacy + ? html`` + : this._serviceData?.fields.map((dataField) => + dataField.selector && (!dataField.advanced || this.showAdvanced) + ? html` + ${dataField.required + ? hasOptional + ? html`
` + : "" + : html``} + ${dataField.name || dataField.key} + ${dataField?.description}
` + : "" + )} `; + } + + private _checkboxChanged(ev) { + const checked = ev.currentTarget.checked; + const key = ev.currentTarget.key; + if (checked) { + this._checkedKeys.add(key); + } else { + this._checkedKeys.delete(key); + const data = { ...this.value?.data }; + + delete data[key]; + + fireEvent(this, "value-changed", { + value: { + ...this.value, + data, + }, + }); + } + this.requestUpdate("_checkedKeys"); + } + + private _serviceChanged(ev: PolymerChangedEvent) { + ev.stopPropagation(); + if (ev.detail.value === this.value?.service) { + return; + } + fireEvent(this, "value-changed", { + value: { service: ev.detail.value || "" }, + }); + } + + private _entityPicked(ev: CustomEvent) { + ev.stopPropagation(); + const newValue = ev.detail.value; + if (this.value?.data?.entity_id === newValue) { + return; + } + let value; + if (!newValue && this.value?.data) { + value = { ...this.value }; + delete value.data.entity_id; + } else { + value = { + ...this.value, + data: { ...this.value?.data, entity_id: ev.detail.value }, + }; + } + fireEvent(this, "value-changed", { + value, + }); + } + + private _targetChanged(ev: CustomEvent) { + ev.stopPropagation(); + const newValue = ev.detail.value; + if (this.value?.target === newValue) { + return; + } + let value; + if (!newValue) { + value = { ...this.value }; + delete value.target; + } else { + value = { ...this.value, target: ev.detail.value }; + } + fireEvent(this, "value-changed", { + value, + }); + } + + private _serviceDataChanged(ev: CustomEvent) { + ev.stopPropagation(); + const key = (ev.currentTarget as any).key; + const value = ev.detail.value; + if (this.value?.data && this.value.data[key] === value) { + return; + } + + const data = { ...this.value?.data, [key]: value }; + + if (value === "" || value === undefined) { + delete data[key]; + } + + fireEvent(this, "value-changed", { + value: { + ...this.value, + data, + }, + }); + } + + private _dataChanged(ev: CustomEvent) { + ev.stopPropagation(); + if (!ev.detail.isValid) { + return; + } + fireEvent(this, "value-changed", { + value: { + ...this.value, + data: ev.detail.value, + }, + }); + } + + static get styles(): CSSResult { + return css` + ha-settings-row { + padding: var(--service-control-padding, 0 16px); + } + ha-settings-row { + --paper-time-input-justify-content: flex-end; + border-top: var( + --service-control-items-border-top, + 1px solid var(--divider-color) + ); + } + ha-service-picker, + ha-entity-picker, + ha-yaml-editor { + display: block; + margin: var(--service-control-padding, 0 16px); + } + ha-yaml-editor { + padding: 16px 0; + } + p { + margin: var(--service-control-padding, 0 16px); + padding: 16px 0; + } + :host(:not([narrow])) ha-settings-row paper-input { + width: 60%; + } + :host(:not([narrow])) ha-settings-row ha-selector { + width: 60%; + } + .checkbox-spacer { + width: 32px; + } + ha-checkbox { + margin-left: -16px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-service-control": HaServiceControl; + } +} diff --git a/src/components/ha-service-picker.js b/src/components/ha-service-picker.js deleted file mode 100644 index 32aee922c9..0000000000 --- a/src/components/ha-service-picker.js +++ /dev/null @@ -1,60 +0,0 @@ -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import LocalizeMixin from "../mixins/localize-mixin"; -import "./ha-combo-box"; - -/* - * @appliesMixin LocalizeMixin - */ -class HaServicePicker extends LocalizeMixin(PolymerElement) { - static get template() { - return html` - - `; - } - - static get properties() { - return { - hass: { - type: Object, - observer: "_hassChanged", - }, - _services: Array, - value: { - type: String, - notify: true, - }, - }; - } - - _hassChanged(hass, oldHass) { - if (!hass) { - this._services = []; - return; - } - if (oldHass && hass.services === oldHass.services) { - return; - } - const result = []; - - Object.keys(hass.services) - .sort() - .forEach((domain) => { - const services = Object.keys(hass.services[domain]).sort(); - - for (let i = 0; i < services.length; i++) { - result.push(`${domain}.${services[i]}`); - } - }); - - this._services = result; - } -} - -customElements.define("ha-service-picker", HaServicePicker); diff --git a/src/components/ha-service-picker.ts b/src/components/ha-service-picker.ts new file mode 100644 index 0000000000..17bff6027f --- /dev/null +++ b/src/components/ha-service-picker.ts @@ -0,0 +1,135 @@ +import { html, internalProperty, LitElement, property } from "lit-element"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { LocalizeFunc } from "../common/translations/localize"; +import { domainToName } from "../data/integration"; +import { HomeAssistant } from "../types"; +import "./ha-combo-box"; + +const rowRenderer = ( + root: HTMLElement, + _owner, + model: { item: { service: string; name: string } } +) => { + if (!root.firstElementChild) { + root.innerHTML = ` + + + +
[[item.name]]
+
[[item.service]]
+
+
+ `; + } + + root.querySelector(".name")!.textContent = model.item.name; + root.querySelector("[secondary]")!.textContent = + model.item.name === model.item.service ? "" : model.item.service; +}; + +class HaServicePicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public value?: string; + + @internalProperty() private _filter?: string; + + protected render() { + return html` + + `; + } + + private _services = memoizeOne( + ( + localize: LocalizeFunc, + services: HomeAssistant["services"] + ): { + service: string; + name: string; + }[] => { + if (!services) { + return []; + } + const result: { service: string; name: string }[] = []; + + Object.keys(services) + .sort() + .forEach((domain) => { + const services_keys = Object.keys(services[domain]).sort(); + + for (const service of services_keys) { + result.push({ + service: `${domain}.${service}`, + name: `${domainToName(localize, domain)}: ${ + services[domain][service].name || service + }`, + }); + } + }); + + return result; + } + ); + + private _filteredServices = memoizeOne( + ( + localize: LocalizeFunc, + services: HomeAssistant["services"], + filter?: string + ) => { + if (!services) { + return []; + } + const processedServices = this._services(localize, services); + + if (!filter) { + return processedServices; + } + return processedServices.filter( + (service) => + service.service.toLowerCase().includes(filter) || + service.name?.toLowerCase().includes(filter) + ); + } + ); + + private _filterChanged(ev: CustomEvent): void { + this._filter = ev.detail.value.toLowerCase(); + } + + private _valueChanged(ev) { + this.value = ev.detail.value; + fireEvent(this, "change"); + fireEvent(this, "value-changed", { value: this.value }); + } +} + +customElements.define("ha-service-picker", HaServicePicker); + +declare global { + interface HTMLElementTagNameMap { + "ha-service-picker": HaServicePicker; + } +} diff --git a/src/components/ha-settings-row.ts b/src/components/ha-settings-row.ts index efa3b95cf4..12f37bfe79 100644 --- a/src/components/ha-settings-row.ts +++ b/src/components/ha-settings-row.ts @@ -6,7 +6,7 @@ import { html, LitElement, property, - SVGTemplateResult, + TemplateResult, } from "lit-element"; @customElement("ha-settings-row") @@ -16,15 +16,18 @@ export class HaSettingsRow extends LitElement { @property({ type: Boolean, attribute: "three-line" }) public threeLine = false; - protected render(): SVGTemplateResult { + protected render(): TemplateResult { return html` - - -
-
+
+ + + +
+
+
`; } @@ -45,6 +48,7 @@ export class HaSettingsRow extends LitElement { min-height: calc( var(--paper-item-body-two-line-min-height, 72px) - 16px ); + flex: 1; } :host([narrow]) { align-items: normal; @@ -58,6 +62,13 @@ export class HaSettingsRow extends LitElement { div[secondary] { white-space: normal; } + .prefix-wrap { + display: contents; + } + :host([narrow]) .prefix-wrap { + display: flex; + align-items: center; + } `; } } diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index c324b6b9eb..574c1b36f2 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -10,7 +10,10 @@ import { mdiUnfoldMoreVertical, } from "@mdi/js"; import "@polymer/paper-tooltip/paper-tooltip"; -import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { + HassServiceTarget, + UnsubscribeFunc, +} from "home-assistant-js-websocket"; import { css, CSSResult, @@ -41,7 +44,6 @@ import { EntityRegistryEntry, subscribeEntityRegistry, } from "../data/entity_registry"; -import { Target } from "../data/target"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { HomeAssistant } from "../types"; import "./device/ha-device-picker"; @@ -56,7 +58,7 @@ import "./ha-svg-icon"; export class HaTargetPicker extends SubscribeMixin(LitElement) { @property() public hass!: HomeAssistant; - @property() public value?: Target; + @property() public value?: HassServiceTarget; @property() public label?: string; @@ -82,6 +84,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { @property() public entityFilter?: HaEntityPickerEntityFilterFunc; + @property({ type: Boolean, reflect: true }) public disabled = false; + @internalProperty() private _areas?: { [areaId: string]: AreaRegistryEntry }; @internalProperty() private _devices?: { @@ -436,7 +440,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { type: string, id: string ): this["value"] { - const newVal = ensureArray(value![type])!.filter((val) => val !== id); + const newVal = ensureArray(value![type])!.filter( + (val) => String(val) !== id + ); if (newVal.length) { return { ...value, @@ -530,6 +536,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .items { z-index: 2; } + .mdc-chip-set { + padding: 4px 0; + } .mdc-chip.add { color: rgba(0, 0, 0, 0.87); } @@ -594,6 +603,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { paper-tooltip.expand { min-width: 200px; } + :host([disabled]) .mdc-chip { + opacity: var(--light-disabled-opacity); + pointer-events: none; + } `; } } diff --git a/src/components/ha-yaml-editor.ts b/src/components/ha-yaml-editor.ts index 473a7dd41a..2c300f4433 100644 --- a/src/components/ha-yaml-editor.ts +++ b/src/components/ha-yaml-editor.ts @@ -44,14 +44,14 @@ export class HaYamlEditor extends LitElement { @internalProperty() private _yaml = ""; - @query("ha-code-editor", true) private _editor?: HaCodeEditor; + @query("ha-code-editor") private _editor?: HaCodeEditor; public setValue(value): void { try { this._yaml = value && !isEmpty(value) ? safeDump(value) : ""; } catch (err) { // eslint-disable-next-line no-console - console.error(err); + console.error(err, value); alert(`There was an error converting to YAML: ${err}`); } afterNextRender(() => { @@ -73,7 +73,7 @@ export class HaYamlEditor extends LitElement { return html``; } return html` - ${this.label ? html`

${this.label}

` : ""} + ${this.label ? html`

${this.label}

` : ""} export const getConfigFlowHandlers = (hass: HomeAssistant) => hass.callApi("GET", "config/config_entries/flow_handlers"); -const fetchConfigFlowInProgress = (conn) => +export const fetchConfigFlowInProgress = ( + conn: Connection +): Promise => conn.sendMessagePromise({ type: "config_entries/flow/progress", }); -const subscribeConfigFlowInProgressUpdates = (conn, store) => +const subscribeConfigFlowInProgressUpdates = (conn: Connection, store) => conn.subscribeEvents( debounce( () => - fetchConfigFlowInProgress(conn).then((flows) => + fetchConfigFlowInProgress(conn).then((flows: DataEntryFlowProgress[]) => store.setState(flows, true) ), 500, diff --git a/src/data/hassio/addon.ts b/src/data/hassio/addon.ts index 0429734bbd..b17f327097 100644 --- a/src/data/hassio/addon.ts +++ b/src/data/hassio/addon.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HaFormSchema } from "../../components/ha-form/ha-form"; import { HomeAssistant } from "../../types"; import { SupervisorArch } from "../supervisor/supervisor"; @@ -102,10 +103,28 @@ export interface HassioAddonSetOptionParams { } export const reloadHassioAddons = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/addons/reload", + method: "post", + }); + return; + } await hass.callApi>("POST", `hassio/addons/reload`); }; -export const fetchHassioAddonsInfo = async (hass: HomeAssistant) => { +export const fetchHassioAddonsInfo = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/addons", + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>("GET", `hassio/addons`) ); @@ -114,7 +133,15 @@ export const fetchHassioAddonsInfo = async (hass: HomeAssistant) => { export const fetchHassioAddonInfo = async ( hass: HomeAssistant, slug: string -) => { +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/info`, + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", @@ -149,6 +176,16 @@ export const setHassioAddonOption = async ( slug: string, data: HassioAddonSetOptionParams ) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/options`, + method: "post", + data, + }); + return; + } + await hass.callApi>( "POST", `hassio/addons/${slug}/options`, @@ -159,21 +196,64 @@ export const setHassioAddonOption = async ( export const validateHassioAddonOption = async ( hass: HomeAssistant, slug: string -) => { - return await hass.callApi< - HassioResponse<{ message: string; valid: boolean }> - >("POST", `hassio/addons/${slug}/options/validate`); +): Promise<{ message: string; valid: boolean }> => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/options/validate`, + method: "post", + }); + } + + return ( + await hass.callApi>( + "POST", + `hassio/addons/${slug}/options/validate` + ) + ).data; }; export const startHassioAddon = async (hass: HomeAssistant, slug: string) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/start`, + method: "post", + timeout: null, + }); + } + return hass.callApi("POST", `hassio/addons/${slug}/start`); }; +export const stopHassioAddon = async (hass: HomeAssistant, slug: string) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/stop`, + method: "post", + timeout: null, + }); + } + + return hass.callApi("POST", `hassio/addons/${slug}/stop`); +}; + export const setHassioAddonSecurity = async ( hass: HomeAssistant, slug: string, data: HassioAddonSetSecurityParams ) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/security`, + method: "post", + data, + }); + return; + } + await hass.callApi>( "POST", `hassio/addons/${slug}/security`, @@ -181,15 +261,61 @@ export const setHassioAddonSecurity = async ( ); }; -export const installHassioAddon = async (hass: HomeAssistant, slug: string) => { - return hass.callApi>( +export const installHassioAddon = async ( + hass: HomeAssistant, + slug: string +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/install`, + method: "post", + timeout: null, + }); + return; + } + + await hass.callApi>( "POST", `hassio/addons/${slug}/install` ); }; -export const restartHassioAddon = async (hass: HomeAssistant, slug: string) => { - return hass.callApi>( +export const updateHassioAddon = async ( + hass: HomeAssistant, + slug: string +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/update`, + method: "post", + timeout: null, + }); + return; + } + + await hass.callApi>( + "POST", + `hassio/addons/${slug}/update` + ); +}; + +export const restartHassioAddon = async ( + hass: HomeAssistant, + slug: string +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/restart`, + method: "post", + timeout: null, + }); + return; + } + + await hass.callApi>( "POST", `hassio/addons/${slug}/restart` ); @@ -199,6 +325,16 @@ export const uninstallHassioAddon = async ( hass: HomeAssistant, slug: string ) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: `/addons/${slug}/uninstall`, + method: "post", + timeout: null, + }); + return; + } + await hass.callApi>( "POST", `hassio/addons/${slug}/uninstall` diff --git a/src/data/hassio/common.ts b/src/data/hassio/common.ts index 57a0afa59a..de6a28c68b 100644 --- a/src/data/hassio/common.ts +++ b/src/data/hassio/common.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; export interface HassioResponse { @@ -33,6 +34,14 @@ export const fetchHassioStats = async ( hass: HomeAssistant, container: string ): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/${container}/stats`, + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", diff --git a/src/data/hassio/docker.ts b/src/data/hassio/docker.ts index 4bc9a194c5..c8884336e0 100644 --- a/src/data/hassio/docker.ts +++ b/src/data/hassio/docker.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; import { hassioApiResultExtractor, HassioResponse } from "./common"; @@ -5,7 +6,17 @@ interface HassioDockerRegistries { [key: string]: { username: string; password?: string }; } -export const fetchHassioDockerRegistries = async (hass: HomeAssistant) => { +export const fetchHassioDockerRegistries = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/docker/registries`, + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", @@ -18,6 +29,16 @@ export const addHassioDockerRegistry = async ( hass: HomeAssistant, data: HassioDockerRegistries ) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: `/docker/registries`, + method: "post", + data, + }); + return; + } + await hass.callApi>( "POST", "hassio/docker/registries", @@ -29,6 +50,15 @@ export const removeHassioDockerRegistry = async ( hass: HomeAssistant, registry: string ) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: `/docker/registries/${registry}`, + method: "delete", + }); + return; + } + await hass.callApi>( "DELETE", `hassio/docker/registries/${registry}` diff --git a/src/data/hassio/hardware.ts b/src/data/hassio/hardware.ts index 2df7a8bcdd..11e70e8b69 100644 --- a/src/data/hassio/hardware.ts +++ b/src/data/hassio/hardware.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; import { hassioApiResultExtractor, HassioResponse } from "./common"; @@ -21,7 +22,17 @@ export interface HassioHardwareInfo { audio: Record; } -export const fetchHassioHardwareAudio = async (hass: HomeAssistant) => { +export const fetchHassioHardwareAudio = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/hardware/audio`, + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", @@ -30,7 +41,17 @@ export const fetchHassioHardwareAudio = async (hass: HomeAssistant) => { ); }; -export const fetchHassioHardwareInfo = async (hass: HomeAssistant) => { +export const fetchHassioHardwareInfo = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/hardware/info`, + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", diff --git a/src/data/hassio/host.ts b/src/data/hassio/host.ts index 79e518b4c9..2718adbcb4 100644 --- a/src/data/hassio/host.ts +++ b/src/data/hassio/host.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; import { hassioApiResultExtractor, HassioResponse } from "./common"; @@ -23,7 +24,17 @@ export interface HassioHassOSInfo { version: string | null; } -export const fetchHassioHostInfo = async (hass: HomeAssistant) => { +export const fetchHassioHostInfo = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/host/info", + method: "get", + }); + } + const response = await hass.callApi>( "GET", "hassio/host/info" @@ -31,7 +42,17 @@ export const fetchHassioHostInfo = async (hass: HomeAssistant) => { return hassioApiResultExtractor(response); }; -export const fetchHassioHassOsInfo = async (hass: HomeAssistant) => { +export const fetchHassioHassOsInfo = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/os/info", + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", @@ -41,22 +62,67 @@ export const fetchHassioHassOsInfo = async (hass: HomeAssistant) => { }; export const rebootHost = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/host/reboot", + method: "post", + timeout: null, + }); + } + return hass.callApi>("POST", "hassio/host/reboot"); }; export const shutdownHost = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/host/shutdown", + method: "post", + timeout: null, + }); + } + return hass.callApi>("POST", "hassio/host/shutdown"); }; export const updateOS = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/os/update", + method: "post", + timeout: null, + }); + } + return hass.callApi>("POST", "hassio/os/update"); }; export const configSyncOS = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "os/config/sync", + method: "post", + timeout: null, + }); + } + return hass.callApi>("POST", "hassio/os/config/sync"); }; export const changeHostOptions = async (hass: HomeAssistant, options: any) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/host/options", + method: "post", + data: options, + }); + } + return hass.callApi>( "POST", "hassio/host/options", diff --git a/src/data/hassio/ingress.ts b/src/data/hassio/ingress.ts index ced84a2698..30a6e68e23 100644 --- a/src/data/hassio/ingress.ts +++ b/src/data/hassio/ingress.ts @@ -1,26 +1,50 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; import { HassioResponse } from "./common"; import { CreateSessionResponse } from "./supervisor"; -export const createHassioSession = async (hass: HomeAssistant) => { - const response = await hass.callApi>( - "POST", - "hassio/ingress/session" - ); - document.cookie = `ingress_session=${ - response.data.session - };path=/api/hassio_ingress/;SameSite=Strict${ +function setIngressCookie(session: string): string { + document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${ location.protocol === "https:" ? ";Secure" : "" }`; - return response.data.session; + return session; +} + +export const createHassioSession = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + const wsResponse: { session: string } = await hass.callWS({ + type: "supervisor/api", + endpoint: "/ingress/session", + method: "post", + }); + return setIngressCookie(wsResponse.session); + } + + const restResponse: { data: { session: string } } = await hass.callApi< + HassioResponse + >("POST", "hassio/ingress/session"); + return setIngressCookie(restResponse.data.session); }; export const validateHassioSession = async ( hass: HomeAssistant, session: string -) => - await hass.callApi>( +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/ingress/validate_session", + method: "post", + data: { session }, + }); + return; + } + + await hass.callApi>( "POST", "hassio/ingress/validate_session", { session } ); +}; diff --git a/src/data/hassio/network.ts b/src/data/hassio/network.ts index 542ee25d95..5aef6cc336 100644 --- a/src/data/hassio/network.ts +++ b/src/data/hassio/network.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; import { hassioApiResultExtractor, HassioResponse } from "./common"; @@ -51,7 +52,17 @@ export interface NetworkInfo { docker: DockerNetwork; } -export const fetchNetworkInfo = async (hass: HomeAssistant) => { +export const fetchNetworkInfo = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/network/info", + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", @@ -65,6 +76,17 @@ export const updateNetworkInterface = async ( network_interface: string, options: Partial ) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: `/network/interface/${network_interface}/update`, + method: "post", + data: options, + timeout: null, + }); + return; + } + await hass.callApi>( "POST", `hassio/network/interface/${network_interface}/update`, @@ -75,7 +97,16 @@ export const updateNetworkInterface = async ( export const accesspointScan = async ( hass: HomeAssistant, network_interface: string -) => { +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/network/interface/${network_interface}/accesspoints`, + method: "get", + timeout: null, + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", diff --git a/src/data/hassio/resolution.ts b/src/data/hassio/resolution.ts index 677404f551..f108083cc5 100644 --- a/src/data/hassio/resolution.ts +++ b/src/data/hassio/resolution.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; import { hassioApiResultExtractor, HassioResponse } from "./common"; @@ -8,7 +9,17 @@ export interface HassioResolution { suggestions: string[]; } -export const fetchHassioResolution = async (hass: HomeAssistant) => { +export const fetchHassioResolution = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/resolution/info", + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", diff --git a/src/data/hassio/snapshot.ts b/src/data/hassio/snapshot.ts index 18157d1255..ecd82ded1c 100644 --- a/src/data/hassio/snapshot.ts +++ b/src/data/hassio/snapshot.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; import { hassioApiResultExtractor, HassioResponse } from "./common"; @@ -33,7 +34,18 @@ export interface HassioPartialSnapshotCreateParams { password?: string; } -export const fetchHassioSnapshots = async (hass: HomeAssistant) => { +export const fetchHassioSnapshots = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + const data: { snapshots: HassioSnapshot[] } = await hass.callWS({ + type: "supervisor/api", + endpoint: `/snapshots`, + method: "get", + }); + return data.snapshots; + } + return hassioApiResultExtractor( await hass.callApi>( "GET", @@ -45,8 +57,15 @@ export const fetchHassioSnapshots = async (hass: HomeAssistant) => { export const fetchHassioSnapshotInfo = async ( hass: HomeAssistant, snapshot: string -) => { +): Promise => { if (hass) { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: `/snapshots/${snapshot}/info`, + method: "get", + }); + } return hassioApiResultExtractor( await hass.callApi>( "GET", @@ -63,6 +82,15 @@ export const fetchHassioSnapshotInfo = async ( }; export const reloadHassioSnapshots = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/snapshots/reload", + method: "post", + }); + return; + } + await hass.callApi>("POST", `hassio/snapshots/reload`); }; @@ -70,6 +98,15 @@ export const createHassioFullSnapshot = async ( hass: HomeAssistant, data: HassioFullSnapshotCreateParams ) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/snapshots/new/full", + method: "post", + timeout: null, + }); + return; + } await hass.callApi>( "POST", `hassio/snapshots/new/full`, @@ -81,6 +118,17 @@ export const createHassioPartialSnapshot = async ( hass: HomeAssistant, data: HassioFullSnapshotCreateParams ) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/snapshots/new/partial", + method: "post", + timeout: null, + data, + }); + return; + } + await hass.callApi>( "POST", `hassio/snapshots/new/partial`, diff --git a/src/data/hassio/supervisor.ts b/src/data/hassio/supervisor.ts index 0db84b3d6c..f7ce56a64e 100644 --- a/src/data/hassio/supervisor.ts +++ b/src/data/hassio/supervisor.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant, PanelInfo } from "../../types"; import { SupervisorArch } from "../supervisor/supervisor"; import { HassioAddonInfo, HassioAddonRepository } from "./addon"; @@ -49,6 +50,15 @@ export type HassioInfo = { hostname: string; logging: string; machine: string; + state: + | "initialize" + | "setup" + | "startup" + | "running" + | "freeze" + | "shutdown" + | "stopping" + | "close"; operating_system: string; supervisor: string; supported: boolean; @@ -74,18 +84,57 @@ export interface SupervisorOptions { } export const reloadSupervisor = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/supervisor/reload", + method: "post", + }); + return; + } + await hass.callApi>("POST", `hassio/supervisor/reload`); }; export const restartSupervisor = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/supervisor/restart", + method: "post", + timeout: null, + }); + return; + } + await hass.callApi>("POST", `hassio/supervisor/restart`); }; export const updateSupervisor = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/supervisor/update", + method: "post", + timeout: null, + }); + return; + } + await hass.callApi>("POST", `hassio/supervisor/update`); }; -export const fetchHassioHomeAssistantInfo = async (hass: HomeAssistant) => { +export const fetchHassioHomeAssistantInfo = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/core/info", + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", @@ -94,7 +143,17 @@ export const fetchHassioHomeAssistantInfo = async (hass: HomeAssistant) => { ); }; -export const fetchHassioSupervisorInfo = async (hass: HomeAssistant) => { +export const fetchHassioSupervisorInfo = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/supervisor/info", + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>( "GET", @@ -103,7 +162,17 @@ export const fetchHassioSupervisorInfo = async (hass: HomeAssistant) => { ); }; -export const fetchHassioInfo = async (hass: HomeAssistant) => { +export const fetchHassioInfo = async ( + hass: HomeAssistant +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return await hass.callWS({ + type: "supervisor/api", + endpoint: "/info", + method: "get", + }); + } + return hassioApiResultExtractor( await hass.callApi>("GET", "hassio/info") ); @@ -120,6 +189,16 @@ export const setSupervisorOption = async ( hass: HomeAssistant, data: SupervisorOptions ) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/supervisor/options", + method: "post", + data, + }); + return; + } + await hass.callApi>( "POST", "hassio/supervisor/options", diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index 090bb0dfc0..447b803dec 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -2,6 +2,7 @@ import { Connection, getCollection, HassEventBase, + HassServiceTarget, } from "home-assistant-js-websocket"; import { HASSDomEvent } from "../common/dom/fire_event"; import { HuiErrorCard } from "../panels/lovelace/cards/hui-error-card"; @@ -120,8 +121,8 @@ export interface ToggleActionConfig extends BaseActionConfig { export interface CallServiceActionConfig extends BaseActionConfig { action: "call-service"; service: string; + target?: HassServiceTarget; service_data?: { - entity_id?: string | [string]; [key: string]: any; }; } diff --git a/src/data/script.ts b/src/data/script.ts index e528754f5e..c4b518e820 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -1,6 +1,7 @@ import { HassEntityAttributeBase, HassEntityBase, + HassServiceTarget, } from "home-assistant-js-websocket"; import { computeObjectId } from "../common/entity/compute_object_id"; import { navigate } from "../common/navigate"; @@ -36,6 +37,7 @@ export interface EventAction { export interface ServiceAction { service: string; entity_id?: string; + target?: HassServiceTarget; data?: Record; } diff --git a/src/data/selector.ts b/src/data/selector.ts index e045ce8b88..a3eedee92d 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -8,8 +8,8 @@ export type Selector = | TimeSelector | ActionSelector | StringSelector - | ObjectSelector; - + | ObjectSelector + | SelectSelector; export interface EntitySelector { entity: { integration?: string; @@ -95,3 +95,9 @@ export interface ObjectSelector { // eslint-disable-next-line @typescript-eslint/ban-types object: {}; } + +export interface SelectSelector { + select: { + options: string[]; + }; +} diff --git a/src/data/supervisor/core.ts b/src/data/supervisor/core.ts index 611fbabd36..0191dbe278 100644 --- a/src/data/supervisor/core.ts +++ b/src/data/supervisor/core.ts @@ -1,3 +1,4 @@ +import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; import { HassioResponse } from "../hassio/common"; @@ -6,5 +7,15 @@ export const restartCore = async (hass: HomeAssistant) => { }; export const updateCore = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + await hass.callWS({ + type: "supervisor/api", + endpoint: "/core/update", + method: "post", + timeout: null, + }); + return; + } + await hass.callApi>("POST", `hassio/core/update`); }; diff --git a/src/data/supervisor/supervisor.ts b/src/data/supervisor/supervisor.ts index bc0161becf..1dbaecef2c 100644 --- a/src/data/supervisor/supervisor.ts +++ b/src/data/supervisor/supervisor.ts @@ -1,3 +1,7 @@ +import { Connection, getCollection } from "home-assistant-js-websocket"; +import { Store } from "home-assistant-js-websocket/dist/store"; +import { HomeAssistant } from "../../types"; +import { HassioAddonsInfo } from "../hassio/addon"; import { HassioHassOSInfo, HassioHostInfo } from "../hassio/host"; import { NetworkInfo } from "../hassio/network"; import { HassioResolution } from "../hassio/resolution"; @@ -7,7 +11,46 @@ import { HassioSupervisorInfo, } from "../hassio/supervisor"; +export const supervisorWSbaseCommand = { + type: "supervisor/api", + method: "GET", +}; + +export const supervisorStore = { + host: "/host/info", + supervisor: "/supervisor/info", + info: "/info", + core: "/core/info", + network: "/network/info", + resolution: "/resolution/info", + os: "/os/info", + addon: "/addons", +}; + export type SupervisorArch = "armhf" | "armv7" | "aarch64" | "i386" | "amd64"; +export type SupervisorObject = + | "host" + | "supervisor" + | "info" + | "core" + | "network" + | "resolution" + | "os" + | "addon"; + +interface supervisorApiRequest { + endpoint: string; + method?: "get" | "post" | "delete" | "put"; + force_rest?: boolean; + data?: any; +} + +export interface SupervisorEvent { + event: string; + update_key?: SupervisorObject; + data?: any; + [key: string]: any; +} export interface Supervisor { host: HassioHostInfo; @@ -17,4 +60,77 @@ export interface Supervisor { network: NetworkInfo; resolution: HassioResolution; os: HassioHassOSInfo; + addon: HassioAddonsInfo; } + +export const supervisorApiWsRequest = ( + conn: Connection, + request: supervisorApiRequest +): Promise => + conn.sendMessagePromise({ ...supervisorWSbaseCommand, ...request }); + +async function processEvent( + conn: Connection, + store: Store, + event: SupervisorEvent, + key: string +) { + if ( + !event.data || + event.data.event !== "supervisor-update" || + event.data.update_key !== key + ) { + return; + } + + if (Object.keys(event.data.data).length === 0) { + const data = await supervisorApiWsRequest(conn, { + endpoint: supervisorStore[key], + }); + store.setState(data); + return; + } + + const state = store.state; + if (state === undefined) { + return; + } + + store.setState({ + ...state, + ...event.data.data, + }); +} + +const subscribeSupervisorEventUpdates = ( + conn: Connection, + store: Store, + key: string +) => + conn.subscribeEvents( + (event) => processEvent(conn, store, event as SupervisorEvent, key), + "supervisor_event" + ); + +export const getSupervisorEventCollection = ( + conn: Connection, + key: string, + endpoint: string +) => + getCollection( + conn, + `_supervisor${key}Event`, + () => supervisorApiWsRequest(conn, { endpoint }), + (connection, store) => + subscribeSupervisorEventUpdates(connection, store, key) + ); + +export const subscribeSupervisorEvents = ( + hass: HomeAssistant, + onChange: (event) => void, + key: string, + endpoint: string +) => + getSupervisorEventCollection(hass.connection, key, endpoint).subscribe( + onChange + ); diff --git a/src/data/target.ts b/src/data/target.ts deleted file mode 100644 index afddff0688..0000000000 --- a/src/data/target.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Target { - entity_id?: string[]; - device_id?: string[]; - area_id?: string[]; -} diff --git a/src/dialogs/config-flow/dialog-data-entry-flow.ts b/src/dialogs/config-flow/dialog-data-entry-flow.ts index 206276ce50..76ac0d966b 100644 --- a/src/dialogs/config-flow/dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/dialog-data-entry-flow.ts @@ -22,7 +22,9 @@ import { AreaRegistryEntry, subscribeAreaRegistry, } from "../../data/area_registry"; +import { fetchConfigFlowInProgress } from "../../data/config_flow"; import type { + DataEntryFlowProgress, DataEntryFlowProgressedEvent, DataEntryFlowStep, } from "../../data/data_entry_flow"; @@ -41,6 +43,7 @@ import "./step-flow-form"; import "./step-flow-loading"; import "./step-flow-pick-handler"; import "./step-flow-progress"; +import "./step-flow-pick-flow"; let instance = 0; @@ -76,6 +79,10 @@ class DataEntryFlowDialog extends LitElement { @internalProperty() private _handlers?: string[]; + @internalProperty() private _handler?: string; + + @internalProperty() private _flowsInProgress?: DataEntryFlowProgress[]; + private _unsubAreas?: UnsubscribeFunc; private _unsubDevices?: UnsubscribeFunc; @@ -84,59 +91,93 @@ class DataEntryFlowDialog extends LitElement { this._params = params; this._instance = instance++; + if (params.startFlowHandler) { + this._checkFlowsInProgress(params.startFlowHandler); + return; + } + + if (params.continueFlowId) { + this._loading = true; + const curInstance = this._instance; + let step: DataEntryFlowStep; + try { + step = await params.flowConfig.fetchFlow( + this.hass, + params.continueFlowId + ); + } catch (err) { + this._step = undefined; + this._params = undefined; + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_flow.error" + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_flow.could_not_load" + ), + }); + return; + } + + // Happens if second showDialog called + if (curInstance !== this._instance) { + return; + } + + this._processStep(step); + this._loading = false; + return; + } + // Create a new config flow. Show picker - if (!params.continueFlowId && !params.startFlowHandler) { - if (!params.flowConfig.getFlowHandlers) { - throw new Error("No getFlowHandlers defined in flow config"); + if (!params.flowConfig.getFlowHandlers) { + throw new Error("No getFlowHandlers defined in flow config"); + } + this._step = null; + + // We only load the handlers once + if (this._handlers === undefined) { + this._loading = true; + try { + this._handlers = await params.flowConfig.getFlowHandlers(this.hass); + } finally { + this._loading = false; } - this._step = null; - - // We only load the handlers once - if (this._handlers === undefined) { - this._loading = true; - try { - this._handlers = await params.flowConfig.getFlowHandlers(this.hass); - } finally { - this._loading = false; - } - } - await this.updateComplete; - return; } - - this._loading = true; - const curInstance = this._instance; - let step: DataEntryFlowStep; - try { - step = await (params.continueFlowId - ? params.flowConfig.fetchFlow(this.hass, params.continueFlowId) - : params.flowConfig.createFlow(this.hass, params.startFlowHandler!)); - } catch (err) { - this._step = undefined; - this._params = undefined; - showAlertDialog(this, { - title: "Error", - text: "Config flow could not be loaded", - }); - return; - } - - // Happens if second showDialog called - if (curInstance !== this._instance) { - return; - } - - this._processStep(step); - this._loading = false; + await this.updateComplete; } public closeDialog() { - if (this._step) { - this._flowDone(); - } else if (this._step === null) { - // Flow aborted during picking flow - this._step = undefined; - this._params = undefined; + if (!this._params) { + return; + } + const flowFinished = Boolean( + this._step && ["create_entry", "abort"].includes(this._step.type) + ); + + // If we created this flow, delete it now. + if (this._step && !flowFinished && !this._params.continueFlowId) { + this._params.flowConfig.deleteFlow(this.hass, this._step.flow_id); + } + + if (this._step !== null && this._params.dialogClosedCallback) { + this._params.dialogClosedCallback({ + flowFinished, + }); + } + + this._step = undefined; + this._params = undefined; + this._devices = undefined; + this._flowsInProgress = undefined; + this._handler = undefined; + if (this._unsubAreas) { + this._unsubAreas(); + this._unsubAreas = undefined; + } + if (this._unsubDevices) { + this._unsubDevices(); + this._unsubDevices = undefined; } fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -156,7 +197,9 @@ class DataEntryFlowDialog extends LitElement { >
${this._loading || - (this._step === null && this._handlers === undefined) + (this._step === null && + this._handlers === undefined && + this._handler === undefined) ? html` ${this._step === null - ? // Show handler picker - html` - - ` + .handler=${this._handler} + .flowsInProgress=${this._flowsInProgress} + >` + : // Show handler picker + html` + + ` : this._step.type === "form" ? html` flow.handler === handler); + + if (!flowsInProgress.length) { + let step: DataEntryFlowStep; + try { + step = await this._params!.flowConfig.createFlow(this.hass, handler); + } catch (err) { + this._step = undefined; + this._params = undefined; + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_flow.error" + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_flow.could_not_load" + ), + }); + return; + } + this._processStep(step); + } else { + this._step = null; + this._handler = handler; + this._flowsInProgress = flowsInProgress; + } + this._loading = false; + } + + private _handlerPicked(ev) { + this._checkFlowsInProgress(ev.detail.handler); + } + private async _processStep( step: DataEntryFlowStep | undefined | Promise ): Promise { @@ -305,7 +392,7 @@ class DataEntryFlowDialog extends LitElement { } if (step === undefined) { - this._flowDone(); + this.closeDialog(); return; } this._step = undefined; @@ -313,38 +400,6 @@ class DataEntryFlowDialog extends LitElement { this._step = step; } - private _flowDone(): void { - if (!this._params) { - return; - } - const flowFinished = Boolean( - this._step && ["create_entry", "abort"].includes(this._step.type) - ); - - // If we created this flow, delete it now. - if (this._step && !flowFinished && !this._params.continueFlowId) { - this._params.flowConfig.deleteFlow(this.hass, this._step.flow_id); - } - - if (this._params.dialogClosedCallback) { - this._params.dialogClosedCallback({ - flowFinished, - }); - } - - this._step = undefined; - this._params = undefined; - this._devices = undefined; - if (this._unsubAreas) { - this._unsubAreas(); - this._unsubAreas = undefined; - } - if (this._unsubDevices) { - this._unsubDevices(); - this._unsubDevices = undefined; - } - } - static get styles(): CSSResultArray { return [ haStyleDialog, diff --git a/src/dialogs/config-flow/step-flow-pick-flow.ts b/src/dialogs/config-flow/step-flow-pick-flow.ts new file mode 100644 index 0000000000..b75f2e3b38 --- /dev/null +++ b/src/dialogs/config-flow/step-flow-pick-flow.ts @@ -0,0 +1,130 @@ +import "@polymer/paper-item/paper-icon-item"; +import "@polymer/paper-item"; +import "@polymer/paper-item/paper-item-body"; +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-icon-next"; +import { localizeConfigFlowTitle } from "../../data/config_flow"; +import { DataEntryFlowProgress } from "../../data/data_entry_flow"; +import { domainToName } from "../../data/integration"; +import { HomeAssistant } from "../../types"; +import { brandsUrl } from "../../util/brands-url"; +import { FlowConfig } from "./show-dialog-data-entry-flow"; +import { configFlowContentStyles } from "./styles"; + +@customElement("step-flow-pick-flow") +class StepFlowPickFlow extends LitElement { + public flowConfig!: FlowConfig; + + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public flowsInProgress!: DataEntryFlowProgress[]; + + @property() public handler!: string; + + protected render(): TemplateResult { + return html` +

+ ${this.hass.localize( + "ui.panel.config.integrations.config_flow.pick_flow_step.title" + )} +

+ +
+ ${this.flowsInProgress.map( + (flow) => html` + + + + ${localizeConfigFlowTitle(this.hass.localize, flow)} + + + ` + )} + + + ${this.hass.localize( + "ui.panel.config.integrations.config_flow.pick_flow_step.new_flow", + "integration", + domainToName(this.hass.localize, this.handler) + )} + + + +
+ `; + } + + private _startNewFlowPicked(ev) { + this._startFlow(ev.currentTarget.handler); + } + + private _startFlow(handler: string) { + fireEvent(this, "flow-update", { + stepPromise: this.flowConfig.createFlow(this.hass, handler), + }); + } + + private _flowInProgressPicked(ev) { + const flow: DataEntryFlowProgress = ev.currentTarget.flow; + fireEvent(this, "flow-update", { + stepPromise: this.flowConfig.fetchFlow(this.hass, flow.flow_id), + }); + } + + static get styles(): CSSResult[] { + return [ + configFlowContentStyles, + css` + img { + width: 40px; + height: 40px; + } + ha-icon-next { + margin-right: 8px; + } + div { + overflow: auto; + max-height: 600px; + margin: 16px 0; + } + h2 { + padding-right: 66px; + } + @media all and (max-height: 900px) { + div { + max-height: calc(100vh - 134px); + } + } + paper-icon-item, + paper-item { + cursor: pointer; + margin-bottom: 4px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "step-flow-pick-flow": StepFlowPickFlow; + } +} diff --git a/src/dialogs/config-flow/step-flow-pick-handler.ts b/src/dialogs/config-flow/step-flow-pick-handler.ts index 774c9778b0..9c4da85cb4 100644 --- a/src/dialogs/config-flow/step-flow-pick-handler.ts +++ b/src/dialogs/config-flow/step-flow-pick-handler.ts @@ -22,7 +22,6 @@ import { domainToName } from "../../data/integration"; import { HomeAssistant } from "../../types"; import { brandsUrl } from "../../util/brands-url"; import { documentationUrl } from "../../util/documentation-url"; -import { FlowConfig } from "./show-dialog-data-entry-flow"; import { configFlowContentStyles } from "./styles"; interface HandlerObj { @@ -30,17 +29,24 @@ interface HandlerObj { slug: string; } +declare global { + // for fire event + interface HASSDomEvents { + "handler-picked": { + handler: string; + }; + } +} + @customElement("step-flow-pick-handler") class StepFlowPickHandler extends LitElement { - public flowConfig!: FlowConfig; - @property({ attribute: false }) public hass!: HomeAssistant; @property() public handlers!: string[]; @property() public showAdvanced?: boolean; - @internalProperty() private filter?: string; + @internalProperty() private _filter?: string; private _width?: number; @@ -74,7 +80,7 @@ class StepFlowPickHandler extends LitElement { protected render(): TemplateResult { const handlers = this._getHandlers( this.handlers, - this.filter, + this._filter, this.hass.localize ); @@ -82,7 +88,7 @@ class StepFlowPickHandler extends LitElement {

${this.hass.localize("ui.panel.config.integrations.new")}

@@ -164,15 +170,12 @@ class StepFlowPickHandler extends LitElement { } private async _filterChanged(e) { - this.filter = e.detail.value; + this._filter = e.detail.value; } private async _handlerPicked(ev) { - fireEvent(this, "flow-update", { - stepPromise: this.flowConfig.createFlow( - this.hass, - ev.currentTarget.handler.slug - ), + fireEvent(this, "handler-picked", { + handler: ev.currentTarget.handler.slug, }); } @@ -195,6 +198,9 @@ class StepFlowPickHandler extends LitElement { overflow: auto; max-height: 600px; } + h2 { + padding-right: 66px; + } @media all and (max-height: 900px) { div { max-height: calc(100vh - 134px); diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 4db3b69260..9789157691 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -380,22 +380,24 @@ export class QuickBar extends LitElement { QuickBarNavigationItem, "action" >[] { - return Object.keys(this.hass.panels).map((panelKey) => { - const panel = this.hass.panels[panelKey]; - const translationKey = getPanelNameTranslationKey(panel); + return Object.keys(this.hass.panels) + .filter((panelKey) => panelKey !== "_my_redirect") + .map((panelKey) => { + const panel = this.hass.panels[panelKey]; + const translationKey = getPanelNameTranslationKey(panel); - const text = this.hass.localize( - "ui.dialogs.quick-bar.commands.navigation.navigate_to", - "panel", - this.hass.localize(translationKey) || panel.title || panel.url_path - ); + const text = this.hass.localize( + "ui.dialogs.quick-bar.commands.navigation.navigate_to", + "panel", + this.hass.localize(translationKey) || panel.title || panel.url_path + ); - return { - text, - icon: getPanelIcon(panel) || DEFAULT_NAVIGATION_ICON, - path: `/${panel.url_path}`, - }; - }); + return { + text, + icon: getPanelIcon(panel) || DEFAULT_NAVIGATION_ICON, + path: `/${panel.url_path}`, + }; + }); } private _generateNavigationConfigSectionCommands(): Partial< diff --git a/src/fake_data/demo_config.ts b/src/fake_data/demo_config.ts index 86b5484b33..68a99ee8e2 100644 --- a/src/fake_data/demo_config.ts +++ b/src/fake_data/demo_config.ts @@ -15,7 +15,8 @@ export const demoConfig: HassConfig = { time_zone: "America/Los_Angeles", config_dir: "/config", version: "DEMO", - whitelist_external_dirs: [], + allowlist_external_dirs: [], + allowlist_external_urls: [], config_source: "storage", safe_mode: false, state: STATE_RUNNING, diff --git a/src/html/_style_base.html.template b/src/html/_style_base.html.template index 0777efeb57..21d047c1b8 100644 --- a/src/html/_style_base.html.template +++ b/src/html/_style_base.html.template @@ -1,4 +1,4 @@ - + - - - - - - -
-

- [[localize('ui.panel.developer-tools.tabs.services.description')]] -

- -
- - -

[[localize('ui.panel.developer-tools.tabs.services.data')]]

- - - [[localize('ui.panel.developer-tools.tabs.services.call_service')]] - -
- - -
- - - -
-
- - -
-
-
- `; - } - - static get properties() { - return { - hass: { - type: Object, - }, - - domainService: { - type: String, - observer: "_domainServiceChanged", - }, - - _domain: { - type: String, - computed: "_computeDomain(domainService)", - }, - - _service: { - type: String, - computed: "_computeService(domainService)", - }, - - serviceData: { - type: String, - value: "", - }, - - parsedJSON: { - type: Object, - computed: "_computeParsedServiceData(serviceData)", - }, - - validJSON: { - type: Boolean, - computed: "_computeValidJSON(parsedJSON)", - }, - - _attributes: { - type: Array, - computed: "_computeAttributesArray(hass, _domain, _service)", - }, - - _description: { - type: String, - computed: "_computeDescription(hass, _domain, _service)", - }, - - rtl: { - reflectToAttribute: true, - computed: "_computeRTL(hass)", - }, - }; - } - - _domainServiceChanged() { - this.serviceData = ""; - } - - _computeAttributesArray(hass, domain, service) { - const serviceDomains = hass.services; - if (!(domain in serviceDomains)) return []; - if (!(service in serviceDomains[domain])) return []; - - const fields = serviceDomains[domain][service].fields; - return Object.keys(fields).map(function (field) { - return { key: field, ...fields[field] }; - }); - } - - _computeDescription(hass, domain, service) { - const serviceDomains = hass.services; - if (!(domain in serviceDomains)) return undefined; - if (!(service in serviceDomains[domain])) return undefined; - return serviceDomains[domain][service].description; - } - - _computeServiceDataKey(domainService) { - return `panel-dev-service-state-servicedata.${domainService}`; - } - - _computeDomain(domainService) { - return domainService.split(".", 1)[0]; - } - - _computeService(domainService) { - return domainService.split(".", 2)[1] || null; - } - - _computeParsedServiceData(serviceData) { - try { - return serviceData.trim() ? safeLoad(serviceData) : {}; - } catch (err) { - return ERROR_SENTINEL; - } - } - - _computeValidJSON(parsedJSON) { - return parsedJSON !== ERROR_SENTINEL; - } - - _computeHasEntity(attributes) { - return attributes.some((attr) => attr.key === "entity_id"); - } - - _computeEntityValue(parsedJSON) { - return parsedJSON === ERROR_SENTINEL ? "" : parsedJSON.entity_id; - } - - _computeEntityDomainFilter(domain) { - return ENTITY_COMPONENT_DOMAINS.includes(domain) ? [domain] : null; - } - - _callService(ev) { - const button = ev.target; - if (this.parsedJSON === ERROR_SENTINEL) { - showAlertDialog(this, { - text: this.hass.localize( - "ui.panel.developer-tools.tabs.services.alert_parsing_yaml", - "data", - this.serviceData - ), - }); - button.actionError(); - return; - } - this.hass - .callService(this._domain, this._service, this.parsedJSON) - .then(() => { - button.actionSuccess(); - }) - .catch(() => { - button.actionError(); - }); - } - - _fillExampleData() { - const example = {}; - this._attributes.forEach((attribute) => { - if (attribute.example) { - let value = ""; - try { - value = safeLoad(attribute.example); - } catch (err) { - value = attribute.example; - } - example[attribute.key] = value; - } - }); - this.serviceData = safeDump(example); - } - - _entityPicked(ev) { - this.serviceData = safeDump({ - ...this.parsedJSON, - entity_id: ev.target.value, - }); - } - - _yamlChanged(ev) { - this.serviceData = ev.detail.value; - } - - _computeRTL(hass) { - return computeRTL(hass); - } -} - -customElements.define("developer-tools-service", HaPanelDevService); diff --git a/src/panels/developer-tools/service/developer-tools-service.ts b/src/panels/developer-tools/service/developer-tools-service.ts new file mode 100644 index 0000000000..22abadf25f --- /dev/null +++ b/src/panels/developer-tools/service/developer-tools-service.ts @@ -0,0 +1,350 @@ +import { safeLoad } from "js-yaml"; +import { + css, + CSSResultArray, + html, + LitElement, + property, + query, +} from "lit-element"; +import memoizeOne from "memoize-one"; +import { LocalStorage } from "../../../common/decorators/local-storage"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { computeObjectId } from "../../../common/entity/compute_object_id"; +import "../../../components/buttons/ha-progress-button"; +import "../../../components/entity/ha-entity-picker"; +import "../../../components/ha-card"; +import "../../../components/ha-expansion-panel"; +import "../../../components/ha-service-control"; +import "../../../components/ha-service-picker"; +import "../../../components/ha-yaml-editor"; +import type { HaYamlEditor } from "../../../components/ha-yaml-editor"; +import { ServiceAction } from "../../../data/script"; +import { haStyle } from "../../../resources/styles"; +import "../../../styles/polymer-ha-style"; +import { HomeAssistant } from "../../../types"; +import "../../../util/app-localstorage-document"; + +class HaPanelDevService extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public narrow!: boolean; + + @LocalStorage("panel-dev-service-state-service-data", true) + private _serviceData?: ServiceAction = { service: "", target: {}, data: {} }; + + @LocalStorage("panel-dev-service-state-yaml-mode", true) + private _yamlMode = false; + + @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; + + protected firstUpdated(params) { + super.firstUpdated(params); + if (!this._serviceData?.service) { + const domain = Object.keys(this.hass.services).sort()[0]; + const service = Object.keys(this.hass.services[domain]).sort()[0]; + this._serviceData = { + service: `${domain}.${service}`, + target: {}, + data: {}, + }; + } + } + + protected render() { + const { target, fields } = this._fields( + this.hass.services, + this._serviceData?.service + ); + + const isValid = this._isValid(this._serviceData, fields, target); + + return html` +
+

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.description" + )} +

+ + ${this._yamlMode + ? html`` + : html`
+
`} +
+
+
+ + ${this._yamlMode + ? this.hass.localize( + "ui.panel.developer-tools.tabs.services.ui_mode" + ) + : this.hass.localize( + "ui.panel.developer-tools.tabs.services.yaml_mode" + )} + + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.call_service" + )} + +
+
+ + ${(this._yamlMode ? fields : this._filterSelectorFields(fields)).length + ? html`
+ + ${this._yamlMode && target + ? html`

+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.accepts_target" + )} +

` + : ""} + + + + + + + ${fields.map( + (field) => html` + + + + ` + )} +
+ ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.column_parameter" + )} + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.column_description" + )} + + ${this.hass.localize( + "ui.panel.developer-tools.tabs.services.column_example" + )} +
${field.key}
${field.description}${field.example}
+ ${this._yamlMode + ? html`${this.hass.localize( + "ui.panel.developer-tools.tabs.services.fill_example_data" + )}` + : ""} +
+
` + : ""} + `; + } + + private _filterSelectorFields = memoizeOne((fields) => + fields.filter((field) => !field.selector) + ); + + private _isValid = memoizeOne((serviceData, fields, target): boolean => { + if (!serviceData?.service) { + return false; + } + const domain = computeDomain(serviceData.service); + const service = computeObjectId(serviceData.service); + if (!domain || !service) { + return false; + } + if ( + target && + !serviceData.target && + !serviceData.data?.entity_id && + !serviceData.data?.device_id && + !serviceData.data?.area_id + ) { + return false; + } + for (const field of fields) { + if ( + field.required && + (!serviceData.data || serviceData.data[field.key] === undefined) + ) { + return false; + } + } + return true; + }); + + private _fields = memoizeOne( + ( + serviceDomains: HomeAssistant["services"], + domainService: string | undefined + ): { target: boolean; fields: any[] } => { + if (!domainService) { + return { target: false, fields: [] }; + } + const domain = computeDomain(domainService); + const service = computeObjectId(domainService); + if (!(domain in serviceDomains)) { + return { target: false, fields: [] }; + } + if (!(service in serviceDomains[domain])) { + return { target: false, fields: [] }; + } + const target = "target" in serviceDomains[domain][service]; + const fields = serviceDomains[domain][service].fields; + const result = Object.keys(fields).map((field) => { + return { key: field, ...fields[field] }; + }); + + return { + target, + fields: result, + }; + } + ); + + private _callService() { + const domain = computeDomain(this._serviceData!.service); + const service = computeObjectId(this._serviceData!.service); + if (!domain || !service) { + return; + } + this.hass.callService( + domain, + service, + this._serviceData!.data, + this._serviceData!.target + ); + } + + private _toggleYaml() { + this._yamlMode = !this._yamlMode; + } + + private _yamlChanged(ev) { + if (!ev.detail.isValid) { + return; + } + this._serviceChanged(ev); + } + + private _serviceChanged(ev) { + this._serviceData = ev.detail.value; + } + + private _fillExampleData() { + const { fields } = this._fields( + this.hass.services, + this._serviceData?.service + ); + const example = {}; + fields.forEach((field) => { + if (field.example) { + let value = ""; + try { + value = safeLoad(field.example); + } catch (err) { + value = field.example; + } + example[field.key] = value; + } + }); + this._serviceData = { ...this._serviceData!, data: example }; + this._yamlEditor?.setValue(this._serviceData); + } + + static get styles(): CSSResultArray { + return [ + haStyle, + css` + .content { + padding: 16px; + max-width: 1200px; + margin: auto; + } + .button-row { + padding: 8px 16px; + border-top: 1px solid var(--divider-color); + border-bottom: 1px solid var(--divider-color); + background: var(--card-background-color); + position: sticky; + bottom: 0; + box-sizing: border-box; + width: 100%; + } + + .button-row .buttons { + display: flex; + justify-content: space-between; + max-width: 1200px; + margin: auto; + } + + .attributes { + width: 100%; + } + + .attributes th { + text-align: left; + background-color: var(--card-background-color); + border-bottom: 1px solid var(--primary-text-color); + } + + :host([rtl]) .attributes th { + text-align: right; + } + + .attributes tr { + vertical-align: top; + direction: ltr; + } + + .attributes tr:nth-child(odd) { + background-color: var(--table-row-background-color, #eee); + } + + .attributes tr:nth-child(even) { + background-color: var(--table-row-alternative-background-color, #eee); + } + + .attributes td:nth-child(3) { + white-space: pre-wrap; + word-break: break-word; + } + + .attributes td { + padding: 4px; + vertical-align: middle; + } + `, + ]; + } +} + +customElements.define("developer-tools-service", HaPanelDevService); + +declare global { + interface HTMLElementTagNameMap { + "developer-tools-service": HaPanelDevService; + } +} diff --git a/src/panels/lovelace/cards/hui-button-card.ts b/src/panels/lovelace/cards/hui-button-card.ts index a9666c33b9..b6259afae7 100644 --- a/src/panels/lovelace/cards/hui-button-card.ts +++ b/src/panels/lovelace/cards/hui-button-card.ts @@ -272,6 +272,7 @@ export class HuiButtonCard extends LitElement implements LovelaceCard { height: auto; color: var(--paper-item-icon-color, #44739e); --mdc-icon-size: 100%; + margin-bottom: 8px; } ha-icon, diff --git a/src/panels/lovelace/common/handle-action.ts b/src/panels/lovelace/common/handle-action.ts index ad95273e5d..a4339e2fe4 100644 --- a/src/panels/lovelace/common/handle-action.ts +++ b/src/panels/lovelace/common/handle-action.ts @@ -130,7 +130,12 @@ export const handleAction = async ( return; } const [domain, service] = actionConfig.service.split(".", 2); - hass.callService(domain, service, actionConfig.service_data); + hass.callService( + domain, + service, + actionConfig.service_data, + actionConfig.target + ); forwardHaptic("light"); break; } diff --git a/src/panels/lovelace/components/hui-action-editor.ts b/src/panels/lovelace/components/hui-action-editor.ts index 7946138d6e..d6b57b239e 100644 --- a/src/panels/lovelace/components/hui-action-editor.ts +++ b/src/panels/lovelace/components/hui-action-editor.ts @@ -15,15 +15,17 @@ import { } from "lit-element"; import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-help-tooltip"; -import "../../../components/ha-service-picker"; import { ActionConfig, CallServiceActionConfig, NavigateActionConfig, UrlActionConfig, } from "../../../data/lovelace"; +import { ServiceAction } from "../../../data/script"; import { HomeAssistant } from "../../../types"; import { EditorTarget } from "../editor/types"; +import "../../../components/ha-service-control"; +import memoizeOne from "memoize-one"; @customElement("hui-action-editor") export class HuiActionEditor extends LitElement { @@ -47,10 +49,15 @@ export class HuiActionEditor extends LitElement { return config.url_path || ""; } - get _service(): string { - const config = this.config as CallServiceActionConfig; - return config.service || ""; - } + private _serviceAction = memoizeOne( + (config: CallServiceActionConfig): ServiceAction => { + return { + service: config.service || "", + data: config.service_data, + target: config.target, + }; + } + ); protected render(): TemplateResult { if (!this.hass || !this.actions) { @@ -117,17 +124,13 @@ export class HuiActionEditor extends LitElement { : ""} ${this.config?.action === "call-service" ? html` - - - ${this.hass!.localize( - "ui.panel.lovelace.editor.action-editor.editor_service_data" - )} - + .value=${this._serviceAction(this.config)} + .showAdvanced=${this.hass.userData?.showAdvanced} + narrow + @value-changed=${this._serviceValueChanged} + > ` : ""} `; @@ -174,11 +177,26 @@ export class HuiActionEditor extends LitElement { } } + private _serviceValueChanged(ev: CustomEvent) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { + value: { + ...this.config!, + service: ev.detail.value.service || "", + service_data: ev.detail.value.data || {}, + target: ev.detail.value.target || {}, + }, + }); + } + static get styles(): CSSResult { return css` .dropdown { display: flex; } + ha-service-control { + --service-control-padding: 0; + } `; } } diff --git a/src/panels/lovelace/editor/config-elements/config-elements-style.ts b/src/panels/lovelace/editor/config-elements/config-elements-style.ts index 0b5f7a0314..4a1c4bf12b 100644 --- a/src/panels/lovelace/editor/config-elements/config-elements-style.ts +++ b/src/panels/lovelace/editor/config-elements/config-elements-style.ts @@ -9,7 +9,11 @@ export const configElementStyle = css` } .side-by-side > * { flex: 1; - padding-right: 4px; + padding-right: 8px; + } + .side-by-side > *:last-child { + flex: 1; + padding-right: 0; } .suffix { margin: 0 8px; diff --git a/src/panels/lovelace/editor/types.ts b/src/panels/lovelace/editor/types.ts index 2cd040cf37..c37c3e084d 100644 --- a/src/panels/lovelace/editor/types.ts +++ b/src/panels/lovelace/editor/types.ts @@ -120,20 +120,27 @@ const actionConfigStructConfirmation = union([ const actionConfigStructUrl = object({ action: literal("url"), - url_path: string(), + url_path: optional(string()), confirmation: optional(actionConfigStructConfirmation), }); const actionConfigStructService = object({ action: literal("call-service"), - service: string(), + service: optional(string()), service_data: optional(object()), + target: optional( + object({ + entity_id: optional(union([string(), array(string())])), + device_id: optional(union([string(), array(string())])), + area_id: optional(union([string(), array(string())])), + }) + ), confirmation: optional(actionConfigStructConfirmation), }); const actionConfigStructNavigate = object({ action: literal("navigate"), - navigation_path: string(), + navigation_path: optional(string()), confirmation: optional(actionConfigStructConfirmation), }); diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index b9eaea9916..2a9887e6c3 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -13,22 +13,28 @@ import { extractSearchParamsObject, } from "../../common/url/search-params"; import "../../layouts/hass-error-screen"; +import { isComponentLoaded } from "../../common/config/is_component_loaded"; +import { domainToName } from "../../data/integration"; -const REDIRECTS = { - info: { - redirect: "/config/info", +const REDIRECTS: Redirects = { + developer_states: { + redirect: "/developer-tools/state", }, - logs: { - redirect: "/config/logs", + developer_services: { + redirect: "/developer-tools/service", }, - profile: { - redirect: "/profile/dashboard", + developer_template: { + redirect: "/developer-tools/template", }, - blueprint_import: { - redirect: "/config/blueprint/dashboard/import", - params: { - blueprint_url: "url", - }, + developer_events: { + redirect: "/developer-tools/event", + }, + cloud: { + component: "cloud", + redirect: "/config/cloud", + }, + integrations: { + redirect: "/config/integrations", }, config_flow_start: { redirect: "/config/integrations/add", @@ -36,12 +42,80 @@ const REDIRECTS = { domain: "string", }, }, + devices: { + redirect: "/config/devices/dashboard", + }, + entities: { + redirect: "/config/entities", + }, + areas: { + redirect: "/config/areas/dashboard", + }, + blueprints: { + redirect: "/config/blueprint/dashboard", + }, + blueprint_import: { + redirect: "/config/blueprint/dashboard/import", + params: { + blueprint_url: "url", + }, + }, + automations: { + redirect: "/config/automation/dashboard", + }, + scenes: { + redirect: "/config/scene/dashboard", + }, + scripts: { + redirect: "/config/script/dashboard", + }, + helpers: { + redirect: "/config/helpers", + }, + tags: { + redirect: "/config/tags", + }, + lovelace_dashboards: { + redirect: "/config/lovelace/dashboards", + }, + lovelace_resources: { + redirect: "/config/lovelace/resources", + }, + people: { + redirect: "/config/person", + }, + zones: { + redirect: "/config/zone", + }, + users: { + redirect: "/config/users", + }, + general: { + redirect: "/config/core", + }, + server_controls: { + redirect: "/config/server_control", + }, + logs: { + redirect: "/config/logs", + }, + info: { + redirect: "/config/info", + }, + customize: { + redirect: "/config/customize", + }, + profile: { + redirect: "/profile/dashboard", + }, }; -type ParamType = "url" | "string"; +export type ParamType = "url" | "string"; -interface Redirect { +export type Redirects = { [key: string]: Redirect }; +export interface Redirect { redirect: string; + component?: string; params?: { [key: string]: ParamType; }; @@ -53,24 +127,37 @@ class HaPanelMy extends LitElement { @property() public route!: Route; - @internalProperty() public _error = ""; + @internalProperty() public _error?: string; connectedCallback() { super.connectedCallback(); const path = this.route.path.substr(1); - const redirect: Redirect | undefined = REDIRECTS[path]; + + if (path.startsWith("supervisor")) { + if (!isComponentLoaded(this.hass, "hassio")) { + this._error = "no_supervisor"; + return; + } + navigate( + this, + `/hassio/_my_redirect/${path}${window.location.search}`, + true + ); + return; + } + + const redirect = REDIRECTS[path]; if (!redirect) { - this._error = this.hass.localize( - "ui.panel.my.not_supported", - "link", - html`${this.hass.localize("ui.panel.my.faq_link")}` - ); + this._error = "not_supported"; + return; + } + + if ( + redirect.component && + !isComponentLoaded(this.hass, redirect.component) + ) { + this._error = "no_component"; return; } @@ -78,7 +165,7 @@ class HaPanelMy extends LitElement { try { url = this._createRedirectUrl(redirect); } catch (err) { - this._error = this.hass.localize("ui.panel.my.error"); + this._error = "url_error"; return; } @@ -87,9 +174,44 @@ class HaPanelMy extends LitElement { protected render() { if (this._error) { - return html``; + let error = "Unknown error"; + switch (this._error) { + case "not_supported": + error = + this.hass.localize( + "ui.panel.my.not_supported", + "link", + html`${this.hass.localize("ui.panel.my.faq_link")}` + ) || "This redirect is not supported."; + break; + case "no_component": + error = + this.hass.localize( + "ui.panel.my.component_not_loaded", + "integration", + domainToName( + this.hass.localize, + REDIRECTS[this.route.path.substr(1)].component! + ) + ) || "This redirect is not supported."; + break; + case "no_supervisor": + error = + this.hass.localize( + "ui.panel.my.component_not_loaded", + "integration", + "Home Assistant Supervisor" + ) || "This redirect requires Home Assistant Supervisor."; + break; + default: + error = this.hass.localize("ui.panel.my.error") || "Unknown error"; + } + return html``; } return html``; } diff --git a/src/panels/profile/dialog-ha-mfa-module-setup-flow.ts b/src/panels/profile/dialog-ha-mfa-module-setup-flow.ts new file mode 100644 index 0000000000..bb3aa67587 --- /dev/null +++ b/src/panels/profile/dialog-ha-mfa-module-setup-flow.ts @@ -0,0 +1,281 @@ +import "@material/mwc-button"; +import { + css, + CSSResult, + customElement, + internalProperty, + LitElement, + property, +} from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { localizeKey } from "../../common/translations/localize"; +import "../../components/ha-circular-progress"; +import "../../components/ha-form/ha-form"; +import "../../components/ha-markdown"; +import { + DataEntryFlowStep, + DataEntryFlowStepForm, +} from "../../data/data_entry_flow"; +import { haStyleDialog } from "../../resources/styles"; +import { HomeAssistant } from "../../types"; +import "../../components/ha-dialog"; + +let instance = 0; + +@customElement("ha-mfa-module-setup-flow") +class HaMfaModuleSetupFlow extends LitElement { + @property() public hass!: HomeAssistant; + + @internalProperty() private _dialogClosedCallback?: (params: { + flowFinished: boolean; + }) => void; + + @internalProperty() private _instance?: number; + + @internalProperty() private _loading = false; + + @internalProperty() private _opened = false; + + @internalProperty() private _stepData: any = {}; + + @internalProperty() private _step?: DataEntryFlowStep; + + @internalProperty() private _errorMessage?: string; + + public showDialog({ continueFlowId, mfaModuleId, dialogClosedCallback }) { + this._instance = instance++; + this._dialogClosedCallback = dialogClosedCallback; + this._opened = true; + + const fetchStep = continueFlowId + ? this.hass.callWS({ + type: "auth/setup_mfa", + flow_id: continueFlowId, + }) + : this.hass.callWS({ + type: "auth/setup_mfa", + mfa_module_id: mfaModuleId, + }); + + const curInstance = this._instance; + + fetchStep.then((step) => { + if (curInstance !== this._instance) return; + + this._processStep(step); + }); + } + + public closeDialog() { + // Closed dialog by clicking on the overlay + if (this._step) { + this._flowDone(); + } + this._opened = false; + } + + protected render(): TemplateResult { + if (!this._opened) { + return html``; + } + return html` + +
+ ${this._errorMessage + ? html`
${this._errorMessage}
` + : ""} + ${!this._step + ? html`
+ +
` + : html`${this._step.type === "abort" + ? html` ` + : this._step.type === "create_entry" + ? html`

+ ${this.hass.localize( + "ui.panel.profile.mfa_setup.step_done", + "step", + this._step.title + )} +

` + : this._step.type === "form" + ? html` + ` + : ""}`} +
+ ${["abort", "create_entry"].includes(this._step?.type || "") + ? html`${this.hass.localize( + "ui.panel.profile.mfa_setup.close" + )}` + : ""} + ${this._step?.type === "form" + ? html`${this.hass.localize( + "ui.panel.profile.mfa_setup.submit" + )}` + : ""} +
+ `; + } + + static get styles(): CSSResult[] { + return [ + haStyleDialog, + css` + .error { + color: red; + } + ha-dialog { + max-width: 500px; + } + ha-markdown { + --markdown-svg-background-color: white; + --markdown-svg-color: black; + display: block; + margin: 0 auto; + } + ha-markdown a { + color: var(--primary-color); + } + .init-spinner { + padding: 10px 100px 34px; + text-align: center; + } + .submit-spinner { + margin-right: 16px; + } + `, + ]; + } + + protected firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); + this.hass.loadBackendTranslation("mfa_setup", "auth"); + this.addEventListener("keypress", (ev) => { + if (ev.key === "Enter") { + this._submitStep(); + } + }); + } + + private _stepDataChanged(ev: CustomEvent) { + this._stepData = ev.detail.value; + } + + private _submitStep() { + this._loading = true; + this._errorMessage = undefined; + + const curInstance = this._instance; + + this.hass + .callWS({ + type: "auth/setup_mfa", + flow_id: this._step!.flow_id, + user_input: this._stepData, + }) + .then( + (step) => { + if (curInstance !== this._instance) { + return; + } + + this._processStep(step); + this._loading = false; + }, + (err) => { + this._errorMessage = + (err && err.body && err.body.message) || "Unknown error occurred"; + this._loading = false; + } + ); + } + + private _processStep(step) { + if (!step.errors) step.errors = {}; + this._step = step; + // We got a new form if there are no errors. + if (Object.keys(step.errors).length === 0) { + this._stepData = {}; + } + } + + private _flowDone() { + const flowFinished = Boolean( + this._step && ["create_entry", "abort"].includes(this._step.type) + ); + + this._dialogClosedCallback!({ + flowFinished, + }); + + this._errorMessage = undefined; + this._step = undefined; + this._stepData = {}; + this._dialogClosedCallback = undefined; + this.closeDialog(); + } + + private _computeStepTitle() { + return this._step?.type === "abort" + ? this.hass.localize("ui.panel.profile.mfa_setup.title_aborted") + : this._step?.type === "create_entry" + ? this.hass.localize("ui.panel.profile.mfa_setup.title_success") + : this._step?.type === "form" + ? this.hass.localize( + `component.auth.mfa_setup.${this._step.handler}.step.${this._step.step_id}.title` + ) + : ""; + } + + private _computeLabel = (schema) => + this.hass.localize( + `component.auth.mfa_setup.${this._step!.handler}.step.${ + (this._step! as DataEntryFlowStepForm).step_id + }.data.${schema.name}` + ) || schema.name; + + private _computeError = (error) => + this.hass.localize( + `component.auth.mfa_setup.${this._step!.handler}.error.${error}` + ) || error; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-mfa-module-setup-flow": HaMfaModuleSetupFlow; + } +} diff --git a/src/panels/profile/ha-mfa-module-setup-flow.js b/src/panels/profile/ha-mfa-module-setup-flow.js deleted file mode 100644 index 2e4a66fd1f..0000000000 --- a/src/panels/profile/ha-mfa-module-setup-flow.js +++ /dev/null @@ -1,322 +0,0 @@ -import "@material/mwc-button"; -import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import "../../components/dialog/ha-paper-dialog"; -import "../../components/ha-circular-progress"; -import "../../components/ha-form/ha-form"; -import "../../components/ha-markdown"; -import { EventsMixin } from "../../mixins/events-mixin"; -import LocalizeMixin from "../../mixins/localize-mixin"; -import "../../styles/polymer-ha-style-dialog"; - -let instance = 0; - -/* - * @appliesMixin LocalizeMixin - * @appliesMixin EventsMixin - */ -class HaMfaModuleSetupFlow extends LocalizeMixin(EventsMixin(PolymerElement)) { - static get template() { - return html` - - -

- - - -

- - - - - -
- - - -
-
- `; - } - - static get properties() { - return { - _hass: Object, - _dialogClosedCallback: Function, - _instance: Number, - - _loading: { - type: Boolean, - value: false, - }, - - // Error message when can't talk to server etc - _errorMsg: String, - - _opened: { - type: Boolean, - value: false, - }, - - _step: { - type: Object, - value: null, - }, - - /* - * Store user entered data. - */ - _stepData: Object, - }; - } - - ready() { - super.ready(); - this.hass.loadBackendTranslation("mfa_setup", "auth"); - this.addEventListener("keypress", (ev) => { - if (ev.keyCode === 13) { - this._submitStep(); - } - }); - } - - showDialog({ hass, continueFlowId, mfaModuleId, dialogClosedCallback }) { - this.hass = hass; - this._instance = instance++; - this._dialogClosedCallback = dialogClosedCallback; - this._createdFromHandler = !!mfaModuleId; - this._loading = true; - this._opened = true; - - const fetchStep = continueFlowId - ? this.hass.callWS({ - type: "auth/setup_mfa", - flow_id: continueFlowId, - }) - : this.hass.callWS({ - type: "auth/setup_mfa", - mfa_module_id: mfaModuleId, - }); - - const curInstance = this._instance; - - fetchStep.then((step) => { - if (curInstance !== this._instance) return; - - this._processStep(step); - this._loading = false; - // When the flow changes, center the dialog. - // Don't do it on each step or else the dialog keeps bouncing. - setTimeout(() => this.$.dialog.center(), 0); - }); - } - - _submitStep() { - this._loading = true; - this._errorMsg = null; - - const curInstance = this._instance; - - this.hass - .callWS({ - type: "auth/setup_mfa", - flow_id: this._step.flow_id, - user_input: this._stepData, - }) - .then( - (step) => { - if (curInstance !== this._instance) return; - - this._processStep(step); - this._loading = false; - }, - (err) => { - this._errorMsg = - (err && err.body && err.body.message) || "Unknown error occurred"; - this._loading = false; - } - ); - } - - _processStep(step) { - if (!step.errors) step.errors = {}; - this._step = step; - // We got a new form if there are no errors. - if (Object.keys(step.errors).length === 0) { - this._stepData = {}; - } - } - - _flowDone() { - this._opened = false; - const flowFinished = - this._step && ["create_entry", "abort"].includes(this._step.type); - - if (this._step && !flowFinished && this._createdFromHandler) { - // console.log('flow not finish'); - } - - this._dialogClosedCallback({ - flowFinished, - }); - - this._errorMsg = null; - this._step = null; - this._stepData = {}; - this._dialogClosedCallback = null; - } - - _equals(a, b) { - return a === b; - } - - _openedChanged(ev) { - // Closed dialog by clicking on the overlay - if (this._step && !ev.detail.value) { - this._flowDone(); - } - } - - _computeStepAbortedReason(localize, step) { - return localize( - `component.auth.mfa_setup.${step.handler}.abort.${step.reason}` - ); - } - - _computeStepTitle(localize, step) { - return ( - localize( - `component.auth.mfa_setup.${step.handler}.step.${step.step_id}.title` - ) || "Setup Multi-factor Authentication" - ); - } - - _computeStepDescription(localize, step) { - const args = [ - `component.auth.mfa_setup.${step.handler}.step.${step.step_id}.description`, - ]; - const placeholders = step.description_placeholders || {}; - Object.keys(placeholders).forEach((key) => { - args.push(key); - args.push(placeholders[key]); - }); - return localize(...args); - } - - _computeLabelCallback(localize, step) { - // Returns a callback for ha-form to calculate labels per schema object - return (schema) => - localize( - `component.auth.mfa_setup.${step.handler}.step.${step.step_id}.data.${schema.name}` - ) || schema.name; - } - - _computeErrorCallback(localize, step) { - // Returns a callback for ha-form to calculate error messages - return (error) => - localize(`component.auth.mfa_setup.${step.handler}.error.${error}`) || - error; - } -} - -customElements.define("ha-mfa-module-setup-flow", HaMfaModuleSetupFlow); diff --git a/src/panels/profile/ha-mfa-modules-card.js b/src/panels/profile/ha-mfa-modules-card.js deleted file mode 100644 index a118d6e1cc..0000000000 --- a/src/panels/profile/ha-mfa-modules-card.js +++ /dev/null @@ -1,130 +0,0 @@ -import "@material/mwc-button"; -import "@polymer/paper-item/paper-item"; -import "@polymer/paper-item/paper-item-body"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import "../../components/ha-card"; -import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; -import { EventsMixin } from "../../mixins/events-mixin"; -import LocalizeMixin from "../../mixins/localize-mixin"; -import "../../styles/polymer-ha-style"; - -let registeredDialog = false; - -/* - * @appliesMixin EventsMixin - * @appliesMixin LocalizeMixin - */ -class HaMfaModulesCard extends EventsMixin(LocalizeMixin(PolymerElement)) { - static get template() { - return html` - - - - - `; - } - - static get properties() { - return { - hass: Object, - - _loading: { - type: Boolean, - value: false, - }, - - // Error message when can't talk to server etc - _statusMsg: String, - _errorMsg: String, - - mfaModules: Array, - }; - } - - connectedCallback() { - super.connectedCallback(); - - if (!registeredDialog) { - registeredDialog = true; - this.fire("register-dialog", { - dialogShowEvent: "show-mfa-module-setup-flow", - dialogTag: "ha-mfa-module-setup-flow", - dialogImport: () => import("./ha-mfa-module-setup-flow"), - }); - } - } - - _enable(ev) { - this.fire("show-mfa-module-setup-flow", { - hass: this.hass, - mfaModuleId: ev.model.module.id, - dialogClosedCallback: () => this._refreshCurrentUser(), - }); - } - - async _disable(ev) { - const mfamodule = ev.model.module; - if ( - !(await showConfirmationDialog(this, { - text: this.localize( - "ui.panel.profile.mfa.confirm_disable", - "name", - mfamodule.name - ), - })) - ) { - return; - } - - const mfaModuleId = mfamodule.id; - - this.hass - .callWS({ - type: "auth/depose_mfa", - mfa_module_id: mfaModuleId, - }) - .then(() => { - this._refreshCurrentUser(); - }); - } - - _refreshCurrentUser() { - this.fire("hass-refresh-current-user"); - } -} - -customElements.define("ha-mfa-modules-card", HaMfaModulesCard); diff --git a/src/panels/profile/ha-mfa-modules-card.ts b/src/panels/profile/ha-mfa-modules-card.ts new file mode 100644 index 0000000000..f5680cb127 --- /dev/null +++ b/src/panels/profile/ha-mfa-modules-card.ts @@ -0,0 +1,101 @@ +import "@material/mwc-button"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-item/paper-item-body"; +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-card"; +import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; +import { HomeAssistant, MFAModule } from "../../types"; +import { showMfaModuleSetupFlowDialog } from "./show-ha-mfa-module-setup-flow-dialog"; + +@customElement("ha-mfa-modules-card") +class HaMfaModulesCard extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public mfaModules!: MFAModule[]; + + protected render(): TemplateResult { + return html` + + ${this.mfaModules.map( + (module) => html` + +
${module.name}
+
${module.id}
+
+ ${module.enabled + ? html`${this.hass.localize( + "ui.panel.profile.mfa.disable" + )}` + : html`${this.hass.localize( + "ui.panel.profile.mfa.enable" + )}`} +
` + )} +
+ `; + } + + static get styles(): CSSResult { + return css` + mwc-button { + margin-right: -0.57em; + } + `; + } + + private _enable(ev) { + showMfaModuleSetupFlowDialog(this, { + mfaModuleId: ev.currentTarget.module.id, + dialogClosedCallback: () => this._refreshCurrentUser(), + }); + } + + private async _disable(ev) { + const mfamodule = ev.currentTarget.module; + if ( + !(await showConfirmationDialog(this, { + text: this.hass.localize( + "ui.panel.profile.mfa.confirm_disable", + "name", + mfamodule.name + ), + })) + ) { + return; + } + + const mfaModuleId = mfamodule.id; + + this.hass + .callWS({ + type: "auth/depose_mfa", + mfa_module_id: mfaModuleId, + }) + .then(() => { + this._refreshCurrentUser(); + }); + } + + private _refreshCurrentUser() { + fireEvent(this, "hass-refresh-current-user"); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-mfa-modules-card": HaMfaModulesCard; + } +} diff --git a/src/panels/profile/show-ha-mfa-module-setup-flow-dialog.ts b/src/panels/profile/show-ha-mfa-module-setup-flow-dialog.ts new file mode 100644 index 0000000000..6c5e6495cb --- /dev/null +++ b/src/panels/profile/show-ha-mfa-module-setup-flow-dialog.ts @@ -0,0 +1,21 @@ +import { fireEvent } from "../../common/dom/fire_event"; + +export interface MfaModuleSetupFlowDialogParams { + continueFlowId?: string; + mfaModuleId?: string; + dialogClosedCallback: (params: { flowFinished: boolean }) => void; +} + +export const loadMfaModuleSetupFlowDialog = () => + import("./dialog-ha-mfa-module-setup-flow"); + +export const showMfaModuleSetupFlowDialog = ( + element: HTMLElement, + dialogParams: MfaModuleSetupFlowDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "ha-mfa-module-setup-flow", + dialogImport: loadMfaModuleSetupFlowDialog, + dialogParams, + }); +}; diff --git a/src/state-summary/state-card-number.js b/src/state-summary/state-card-number.js index 635d76bcc0..61fa0118d9 100644 --- a/src/state-summary/state-card-number.js +++ b/src/state-summary/state-card-number.js @@ -6,7 +6,6 @@ import { html } from "@polymer/polymer/lib/utils/html-tag"; /* eslint-plugin-disable lit */ import { PolymerElement } from "@polymer/polymer/polymer-element"; import "../components/entity/state-info"; -import "../components/ha-slider"; class StateCardNumber extends mixinBehaviors( [IronResizableBehavior], @@ -16,9 +15,6 @@ class StateCardNumber extends mixinBehaviors( return html`