Skip to content

Commit e5b2be2

Browse files
committed
Add ability to import views via external URL, making it possible to import views via my.home-assistant.io
1 parent 89755f2 commit e5b2be2

7 files changed

Lines changed: 544 additions & 0 deletions

File tree

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
import { mdiClose } from "@mdi/js";
2+
import { dump, load } from "js-yaml";
3+
import { css, html, LitElement, nothing } from "lit";
4+
import { customElement, property, state } from "lit/decorators";
5+
import { fireEvent } from "../../../../common/dom/fire_event";
6+
import { navigate } from "../../../../common/navigate";
7+
import "../../../../components/ha-alert";
8+
import "../../../../components/ha-button";
9+
import "../../../../components/ha-code-editor";
10+
import "../../../../components/ha-dialog";
11+
import "../../../../components/ha-dialog-footer";
12+
import "../../../../components/ha-dialog-header";
13+
import "../../../../components/ha-expansion-panel";
14+
import "../../../../components/ha-textfield";
15+
import "../../../../components/ha-select";
16+
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
17+
import "../../../../components/ha-dropdown-item";
18+
import "../../../../components/ha-spinner";
19+
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
20+
import {
21+
fetchConfig,
22+
isStrategyDashboard,
23+
saveConfig,
24+
} from "../../../../data/lovelace/config/types";
25+
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
26+
import type { LovelaceStorageDashboard } from "../../../../data/lovelace/dashboard";
27+
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
28+
import { addView } from "../../../lovelace/editor/config-util";
29+
import { haStyleDialog } from "../../../../resources/styles";
30+
import type { HomeAssistant } from "../../../../types";
31+
import { clearEntityReferences } from "./import-utils";
32+
import type { ImportLovelaceViewDialogParams } from "./show-dialog-import-lovelace-view";
33+
34+
interface DashboardOption {
35+
value: string;
36+
label: string;
37+
}
38+
39+
@customElement("dialog-import-lovelace-view")
40+
class DialogImportLovelaceView extends LitElement {
41+
@property({ attribute: false }) public hass!: HomeAssistant;
42+
43+
@state() private _params?: ImportLovelaceViewDialogParams;
44+
45+
@state() private _open = false;
46+
47+
@state() private _step: "loading" | "configure" | "error" = "loading";
48+
49+
@state() private _config?: LovelaceViewConfig;
50+
51+
@state() private _dashboards: DashboardOption[] = [];
52+
53+
private _abortController?: AbortController;
54+
55+
@state() private _selectedDashboardPath = "";
56+
57+
@state() private _error?: string;
58+
59+
@state() private _saving = false;
60+
61+
@state() private _sourceUrlWarning = false;
62+
63+
public async showDialog(
64+
params: ImportLovelaceViewDialogParams
65+
): Promise<void> {
66+
this._abortController = new AbortController();
67+
this._params = params;
68+
this._step = "loading";
69+
this._error = undefined;
70+
this._saving = false;
71+
this._config = undefined;
72+
this._selectedDashboardPath = "";
73+
this._sourceUrlWarning = !this._isTrustedUrl(params.url);
74+
this._open = true;
75+
76+
try {
77+
const [fetchResult, allDashboards] = await Promise.all([
78+
fetch(params.url, { signal: this._abortController.signal }),
79+
fetchDashboards(this.hass),
80+
]);
81+
82+
if (!fetchResult.ok) {
83+
throw new Error(
84+
this.hass.localize(
85+
"ui.panel.config.lovelace.dashboards.import_view.error_fetch"
86+
)
87+
);
88+
}
89+
90+
let parsed: unknown;
91+
const importedView = await fetchResult.text();
92+
try {
93+
parsed = load(importedView);
94+
} catch {
95+
throw new Error(
96+
this.hass.localize(
97+
"ui.panel.config.lovelace.dashboards.import_view.error_parse"
98+
)
99+
);
100+
}
101+
102+
if (
103+
!parsed ||
104+
typeof parsed !== "object" ||
105+
"views" in (parsed as object)
106+
) {
107+
throw new Error(
108+
this.hass.localize(
109+
"ui.panel.config.lovelace.dashboards.import_view.error_not_a_view"
110+
)
111+
);
112+
}
113+
114+
this._config = clearEntityReferences(parsed as LovelaceViewConfig);
115+
116+
const candidates = allDashboards.filter(
117+
(d): d is LovelaceStorageDashboard => d.mode === "storage"
118+
);
119+
120+
// Fetch each dashboard's config to filter out strategy-based dashboards
121+
// (e.g. the built-in Map dashboard), which don't support adding views.
122+
const configs = await Promise.all(
123+
candidates.map((d) =>
124+
fetchConfig(this.hass.connection, d.url_path, false).catch(() => null)
125+
)
126+
);
127+
128+
this._dashboards = candidates
129+
.filter((_, i) => !configs[i] || !isStrategyDashboard(configs[i]!))
130+
.map((d) => ({ value: d.url_path, label: d.title }));
131+
132+
this._selectedDashboardPath = this._dashboards[0]?.value ?? "";
133+
this._step = "configure";
134+
} catch (err: any) {
135+
if (err.name === "AbortError") return;
136+
this._error = err.message;
137+
this._step = "error";
138+
}
139+
}
140+
141+
public closeDialog(): void {
142+
this._abortController?.abort();
143+
this._open = false;
144+
}
145+
146+
private _dialogClosed(): void {
147+
this._params = undefined;
148+
this._config = undefined;
149+
this._error = undefined;
150+
fireEvent(this, "dialog-closed", { dialog: this.localName });
151+
}
152+
153+
protected render() {
154+
if (!this._params) {
155+
return nothing;
156+
}
157+
158+
return html`
159+
<ha-dialog
160+
.hass=${this.hass}
161+
.open=${this._open}
162+
width="medium"
163+
@closed=${this._dialogClosed}
164+
>
165+
<ha-dialog-header slot="header">
166+
<ha-icon-button
167+
slot="navigationIcon"
168+
@click=${this.closeDialog}
169+
.label=${this.hass.localize("ui.common.close")}
170+
.path=${mdiClose}
171+
></ha-icon-button>
172+
<span slot="title">
173+
${this.hass.localize(
174+
"ui.panel.config.lovelace.dashboards.import_view.header"
175+
)}
176+
</span>
177+
</ha-dialog-header>
178+
179+
<div>
180+
${this._step === "loading"
181+
? html`<div class="loading"><ha-spinner></ha-spinner></div>`
182+
: this._step === "error"
183+
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
184+
: html`
185+
${this._sourceUrlWarning
186+
? html`
187+
<ha-alert alert-type="warning">
188+
${this.hass.localize(
189+
"ui.panel.config.lovelace.dashboards.import_view.source_warning"
190+
)}
191+
</ha-alert>
192+
`
193+
: nothing}
194+
<p>
195+
${this.hass.localize(
196+
"ui.panel.config.lovelace.dashboards.import_view.introduction"
197+
)}
198+
</p>
199+
${this._dashboards.length === 0
200+
? html`
201+
<ha-alert alert-type="warning">
202+
${this.hass.localize(
203+
"ui.panel.config.lovelace.dashboards.import_view.no_dashboards"
204+
)}
205+
</ha-alert>
206+
`
207+
: nothing}
208+
<ha-textfield
209+
.label=${this.hass.localize(
210+
"ui.panel.config.lovelace.dashboards.import_view.view_title"
211+
)}
212+
.value=${this._config!.title || ""}
213+
@change=${this._titleChanged}
214+
></ha-textfield>
215+
${this._dashboards.length > 0
216+
? html`
217+
<ha-select
218+
.label=${this.hass.localize(
219+
"ui.panel.config.lovelace.dashboards.import_view.target_dashboard"
220+
)}
221+
.value=${this._dashboards.find(
222+
(d) => d.value === this._selectedDashboardPath
223+
)?.label ?? ""}
224+
@selected=${this._dashboardSelected}
225+
>
226+
${this._dashboards.map(
227+
(d) =>
228+
html`<ha-dropdown-item
229+
.value=${d.value}
230+
.selected=${d.value ===
231+
this._selectedDashboardPath}
232+
>${d.label}</ha-dropdown-item
233+
>`
234+
)}
235+
</ha-select>
236+
`
237+
: nothing}
238+
<ha-expansion-panel
239+
.header=${this.hass.localize(
240+
"ui.panel.config.lovelace.dashboards.import_view.preview_title"
241+
)}
242+
>
243+
<ha-code-editor
244+
mode="yaml"
245+
.value=${dump(this._config!)}
246+
.hass=${this.hass}
247+
read-only
248+
dir="ltr"
249+
></ha-code-editor>
250+
</ha-expansion-panel>
251+
`}
252+
</div>
253+
254+
<ha-dialog-footer slot="footer">
255+
<ha-button
256+
appearance="plain"
257+
slot="secondaryAction"
258+
@click=${this.closeDialog}
259+
.disabled=${this._saving}
260+
>
261+
${this.hass.localize("ui.common.cancel")}
262+
</ha-button>
263+
${this._step === "configure"
264+
? html`
265+
<ha-button
266+
slot="primaryAction"
267+
@click=${this._save}
268+
.disabled=${this._saving || this._dashboards.length === 0}
269+
.loading=${this._saving}
270+
>
271+
${this.hass.localize(
272+
"ui.panel.config.lovelace.dashboards.import_view.add_btn"
273+
)}
274+
</ha-button>
275+
`
276+
: nothing}
277+
</ha-dialog-footer>
278+
</ha-dialog>
279+
`;
280+
}
281+
282+
private _titleChanged(ev: Event) {
283+
this._config = {
284+
...this._config!,
285+
title: (ev.target as HTMLInputElement).value,
286+
};
287+
}
288+
289+
private _dashboardSelected(ev: HaSelectSelectEvent) {
290+
this._selectedDashboardPath = ev.detail.value;
291+
}
292+
293+
private async _save() {
294+
this._saving = true;
295+
this._error = undefined;
296+
try {
297+
const currentConfig = await fetchConfig(
298+
this.hass.connection,
299+
this._selectedDashboardPath,
300+
false
301+
);
302+
303+
if (isStrategyDashboard(currentConfig)) {
304+
this._error = this.hass.localize(
305+
"ui.panel.config.lovelace.dashboards.import_view.error_strategy_dashboard"
306+
);
307+
return;
308+
}
309+
310+
const newConfig = addView(
311+
this.hass,
312+
currentConfig as LovelaceConfig,
313+
this._config!,
314+
true
315+
);
316+
await saveConfig(this.hass, this._selectedDashboardPath, newConfig);
317+
const addedView = newConfig.views[newConfig.views.length - 1];
318+
const viewPath = addedView.path ?? newConfig.views.length - 1;
319+
this.closeDialog();
320+
navigate(`/${this._selectedDashboardPath}/${viewPath}?edit=1`);
321+
} catch (err: any) {
322+
this._error = err.message;
323+
} finally {
324+
this._saving = false;
325+
}
326+
}
327+
328+
private _isTrustedUrl(url?: string): boolean {
329+
if (!url) {
330+
return true;
331+
}
332+
let hostname: string;
333+
try {
334+
hostname = new URL(url).hostname.toLowerCase();
335+
} catch {
336+
return false;
337+
}
338+
return (
339+
hostname === "github.com" ||
340+
hostname.endsWith(".github.com") ||
341+
hostname.endsWith(".githubusercontent.com") ||
342+
hostname === "home-assistant.io" ||
343+
hostname.endsWith(".home-assistant.io")
344+
);
345+
}
346+
347+
static styles = [
348+
haStyleDialog,
349+
css`
350+
p {
351+
margin-top: 0;
352+
margin-bottom: var(--ha-space-2);
353+
}
354+
ha-alert {
355+
display: block;
356+
margin-bottom: var(--ha-space-2);
357+
}
358+
ha-textfield {
359+
display: block;
360+
margin-bottom: var(--ha-space-4);
361+
}
362+
ha-select {
363+
display: block;
364+
margin-bottom: var(--ha-space-4);
365+
}
366+
ha-expansion-panel {
367+
--expansion-panel-content-padding: 0px;
368+
margin-top: var(--ha-space-4);
369+
}
370+
.loading {
371+
display: flex;
372+
justify-content: center;
373+
padding: var(--ha-space-4);
374+
}
375+
`,
376+
];
377+
}
378+
379+
declare global {
380+
interface HTMLElementTagNameMap {
381+
"dialog-import-lovelace-view": DialogImportLovelaceView;
382+
}
383+
}

0 commit comments

Comments
 (0)