diff --git a/src/panels/config/lovelace/dashboards/dialog-import-lovelace-view.ts b/src/panels/config/lovelace/dashboards/dialog-import-lovelace-view.ts new file mode 100644 index 000000000000..dc013d1e3958 --- /dev/null +++ b/src/panels/config/lovelace/dashboards/dialog-import-lovelace-view.ts @@ -0,0 +1,383 @@ +import { mdiClose } from "@mdi/js"; +import { dump, load } from "js-yaml"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { navigate } from "../../../../common/navigate"; +import "../../../../components/ha-alert"; +import "../../../../components/ha-button"; +import "../../../../components/ha-code-editor"; +import "../../../../components/ha-dialog"; +import "../../../../components/ha-dialog-footer"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-expansion-panel"; +import "../../../../components/ha-textfield"; +import "../../../../components/ha-select"; +import type { HaSelectSelectEvent } from "../../../../components/ha-select"; +import "../../../../components/ha-dropdown-item"; +import "../../../../components/ha-spinner"; +import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; +import { + fetchConfig, + isStrategyDashboard, + saveConfig, +} from "../../../../data/lovelace/config/types"; +import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; +import type { LovelaceStorageDashboard } from "../../../../data/lovelace/dashboard"; +import { fetchDashboards } from "../../../../data/lovelace/dashboard"; +import { addView } from "../../../lovelace/editor/config-util"; +import { haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import { clearEntityReferences } from "./import-utils"; +import type { ImportLovelaceViewDialogParams } from "./show-dialog-import-lovelace-view"; + +interface DashboardOption { + value: string; + label: string; +} + +@customElement("dialog-import-lovelace-view") +class DialogImportLovelaceView extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: ImportLovelaceViewDialogParams; + + @state() private _open = false; + + @state() private _step: "loading" | "configure" | "error" = "loading"; + + @state() private _config?: LovelaceViewConfig; + + @state() private _dashboards: DashboardOption[] = []; + + private _abortController?: AbortController; + + @state() private _selectedDashboardPath = ""; + + @state() private _error?: string; + + @state() private _saving = false; + + @state() private _sourceUrlWarning = false; + + public async showDialog( + params: ImportLovelaceViewDialogParams + ): Promise { + this._abortController = new AbortController(); + this._params = params; + this._step = "loading"; + this._error = undefined; + this._saving = false; + this._config = undefined; + this._selectedDashboardPath = ""; + this._sourceUrlWarning = !this._isTrustedUrl(params.url); + this._open = true; + + try { + const [fetchResult, allDashboards] = await Promise.all([ + fetch(params.url, { signal: this._abortController.signal }), + fetchDashboards(this.hass), + ]); + + if (!fetchResult.ok) { + throw new Error( + this.hass.localize( + "ui.panel.config.lovelace.dashboards.import_view.error_fetch" + ) + ); + } + + let parsed: unknown; + const importedView = await fetchResult.text(); + try { + parsed = load(importedView); + } catch { + throw new Error( + this.hass.localize( + "ui.panel.config.lovelace.dashboards.import_view.error_parse" + ) + ); + } + + if ( + !parsed || + typeof parsed !== "object" || + "views" in (parsed as object) + ) { + throw new Error( + this.hass.localize( + "ui.panel.config.lovelace.dashboards.import_view.error_not_a_view" + ) + ); + } + + this._config = clearEntityReferences(parsed as LovelaceViewConfig); + + const candidates = allDashboards.filter( + (d): d is LovelaceStorageDashboard => d.mode === "storage" + ); + + // Fetch each dashboard's config to filter out strategy-based dashboards + // (e.g. the built-in Map dashboard), which don't support adding views. + const configs = await Promise.all( + candidates.map((d) => + fetchConfig(this.hass.connection, d.url_path, false).catch(() => null) + ) + ); + + this._dashboards = candidates + .filter((_, i) => !configs[i] || !isStrategyDashboard(configs[i]!)) + .map((d) => ({ value: d.url_path, label: d.title })); + + this._selectedDashboardPath = this._dashboards[0]?.value ?? ""; + this._step = "configure"; + } catch (err: any) { + if (err.name === "AbortError") return; + this._error = err.message; + this._step = "error"; + } + } + + public closeDialog(): void { + this._abortController?.abort(); + this._open = false; + } + + private _dialogClosed(): void { + this._params = undefined; + this._config = undefined; + this._error = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params) { + return nothing; + } + + return html` + + + + + ${this.hass.localize( + "ui.panel.config.lovelace.dashboards.import_view.header" + )} + + + +
+ ${this._step === "loading" + ? html`
` + : this._step === "error" + ? html`${this._error}` + : html` + ${this._sourceUrlWarning + ? html` + + ${this.hass.localize( + "ui.panel.config.lovelace.dashboards.import_view.source_warning" + )} + + ` + : nothing} +

+ ${this.hass.localize( + "ui.panel.config.lovelace.dashboards.import_view.introduction" + )} +

+ ${this._dashboards.length === 0 + ? html` + + ${this.hass.localize( + "ui.panel.config.lovelace.dashboards.import_view.no_dashboards" + )} + + ` + : nothing} + + ${this._dashboards.length > 0 + ? html` + d.value === this._selectedDashboardPath + )?.label ?? ""} + @selected=${this._dashboardSelected} + > + ${this._dashboards.map( + (d) => + html`${d.label}` + )} + + ` + : nothing} + + + + `} +
+ + + + ${this.hass.localize("ui.common.cancel")} + + ${this._step === "configure" + ? html` + + ${this.hass.localize( + "ui.panel.config.lovelace.dashboards.import_view.add_btn" + )} + + ` + : nothing} + +
+ `; + } + + private _titleChanged(ev: Event) { + this._config = { + ...this._config!, + title: (ev.target as HTMLInputElement).value, + }; + } + + private _dashboardSelected(ev: HaSelectSelectEvent) { + this._selectedDashboardPath = ev.detail.value; + } + + private async _save() { + this._saving = true; + this._error = undefined; + try { + const currentConfig = await fetchConfig( + this.hass.connection, + this._selectedDashboardPath, + false + ); + + if (isStrategyDashboard(currentConfig)) { + this._error = this.hass.localize( + "ui.panel.config.lovelace.dashboards.import_view.error_strategy_dashboard" + ); + return; + } + + const newConfig = addView( + this.hass, + currentConfig as LovelaceConfig, + this._config!, + true + ); + await saveConfig(this.hass, this._selectedDashboardPath, newConfig); + const addedView = newConfig.views[newConfig.views.length - 1]; + const viewPath = addedView.path ?? newConfig.views.length - 1; + this.closeDialog(); + navigate(`/${this._selectedDashboardPath}/${viewPath}?edit=1`); + } catch (err: any) { + this._error = err.message; + } finally { + this._saving = false; + } + } + + private _isTrustedUrl(url?: string): boolean { + if (!url) { + return true; + } + let hostname: string; + try { + hostname = new URL(url).hostname.toLowerCase(); + } catch { + return false; + } + return ( + hostname === "github.com" || + hostname.endsWith(".github.com") || + hostname.endsWith(".githubusercontent.com") || + hostname === "home-assistant.io" || + hostname.endsWith(".home-assistant.io") + ); + } + + static styles = [ + haStyleDialog, + css` + p { + margin-top: 0; + margin-bottom: var(--ha-space-2); + } + ha-alert { + display: block; + margin-bottom: var(--ha-space-2); + } + ha-textfield { + display: block; + margin-bottom: var(--ha-space-4); + } + ha-select { + display: block; + margin-bottom: var(--ha-space-4); + } + ha-expansion-panel { + --expansion-panel-content-padding: 0px; + margin-top: var(--ha-space-4); + } + .loading { + display: flex; + justify-content: center; + padding: var(--ha-space-4); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-import-lovelace-view": DialogImportLovelaceView; + } +} diff --git a/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts b/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts index ebe3be7d9e09..5958f1294ea7 100644 --- a/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts +++ b/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts @@ -13,6 +13,7 @@ import { customElement, property, state } from "lit/decorators"; import memoize from "memoize-one"; import { storage } from "../../../../common/decorators/storage"; import { navigate } from "../../../../common/navigate"; +import { extractSearchParam } from "../../../../common/url/search-params"; import { stringCompare } from "../../../../common/string/compare"; import type { LocalizeFunc } from "../../../../common/translations/localize"; import type { @@ -65,6 +66,7 @@ import { lovelaceTabs } from "../ha-config-lovelace"; import { showDashboardConfigureStrategyDialog } from "./show-dialog-lovelace-dashboard-configure-strategy"; import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail"; import { showPanelDetailDialog } from "./show-dialog-panel-detail"; +import { showImportLovelaceViewDialog } from "./show-dialog-import-lovelace-view"; export const PANEL_DASHBOARDS = [ "home", @@ -453,6 +455,14 @@ export class HaConfigLovelaceDashboards extends LitElement { protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); this._getDashboards(); + + if (this.route.path === "/import-view") { + const url = extractSearchParam("url"); + navigate("/config/lovelace/dashboards", { replace: true }); + if (url) { + showImportLovelaceViewDialog(this, { url }); + } + } } private async _getDashboards() { diff --git a/src/panels/config/lovelace/dashboards/import-utils.ts b/src/panels/config/lovelace/dashboards/import-utils.ts new file mode 100644 index 000000000000..f1c1ceb1782e --- /dev/null +++ b/src/panels/config/lovelace/dashboards/import-utils.ts @@ -0,0 +1,23 @@ +/** + * Recursively clears all entity references in a Lovelace config object. + * Sets `entity` fields to "" and `entities` arrays to []. + */ +export function clearEntityReferences(config: T): T { + if (!config || typeof config !== "object") { + return config; + } + if (Array.isArray(config)) { + return config.map(clearEntityReferences) as unknown as T; + } + const result = { ...(config as Record) }; + for (const key of Object.keys(result)) { + if (key === "entity") { + result[key] = ""; + } else if (key === "entities") { + result[key] = []; + } else { + result[key] = clearEntityReferences(result[key]); + } + } + return result as unknown as T; +} diff --git a/src/panels/config/lovelace/dashboards/show-dialog-import-lovelace-view.ts b/src/panels/config/lovelace/dashboards/show-dialog-import-lovelace-view.ts new file mode 100644 index 000000000000..556be0296883 --- /dev/null +++ b/src/panels/config/lovelace/dashboards/show-dialog-import-lovelace-view.ts @@ -0,0 +1,16 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; + +export interface ImportLovelaceViewDialogParams { + url: string; +} + +export const showImportLovelaceViewDialog = ( + element: HTMLElement, + dialogParams: ImportLovelaceViewDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-import-lovelace-view", + dialogImport: () => import("./dialog-import-lovelace-view"), + dialogParams, + }); +}; diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index b7f5f79ad63a..93097e2ca144 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -221,6 +221,13 @@ export const getMyRedirects = (): Redirects => ({ component: "lovelace", redirect: "/config/lovelace/resources", }, + lovelace_view_import: { + component: "lovelace", + redirect: "/config/lovelace/dashboards/import-view", + params: { + url: "url", + }, + }, oauth: { redirect: "/auth/external/callback", navigate_outside_spa: true, diff --git a/src/translations/en.json b/src/translations/en.json index ba6326257841..3e50e9b68db1 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4401,6 +4401,20 @@ "set_default_admin_only_title": "Can't set as default", "set_default_admin_only_text": "This dashboard is set to admin-only. Disable this limitation before setting it as default." }, + "import_view": { + "header": "Import view", + "introduction": "Add a view from a URL to one of your dashboards.", + "target_dashboard": "Add to dashboard", + "view_title": "View title", + "preview_title": "View content", + "source_warning": "This configuration is not from GitHub or an official Home Assistant website. Only continue if you trust the source.", + "error_fetch": "Could not fetch the configuration. Make sure the URL is publicly accessible.", + "error_parse": "Could not parse the file. Make sure it is valid YAML or JSON.", + "error_not_a_view": "This file does not look like a view configuration.", + "error_strategy_dashboard": "Cannot add a view to a strategy-based dashboard.", + "no_dashboards": "No editable dashboards found. Create a dashboard first before importing a view.", + "add_btn": "Add view" + }, "panel_detail": { "edit_panel": "Edit panel", "title": "[%key:ui::panel::config::lovelace::dashboards::detail::title%]", diff --git a/test/panels/config/lovelace/dashboards/import-utils.test.ts b/test/panels/config/lovelace/dashboards/import-utils.test.ts new file mode 100644 index 000000000000..b0d1de613567 --- /dev/null +++ b/test/panels/config/lovelace/dashboards/import-utils.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { clearEntityReferences } from "../../../../../src/panels/config/lovelace/dashboards/import-utils"; + +describe("clearEntityReferences", () => { + it("returns primitives unchanged", () => { + expect(clearEntityReferences(42)).toBe(42); + expect(clearEntityReferences("hello")).toBe("hello"); + expect(clearEntityReferences(null)).toBe(null); + expect(clearEntityReferences(undefined)).toBe(undefined); + }); + + it("clears a top-level entity string", () => { + expect(clearEntityReferences({ entity: "light.living_room" })).toEqual({ + entity: "", + }); + }); + + it("clears a top-level entities array", () => { + expect(clearEntityReferences({ entities: ["light.a", "light.b"] })).toEqual( + { entities: [] } + ); + }); + + it("recursively clears entity references in nested objects", () => { + const input = { + type: "entities", + card: { + entity: "sensor.temperature", + name: "Temperature", + }, + }; + expect(clearEntityReferences(input)).toEqual({ + type: "entities", + card: { + entity: "", + name: "Temperature", + }, + }); + }); + + it("recursively clears entity references in arrays", () => { + const input = [ + { entity: "light.a", name: "Light A" }, + { entity: "light.b", name: "Light B" }, + ]; + expect(clearEntityReferences(input)).toEqual([ + { entity: "", name: "Light A" }, + { entity: "", name: "Light B" }, + ]); + }); + + it("clears deeply nested entity and entities fields", () => { + const input = { + views: [ + { + cards: [ + { + type: "glance", + entities: ["light.a", "light.b"], + }, + { + type: "entity", + entity: "sensor.power", + }, + ], + }, + ], + }; + expect(clearEntityReferences(input)).toEqual({ + views: [ + { + cards: [ + { + type: "glance", + entities: [], + }, + { + type: "entity", + entity: "", + }, + ], + }, + ], + }); + }); + + it("leaves unrelated fields unchanged", () => { + const input = { type: "button", name: "My button", icon: "mdi:lightbulb" }; + expect(clearEntityReferences(input)).toEqual(input); + }); +});