Add UI to create and manage Lovelace dashboards and resources (#5012)

* Add UI to create and manage Lovelace dashboards and resources

* update, comments, fixes

* Align icons with seach icon and checkboxes

* Fix

* Remove js and html resource types

* Allow it for existing ones
This commit is contained in:
Bram Kragten 2020-02-28 21:58:50 +01:00 committed by GitHub
parent 33d65bcefc
commit 5646045e9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1465 additions and 180 deletions

View File

@ -571,6 +571,18 @@ export class HaDataTable extends BaseElement {
width: 24px;
}
.mdc-data-table__header-cell--icon {
text-align: center;
}
.mdc-data-table__cell--icon:first-child ha-icon {
margin-left: 8px;
}
.mdc-data-table__cell--icon:first-child state-badge {
margin-right: -8px;
}
.mdc-data-table__header-cell {
font-family: Roboto, sans-serif;
-moz-osx-font-smoothing: grayscale;
@ -598,10 +610,6 @@ export class HaDataTable extends BaseElement {
text-align: left;
}
.mdc-data-table__header-cell--icon {
text-align: center;
}
/* custom from here */
:host {
@ -615,27 +623,39 @@ export class HaDataTable extends BaseElement {
}
.mdc-data-table__header-cell {
overflow: hidden;
position: relative;
}
.mdc-data-table__header-cell span {
position: relative;
left: 0px;
}
.mdc-data-table__header-cell.sortable {
cursor: pointer;
}
.mdc-data-table__header-cell.not-sorted:not(.mdc-data-table__header-cell--numeric):not(.mdc-data-table__header-cell--icon)
span {
position: relative;
left: -24px;
}
.mdc-data-table__header-cell.not-sorted > * {
.mdc-data-table__header-cell > * {
transition: left 0.2s ease 0s;
}
.mdc-data-table__header-cell ha-icon {
top: 15px;
position: absolute;
}
.mdc-data-table__header-cell.not-sorted ha-icon {
left: -36px;
left: -20px;
}
.mdc-data-table__header-cell.not-sorted:not(.mdc-data-table__header-cell--numeric):not(.mdc-data-table__header-cell--icon):hover
.mdc-data-table__header-cell:not(.not-sorted) span,
.mdc-data-table__header-cell.not-sorted:hover span {
left: 24px;
}
.mdc-data-table__header-cell.mdc-data-table__header-cell--numeric:not(.not-sorted)
span,
.mdc-data-table__header-cell.mdc-data-table__header-cell--numeric.not-sorted:hover
span {
left: 0px;
left: 12px;
}
.mdc-data-table__header-cell:not(.not-sorted) ha-icon,
.mdc-data-table__header-cell:hover.not-sorted ha-icon {
left: 0px;
left: 12px;
}
.table-header {
border-bottom: 1px solid rgba(var(--rgb-primary-text-color), 0.12);

View File

@ -1,12 +1,23 @@
import { customElement, CSSResult, css } from "lit-element";
import { customElement, CSSResult, css, html } from "lit-element";
import "@polymer/paper-icon-button/paper-icon-button";
import "@material/mwc-dialog";
import { style } from "@material/mwc-dialog/mwc-dialog-css";
// tslint:disable-next-line
import { Dialog } from "@material/mwc-dialog";
import { Constructor } from "../types";
import { Constructor, HomeAssistant } from "../types";
// tslint:disable-next-line
const MwcDialog = customElements.get("mwc-dialog") as Constructor<Dialog>;
export const createCloseHeading = (hass: HomeAssistant, title: string) => html`
${title}
<paper-icon-button
aria-label=${hass.localize("ui.dialogs.generic.close")}
icon="hass:close"
dialogAction="close"
class="close_button"
></paper-icon-button>
`;
@customElement("ha-dialog")
export class HaDialog extends MwcDialog {
protected static get styles(): CSSResult[] {
@ -19,6 +30,15 @@ export class HaDialog extends MwcDialog {
.mdc-dialog__container {
align-items: var(--vertial-align-dialog, center);
}
.mdc-dialog__title::before {
display: block;
height: 20px;
}
.close_button {
position: absolute;
right: 16px;
top: 12px;
}
`,
];
}

View File

@ -12,10 +12,47 @@ export interface LovelaceConfig {
background?: string;
}
export type LovelaceResources = Array<{
export interface LovelaceResource {
id: string;
type: "css" | "js" | "module" | "html";
url: string;
}>;
}
export interface LovelaceResourcesMutableParams {
res_type: "css" | "js" | "module" | "html";
url: string;
}
export type LovelaceDashboard =
| LovelaceYamlDashboard
| LovelaceStorageDashboard;
interface LovelaceGenericDashboard {
id: string;
url_path: string;
require_admin: boolean;
sidebar?: { icon: string; title: string };
}
export interface LovelaceYamlDashboard extends LovelaceGenericDashboard {
mode: "yaml";
filename: string;
}
export interface LovelaceStorageDashboard extends LovelaceGenericDashboard {
mode: "storage";
}
export interface LovelaceDashboardMutableParams {
require_admin: boolean;
sidebar: { icon: string; title: string } | null;
}
export interface LovelaceDashboardCreateParams
extends LovelaceDashboardMutableParams {
url_path: string;
mode: "storage";
}
export interface LovelaceViewConfig {
index?: number;
@ -111,10 +148,70 @@ type LovelaceUpdatedEvent = HassEventBase & {
};
};
export const fetchResources = (conn: Connection): Promise<LovelaceResources> =>
export const fetchResources = (conn: Connection): Promise<LovelaceResource[]> =>
conn.sendMessagePromise({
type: "lovelace/resources",
});
export const createResource = (
hass: HomeAssistant,
values: LovelaceResourcesMutableParams
) =>
hass.callWS<LovelaceResource>({
type: "lovelace/resources/create",
...values,
});
export const updateResource = (
hass: HomeAssistant,
id: string,
updates: Partial<LovelaceResourcesMutableParams>
) =>
hass.callWS<LovelaceResource>({
type: "lovelace/resources/update",
resource_id: id,
...updates,
});
export const deleteResource = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "lovelace/resources/delete",
resource_id: id,
});
export const fetchDashboards = (
hass: HomeAssistant
): Promise<LovelaceDashboard[]> =>
hass.callWS({
type: "lovelace/dashboards/list",
});
export const createDashboard = (
hass: HomeAssistant,
values: LovelaceDashboardCreateParams
) =>
hass.callWS<LovelaceDashboard>({
type: "lovelace/dashboards/create",
...values,
});
export const updateDashboard = (
hass: HomeAssistant,
id: string,
updates: Partial<LovelaceDashboardMutableParams>
) =>
hass.callWS<LovelaceDashboard>({
type: "lovelace/dashboards/update",
dashboard_id: id,
...updates,
});
export const deleteDashboard = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "lovelace/dashboards/delete",
dashboard_id: id,
});
export const fetchConfig = (
conn: Connection,
urlPath: string | null,
@ -125,6 +222,7 @@ export const fetchConfig = (
url_path: urlPath,
force,
});
export const saveConfig = (
hass: HomeAssistant,
urlPath: string | null,
@ -174,7 +272,7 @@ export const getLovelaceCollection = (
export interface WindowWithLovelaceProm extends Window {
llConfProm?: Promise<LovelaceConfig>;
llResProm?: Promise<LovelaceResources>;
llResProm?: Promise<LovelaceResource[]>;
}
export interface ActionHandlerOptions {

View File

@ -15,6 +15,7 @@ import { Route, HomeAssistant } from "../types";
import { navigate } from "../common/navigate";
import "@material/mwc-ripple";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import memoizeOne from "memoize-one";
export interface PageNavigation {
path: string;
@ -22,7 +23,7 @@ export interface PageNavigation {
component?: string;
name?: string;
core?: boolean;
exportOnly?: boolean;
advancedOnly?: boolean;
icon?: string;
info?: any;
}
@ -33,12 +34,57 @@ class HassTabsSubpage extends LitElement {
@property({ type: String, attribute: "back-path" }) public backPath?: string;
@property() public backCallback?: () => void;
@property({ type: Boolean }) public hassio = false;
@property({ type: Boolean }) public showAdvanced = false;
@property() public route!: Route;
@property() public tabs!: PageNavigation[];
@property({ type: Boolean, reflect: true }) public narrow = false;
@property() private _activeTab: number = -1;
private _getTabs = memoizeOne(
(
tabs: PageNavigation[],
activeTab: number,
showAdvanced: boolean | undefined,
_components,
_language
) => {
const shownTabs = tabs.filter(
(page) =>
(!page.component ||
page.core ||
isComponentLoaded(this.hass, page.component)) &&
(!page.advancedOnly || showAdvanced)
);
return shownTabs.map(
(page, index) => html`
<div
class="tab ${classMap({
active: index === activeTab,
})}"
@click=${this._tabTapped}
.path=${page.path}
>
${this.narrow
? html`
<ha-icon .icon=${page.icon}></ha-icon>
`
: ""}
${!this.narrow || index === activeTab
? html`
<span class="name"
>${page.translationKey
? this.hass.localize(page.translationKey)
: name}</span
>
`
: ""}
<mwc-ripple></mwc-ripple>
</div>
`
);
}
);
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has("route")) {
@ -49,6 +95,14 @@ class HassTabsSubpage extends LitElement {
}
protected render(): TemplateResult {
const tabs = this._getTabs(
this.tabs,
this._activeTab,
this.hass.userData?.showAdvanced,
this.hass.config.components,
this.hass.language
);
return html`
<div class="toolbar">
<ha-paper-icon-button-arrow-prev
@ -61,41 +115,13 @@ class HassTabsSubpage extends LitElement {
<div main-title><slot name="header"></slot></div>
`
: ""}
<div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}>
${this.tabs.map((page, index) =>
(!page.component ||
page.core ||
isComponentLoaded(this.hass, page.component)) &&
(!page.exportOnly || this.showAdvanced)
? html`
<div
class="tab ${classMap({
active: index === this._activeTab,
})}"
@click=${this._tabTapped}
.path=${page.path}
>
${this.narrow
? html`
<ha-icon .icon=${page.icon}></ha-icon>
`
: ""}
${!this.narrow || index === this._activeTab
? html`
<span class="name"
>${page.translationKey
? this.hass.localize(page.translationKey)
: name}</span
>
`
: ""}
<mwc-ripple></mwc-ripple>
</div>
`
: ""
)}
</div>
${tabs.length > 1 || !this.narrow
? html`
<div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}>
${tabs}
</div>
`
: ""}
<div id="toolbar-icon">
<slot name="toolbar-icon"></slot>
</div>

View File

@ -31,7 +31,7 @@ class HaConfigNavigation extends LitElement {
(!page.component ||
page.core ||
isComponentLoaded(this.hass, page.component)) &&
(!page.exportOnly || this.showAdvanced)
(!page.advancedOnly || this.showAdvanced)
? html`
<a
href=${`/config/${page.component}`}

View File

@ -1,7 +1,6 @@
import {
LitElement,
html,
css,
CSSResult,
TemplateResult,
property,
@ -22,6 +21,7 @@ import {
fetchDeviceConditions,
fetchDeviceActions,
} from "../../../../data/device_automation";
import { haStyleDialog } from "../../../../resources/styles";
@customElement("dialog-device-automation")
export class DialogDeviceAutomation extends LitElement {
@ -129,16 +129,7 @@ export class DialogDeviceAutomation extends LitElement {
}
static get styles(): CSSResult {
return css`
ha-dialog {
--mdc-dialog-title-ink-color: var(--primary-text-color);
}
@media only screen and (min-width: 600px) {
ha-dialog {
--mdc-dialog-min-width: 600px;
}
}
`;
return haStyleDialog;
}
}

View File

@ -1,5 +1,3 @@
import "@polymer/app-route/app-route";
import "./ha-config-devices-dashboard";
import "./ha-config-device-page";
import { compare } from "../../../common/string/compare";

View File

@ -75,6 +75,14 @@ export const configSections: { [name: string]: PageNavigation[] } = {
core: true,
},
],
lovelace: [
{
component: "lovelace",
path: "/config/lovelace/dashboards",
translationKey: "ui.panel.config.lovelace.caption",
icon: "hass:view-dashboard",
},
],
persons: [
{
component: "person",
@ -117,7 +125,7 @@ export const configSections: { [name: string]: PageNavigation[] } = {
translationKey: "ui.panel.config.customize.caption",
icon: "hass:pencil",
core: true,
exportOnly: true,
advancedOnly: true,
},
],
other: [
@ -217,6 +225,13 @@ class HaPanelConfig extends HassRouterPage {
/* webpackChunkName: "panel-config-integrations" */ "./integrations/ha-config-integrations"
),
},
lovelace: {
tag: "ha-config-lovelace",
load: () =>
import(
/* webpackChunkName: "panel-config-lovelace" */ "./lovelace/ha-config-lovelace"
),
},
person: {
tag: "ha-config-person",
load: () =>

View File

@ -25,6 +25,7 @@ import "./forms/ha-input_select-form";
import "./forms/ha-input_number-form";
import { domainIcon } from "../../../common/entity/domain_icon";
import { classMap } from "lit-html/directives/class-map";
import { haStyleDialog } from "../../../resources/styles";
const HELPERS = {
input_boolean: createInputBoolean,
@ -156,37 +157,18 @@ export class DialogHelperDetail extends LitElement {
this._platform = undefined;
}
static get styles(): CSSResult {
return css`
ha-dialog {
--mdc-dialog-title-ink-color: var(--primary-text-color);
--justify-action-buttons: space-between;
}
ha-dialog.button-left {
--justify-action-buttons: flex-start;
}
@media only screen and (min-width: 600px) {
ha-dialog {
--mdc-dialog-min-width: 600px;
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
ha-dialog.button-left {
--justify-action-buttons: flex-start;
}
}
/* make dialog fullscreen on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-shape-radius: 0px;
--vertial-align-dialog: flex-end;
paper-icon-item {
cursor: pointer;
}
}
.error {
color: var(--google-red-500);
}
paper-icon-item {
cursor: pointer;
}
`;
`,
];
}
}

View File

@ -0,0 +1,262 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import "../../../../components/ha-icon-input";
import { HomeAssistant } from "../../../../types";
import {
LovelaceDashboard,
LovelaceDashboardMutableParams,
LovelaceDashboardCreateParams,
} from "../../../../data/lovelace";
import { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { PolymerChangedEvent } from "../../../../polymer-types";
import { HaSwitch } from "../../../../components/ha-switch";
import { createCloseHeading } from "../../../../components/ha-dialog";
import { haStyleDialog } from "../../../../resources/styles";
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _params?: LovelaceDashboardDetailsDialogParams;
@property() private _urlPath!: LovelaceDashboard["url_path"];
@property() private _showSidebar!: boolean;
@property() private _sidebarIcon!: string;
@property() private _sidebarTitle!: string;
@property() private _requireAdmin!: LovelaceDashboard["require_admin"];
@property() private _error?: string;
@property() private _submitting = false;
public async showDialog(
params: LovelaceDashboardDetailsDialogParams
): Promise<void> {
this._params = params;
this._error = undefined;
if (this._params.dashboard) {
this._urlPath = this._params.dashboard.url_path || "";
this._showSidebar = !!this._params.dashboard.sidebar;
this._sidebarIcon = this._params.dashboard.sidebar?.icon || "";
this._sidebarTitle = this._params.dashboard.sidebar?.title || "";
this._requireAdmin = this._params.dashboard.require_admin || false;
} else {
this._urlPath = "";
this._showSidebar = true;
this._sidebarIcon = "";
this._sidebarTitle = "";
this._requireAdmin = false;
}
await this.updateComplete;
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
const urlInvalid = !/^[a-zA-Z0-9_-]+$/.test(this._urlPath);
return html`
<ha-dialog
open
@closing="${this._close}"
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this._params.dashboard
? this._sidebarTitle ||
this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)
)}
>
<div>
${this._error
? html`
<div class="error">${this._error}</div>
`
: ""}
<div class="form">
<ha-switch
.checked=${this._showSidebar}
@change=${this._showSidebarChanged}
>${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.show_sidebar"
)}</ha-switch
>
${this._showSidebar
? html`
<ha-icon-input
.value=${this._sidebarIcon}
@value-changed=${this._sidebarIconChanged}
.label=${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.icon"
)}
></ha-icon-input>
<paper-input
.value=${this._sidebarTitle}
@value-changed=${this._sidebarTitleChanged}
.label=${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.title"
)}
@blur=${this._fillUrlPath}
></paper-input>
`
: ""}
${!this._params.dashboard
? html`
<paper-input
.value=${this._urlPath}
@value-changed=${this._urlChanged}
.label=${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.url"
)}
.errorMessage=${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.url_error_msg"
)}
.invalid=${urlInvalid}
></paper-input>
`
: ""}
<ha-switch
.checked=${this._requireAdmin}
@change=${this._requireAdminChanged}
>${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.require_admin"
)}</ha-switch
>
</div>
</div>
${this._params.dashboard
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click="${this._deleteDashboard}"
.disabled=${this._submitting}
>
${this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.delete"
)}
</mwc-button>
`
: html``}
<mwc-button
slot="primaryAction"
@click="${this._updateDashboard}"
.disabled=${urlInvalid || this._submitting}
>
${this._params.dashboard
? this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.update"
)
: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.detail.create"
)}
</mwc-button>
</ha-dialog>
`;
}
private _urlChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._urlPath = ev.detail.value;
}
private _sidebarIconChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._sidebarIcon = ev.detail.value;
}
private _sidebarTitleChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._sidebarTitle = ev.detail.value;
}
private _fillUrlPath() {
if (this._urlPath) {
return;
}
const parts = this._sidebarTitle.split(" ");
if (parts.length) {
this._urlPath = parts[0].toLowerCase();
}
}
private _showSidebarChanged(ev: Event) {
this._showSidebar = (ev.target as HaSwitch).checked;
}
private _requireAdminChanged(ev: Event) {
this._requireAdmin = (ev.target as HaSwitch).checked;
}
private async _updateDashboard() {
this._submitting = true;
try {
const values: Partial<LovelaceDashboardMutableParams> = {
require_admin: this._requireAdmin,
sidebar: this._showSidebar
? { icon: this._sidebarIcon, title: this._sidebarTitle }
: null,
};
if (this._params!.dashboard) {
await this._params!.updateDashboard(values);
} else {
(values as LovelaceDashboardCreateParams).url_path = this._urlPath.trim();
(values as LovelaceDashboardCreateParams).mode = "storage";
await this._params!.createDashboard(
values as LovelaceDashboardCreateParams
);
}
this._params = undefined;
} catch (err) {
this._error = err?.message || "Unknown error";
} finally {
this._submitting = false;
}
}
private async _deleteDashboard() {
this._submitting = true;
try {
if (await this._params!.removeDashboard()) {
this._close();
}
} finally {
this._submitting = false;
}
}
private _close(): void {
this._params = undefined;
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
.form {
padding-bottom: 24px;
}
ha-switch {
padding: 16px 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-lovelace-dashboard-detail": DialogLovelaceDashboardDetail;
}
}

View File

@ -0,0 +1,276 @@
import {
customElement,
html,
LitElement,
property,
PropertyValues,
TemplateResult,
CSSResult,
css,
} from "lit-element";
import memoize from "memoize-one";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../../components/data-table/ha-data-table";
import "../../../../components/ha-icon";
import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../../types";
import {
LovelaceDashboard,
fetchDashboards,
createDashboard,
updateDashboard,
deleteDashboard,
LovelaceDashboardCreateParams,
} from "../../../../data/lovelace";
import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail";
import { compare } from "../../../../common/string/compare";
import {
showConfirmationDialog,
showAlertDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { lovelaceTabs } from "../ha-config-lovelace";
import { navigate } from "../../../../common/navigate";
@customElement("ha-config-lovelace-dashboards")
export class HaConfigLovelaceDashboards extends LitElement {
@property() public hass!: HomeAssistant;
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@property() private _dashboards: LovelaceDashboard[] = [];
private _columns = memoize(
(_language, dashboards): DataTableColumnContainer => {
const columns: DataTableColumnContainer = {
icon: {
title: "",
type: "icon",
template: (icon) =>
icon
? html`
<ha-icon slot="item-icon" .icon=${icon}></ha-icon>
`
: html``,
},
title: {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.title"
),
sortable: true,
filterable: true,
direction: "asc",
},
mode: {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.conf_mode"
),
sortable: true,
filterable: true,
template: (mode) =>
html`
${this.hass.localize(
`ui.panel.config.lovelace.dashboards.conf_mode.${mode}`
) || mode}
`,
},
};
if (dashboards.some((dashboard) => dashboard.mode === "yaml")) {
columns.filename = {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.filename"
),
sortable: true,
filterable: true,
};
}
const columns2: DataTableColumnContainer = {
require_admin: {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.require_admin"
),
sortable: true,
type: "icon",
template: (requireAdmin: boolean) =>
requireAdmin
? html`
<ha-icon icon="hass:check-circle-outline"></ha-icon>
`
: html`
-
`,
},
sidebar: {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.headers.sidebar"
),
type: "icon",
template: (sidebar) =>
sidebar
? html`
<ha-icon icon="hass:check-circle-outline"></ha-icon>
`
: html`
-
`,
},
url_path: {
title: "",
type: "icon",
filterable: true,
template: (urlPath) =>
html`
<mwc-button .urlPath=${urlPath} @click=${this._navigate}
>${this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.open"
)}</mwc-button
>
`,
},
};
return { ...columns, ...columns2 };
}
);
private _getItems = memoize((dashboards: LovelaceDashboard[]) => {
return dashboards.map((dashboard) => {
return {
filename: "",
...dashboard,
icon: dashboard.sidebar?.icon,
title: dashboard.sidebar?.title || dashboard.url_path,
};
});
});
protected render(): TemplateResult {
if (!this.hass || this._dashboards === undefined) {
return html`
<hass-loading-screen></hass-loading-screen>
`;
}
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${lovelaceTabs}
.columns=${this._columns(this.hass.language, this._dashboards)}
.data=${this._getItems(this._dashboards)}
@row-click=${this._editDashboard}
>
</hass-tabs-subpage-data-table>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.add_dashboard"
)}"
@click=${this._addDashboard}
></ha-fab>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._getDashboards();
}
private async _getDashboards() {
this._dashboards = await fetchDashboards(this.hass);
}
private _navigate(ev: Event) {
ev.stopPropagation();
const url = `/${(ev.target as any).urlPath}`;
navigate(this, url);
}
private _editDashboard(ev: CustomEvent) {
const id = (ev.detail as RowClickedEvent).id;
const dashboard = id
? this._dashboards.find((res) => res.id === id)
: undefined;
if (!dashboard) {
showAlertDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
),
});
return;
}
this._openDialog(dashboard);
}
private _addDashboard() {
this._openDialog();
}
private async _openDialog(dashboard?: LovelaceDashboard): Promise<void> {
showDashboardDetailDialog(this, {
dashboard,
createDashboard: async (values: LovelaceDashboardCreateParams) => {
const created = await createDashboard(this.hass!, values);
this._dashboards = this._dashboards!.concat(
created
).sort((res1, res2) => compare(res1.url_path, res2.url_path));
},
updateDashboard: async (values) => {
const updated = await updateDashboard(
this.hass!,
dashboard!.id,
values
);
this._dashboards = this._dashboards!.map((res) =>
res === dashboard ? updated : res
);
},
removeDashboard: async () => {
if (
!(await showConfirmationDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.confirm_delete"
),
}))
) {
return false;
}
try {
await deleteDashboard(this.hass!, dashboard!.id);
this._dashboards = this._dashboards!.filter(
(res) => res !== dashboard
);
return true;
} catch (err) {
return false;
}
},
});
}
static get styles(): CSSResult {
return css`
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
`;
}
}

View File

@ -0,0 +1,31 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import {
LovelaceDashboard,
LovelaceDashboardMutableParams,
LovelaceDashboardCreateParams,
} from "../../../../data/lovelace";
export interface LovelaceDashboardDetailsDialogParams {
dashboard?: LovelaceDashboard;
createDashboard: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
updateDashboard: (
updates: Partial<LovelaceDashboardMutableParams>
) => Promise<unknown>;
removeDashboard: () => Promise<boolean>;
}
export const loadDashboardDetailDialog = () =>
import(
/* webpackChunkName: "lovelace-dashboard-detail-dialog" */ "./dialog-lovelace-dashboard-detail"
);
export const showDashboardDetailDialog = (
element: HTMLElement,
dialogParams: LovelaceDashboardDetailsDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-lovelace-dashboard-detail",
dialogImport: loadDashboardDetailDialog,
dialogParams,
});
};

View File

@ -0,0 +1,63 @@
import {
HassRouterPage,
RouterOptions,
} from "../../../layouts/hass-router-page";
import { property, customElement } from "lit-element";
import { HomeAssistant } from "../../../types";
export const lovelaceTabs = [
{
component: "lovelace",
path: "/config/lovelace/dashboards",
translationKey: "ui.panel.config.lovelace.dashboards.caption",
icon: "hass:view-dashboard",
},
{
component: "lovelace",
path: "/config/lovelace/resources",
translationKey: "ui.panel.config.lovelace.resources.caption",
icon: "hass:file-multiple",
advancedOnly: true,
},
];
@customElement("ha-config-lovelace")
class HaConfigLovelace extends HassRouterPage {
@property() public hass!: HomeAssistant;
@property() public narrow!: boolean;
@property() public isWide!: boolean;
protected routerOptions: RouterOptions = {
defaultPage: "dashboards",
routes: {
dashboards: {
tag: "ha-config-lovelace-dashboards",
load: () =>
import(
/* webpackChunkName: "panel-config-lovelace-dashboards" */ "./dashboards/ha-config-lovelace-dashboards"
),
cache: true,
},
resources: {
tag: "ha-config-lovelace-resources",
load: () =>
import(
/* webpackChunkName: "panel-config-lovelace-resources" */ "./resources/ha-config-lovelace-resources"
),
},
},
};
protected updatePageEl(pageEl) {
pageEl.hass = this.hass;
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.route = this.routeTail;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-lovelace": HaConfigLovelace;
}
}

View File

@ -0,0 +1,228 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { HomeAssistant } from "../../../../types";
import {
LovelaceResource,
LovelaceResourcesMutableParams,
} from "../../../../data/lovelace";
import { LovelaceResourceDetailsDialogParams } from "./show-dialog-lovelace-resource-detail";
import { PolymerChangedEvent } from "../../../../polymer-types";
import { createCloseHeading } from "../../../../components/ha-dialog";
import { haStyleDialog } from "../../../../resources/styles";
@customElement("dialog-lovelace-resource-detail")
export class DialogLovelaceResourceDetail extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _params?: LovelaceResourceDetailsDialogParams;
@property() private _url!: LovelaceResource["url"];
@property() private _type!: LovelaceResource["type"];
@property() private _error?: string;
@property() private _submitting = false;
public async showDialog(
params: LovelaceResourceDetailsDialogParams
): Promise<void> {
this._params = params;
this._error = undefined;
if (this._params.resource) {
this._url = this._params.resource.url || "";
this._type = this._params.resource.type || "module";
} else {
this._url = "";
this._type = "module";
}
await this.updateComplete;
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
const urlInvalid = this._url.trim() === "";
return html`
<ha-dialog
open
@closing=${this._close}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this._params.resource
? this._params.resource.url
: this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.new_resource"
)
)}
>
<div>
${this._error
? html`
<div class="error">${this._error}</div>
`
: ""}
<div class="form">
<h3 class="warning">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.warning_header"
)}
</h3>
${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.warning_text"
)}
<paper-input
.value=${this._url}
@value-changed=${this._urlChanged}
.label=${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.url"
)}
.errorMessage=${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.url_error_msg"
)}
.invalid=${urlInvalid}
></paper-input>
<br />
<ha-paper-dropdown-menu
.label=${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.type"
)}
>
<paper-listbox
slot="dropdown-content"
.selected=${this._type}
@iron-select=${this._typeChanged}
attr-for-selected="type"
>
<paper-item type="module">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.types.module"
)}
</paper-item>
${this._type === "js"
? html`
<paper-item type="js">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.types.js"
)}
</paper-item>
`
: ""}
<paper-item type="css">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.types.css"
)}
</paper-item>
${this._type === "html"
? html`
<paper-item type="html">
${this.hass!.localize(
"ui.panel.config.lovelace.resources.types.html"
)}
</paper-item>
`
: ""}
</paper-listbox>
</ha-paper-dropdown-menu>
</div>
</div>
${this._params.resource
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click="${this._deleteResource}"
.disabled=${this._submitting}
>
${this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.delete"
)}
</mwc-button>
`
: html``}
<mwc-button
slot="primaryAction"
@click="${this._updateResource}"
.disabled=${urlInvalid || this._submitting}
>
${this._params.resource
? this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.update"
)
: this.hass!.localize(
"ui.panel.config.lovelace.resources.detail.create"
)}
</mwc-button>
</ha-dialog>
`;
}
private _urlChanged(ev: PolymerChangedEvent<string>) {
this._error = undefined;
this._url = ev.detail.value;
}
private _typeChanged(ev: CustomEvent) {
this._type = ev.detail.item.getAttribute("type");
}
private async _updateResource() {
this._submitting = true;
try {
const values: LovelaceResourcesMutableParams = {
url: this._url.trim(),
res_type: this._type,
};
if (this._params!.resource) {
await this._params!.updateResource(values);
} else {
await this._params!.createResource(values);
}
this._params = undefined;
} catch (err) {
this._error = err?.message || "Unknown error";
} finally {
this._submitting = false;
}
}
private async _deleteResource() {
this._submitting = true;
try {
if (await this._params!.removeResource()) {
this._close();
}
} finally {
this._submitting = false;
}
}
private _close(): void {
this._params = undefined;
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
.form {
padding-bottom: 24px;
}
.warning {
color: var(--error-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-lovelace-resource-detail": DialogLovelaceResourceDetail;
}
}

View File

@ -0,0 +1,209 @@
import "@polymer/paper-checkbox/paper-checkbox";
import "@polymer/paper-dropdown-menu/paper-dropdown-menu";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-listbox/paper-listbox";
import "@polymer/paper-tooltip/paper-tooltip";
import {
customElement,
html,
LitElement,
property,
PropertyValues,
TemplateResult,
CSSResult,
css,
} from "lit-element";
import memoize from "memoize-one";
import "../../../../common/search/search-input";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../../components/data-table/ha-data-table";
import "../../../../components/ha-icon";
import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../../types";
import {
LovelaceResource,
fetchResources,
createResource,
updateResource,
deleteResource,
} from "../../../../data/lovelace";
import { showResourceDetailDialog } from "./show-dialog-lovelace-resource-detail";
import { compare } from "../../../../common/string/compare";
import {
showConfirmationDialog,
showAlertDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import { lovelaceTabs } from "../ha-config-lovelace";
import { loadLovelaceResources } from "../../../lovelace/common/load-resources";
@customElement("ha-config-lovelace-resources")
export class HaConfigLovelaceRescources extends LitElement {
@property() public hass!: HomeAssistant;
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@property() private _resources: LovelaceResource[] = [];
private _columns = memoize(
(_language): DataTableColumnContainer => {
return {
url: {
title: this.hass.localize(
"ui.panel.config.lovelace.resources.picker.headers.url"
),
sortable: true,
filterable: true,
direction: "asc",
},
type: {
title: this.hass.localize(
"ui.panel.config.lovelace.resources.picker.headers.type"
),
sortable: true,
filterable: true,
template: (type) =>
html`
${this.hass.localize(
`ui.panel.config.lovelace.resources.types.${type}`
) || type}
`,
},
};
}
);
protected render(): TemplateResult {
if (!this.hass || this._resources === undefined) {
return html`
<hass-loading-screen></hass-loading-screen>
`;
}
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${lovelaceTabs}
.columns=${this._columns(this.hass.language)}
.data=${this._resources}
@row-click=${this._editResource}
>
</hass-tabs-subpage-data-table>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize(
"ui.panel.config.lovelace.resources.picker.add_resource"
)}"
@click=${this._addResource}
></ha-fab>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._getResources();
}
private async _getResources() {
this._resources = await fetchResources(this.hass.connection);
}
private _editResource(ev: CustomEvent) {
if ((this.hass.panels.lovelace?.config as any)?.mode !== "storage") {
showAlertDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.resources.cant_edit_yaml"
),
});
return;
}
const id = (ev.detail as RowClickedEvent).id;
const resource = this._resources.find((res) => res.id === id);
this._openDialog(resource);
}
private _addResource() {
if ((this.hass.panels.lovelace?.config as any)?.mode !== "storage") {
showAlertDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.resources.cant_edit_yaml"
),
});
return;
}
this._openDialog();
}
private async _openDialog(resource?: LovelaceResource): Promise<void> {
showResourceDetailDialog(this, {
resource,
createResource: async (values) => {
const created = await createResource(this.hass!, values);
this._resources = this._resources!.concat(created).sort((res1, res2) =>
compare(res1.url, res2.url)
);
loadLovelaceResources(this._resources, this.hass!.auth.data.hassUrl);
},
updateResource: async (values) => {
const updated = await updateResource(this.hass!, resource!.id, values);
this._resources = this._resources!.map((res) =>
res === resource ? updated : res
);
loadLovelaceResources(this._resources, this.hass!.auth.data.hassUrl);
},
removeResource: async () => {
if (
!(await showConfirmationDialog(this, {
text: this.hass!.localize(
"ui.panel.config.lovelace.resources.confirm_delete"
),
}))
) {
return false;
}
try {
await deleteResource(this.hass!, resource!.id);
this._resources = this._resources!.filter((res) => res !== resource);
showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.lovelace.resources.refresh_header"
),
text: this.hass!.localize(
"ui.panel.config.lovelace.resources.refresh_body"
),
confirm: () => location.reload(),
});
return true;
} catch (err) {
return false;
}
},
});
}
static get styles(): CSSResult {
return css`
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
`;
}
}

View File

@ -0,0 +1,30 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import {
LovelaceResource,
LovelaceResourcesMutableParams,
} from "../../../../data/lovelace";
export interface LovelaceResourceDetailsDialogParams {
resource?: LovelaceResource;
createResource: (values: LovelaceResourcesMutableParams) => Promise<unknown>;
updateResource: (
updates: Partial<LovelaceResourcesMutableParams>
) => Promise<unknown>;
removeResource: () => Promise<boolean>;
}
export const loadResourceDetailDialog = () =>
import(
/* webpackChunkName: "lovelace-resource-detail-dialog" */ "./dialog-lovelace-resource-detail"
);
export const showResourceDetailDialog = (
element: HTMLElement,
dialogParams: LovelaceResourceDetailsDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-lovelace-resource-detail",
dialogImport: loadResourceDetailDialog,
dialogParams,
});
};

View File

@ -13,11 +13,12 @@ import "@material/mwc-button";
import "../../../components/entity/ha-entities-picker";
import "../../../components/user/ha-user-picker";
import "../../../components/ha-dialog";
import { PersonDetailDialogParams } from "./show-dialog-person-detail";
import { PolymerChangedEvent } from "../../../polymer-types";
import { HomeAssistant } from "../../../types";
import { PersonMutableParams } from "../../../data/person";
import { createCloseHeading } from "../../../components/ha-dialog";
import { haStyleDialog } from "../../../resources/styles";
class DialogPersonDetail extends LitElement {
@property() public hass!: HomeAssistant;
@ -55,26 +56,18 @@ class DialogPersonDetail extends LitElement {
return html``;
}
const nameInvalid = this._name.trim() === "";
const title = html`
${this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.person.detail.new_person")}
<paper-icon-button
aria-label=${this.hass.localize(
"ui.panel.config.integrations.config_flow.dismiss"
)}
icon="hass:close"
dialogAction="close"
style="position: absolute; right: 16px; top: 12px;"
></paper-icon-button>
`;
return html`
<ha-dialog
open
@closing="${this._close}"
scrimClickAction=""
escapeKeyAction=""
.heading=${title}
.heading=${createCloseHeading(
this.hass,
this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.person.detail.new_person")
)}
>
<div>
${this._error
@ -236,34 +229,14 @@ class DialogPersonDetail extends LitElement {
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-min-width: 400px;
--mdc-dialog-max-width: 600px;
--mdc-dialog-title-ink-color: var(--primary-text-color);
--justify-action-buttons: space-between;
}
/* make dialog fullscreen on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-shape-radius: 0px;
--vertial-align-dialog: flex-end;
}
}
.form {
padding-bottom: 24px;
}
ha-user-picker {
margin-top: 16px;
}
mwc-button.warning {
--mdc-theme-primary: var(--google-red-500);
}
.error {
color: var(--google-red-500);
}
a {
color: var(--primary-color);
}

View File

@ -12,7 +12,6 @@ import "@material/mwc-button";
import "../../../components/map/ha-location-editor";
import "../../../components/ha-switch";
import "../../../components/ha-dialog";
import { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
import { HomeAssistant } from "../../../types";
@ -23,6 +22,8 @@ import {
getZoneEditorInitData,
} from "../../../data/zone";
import { addDistanceToCoord } from "../../../common/location/add_distance_to_coord";
import { createCloseHeading } from "../../../components/ha-dialog";
import { haStyleDialog } from "../../../resources/styles";
class DialogZoneDetail extends LitElement {
@property() public hass!: HomeAssistant;
@ -72,19 +73,6 @@ class DialogZoneDetail extends LitElement {
if (!this._params) {
return html``;
}
const title = html`
${this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.zone.detail.new_zone")}
<paper-icon-button
aria-label=${this.hass.localize(
"ui.panel.config.integrations.config_flow.dismiss"
)}
icon="hass:close"
dialogAction="close"
style="position: absolute; right: 16px; top: 12px;"
></paper-icon-button>
`;
const nameValid = this._name.trim() === "";
const iconValid = !this._icon.trim().includes(":");
const latValid = String(this._latitude) === "";
@ -100,7 +88,12 @@ class DialogZoneDetail extends LitElement {
@closing="${this._close}"
scrimClickAction=""
escapeKeyAction=""
.heading=${title}
.heading=${createCloseHeading(
this.hass,
this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.zone.detail.new_zone")
)}
>
<div>
${this._error
@ -277,26 +270,8 @@ class DialogZoneDetail extends LitElement {
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-title-ink-color: var(--primary-text-color);
--justify-action-buttons: space-between;
}
@media only screen and (min-width: 600px) {
ha-dialog {
--mdc-dialog-min-width: 600px;
}
}
/* make dialog fullscreen on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-shape-radius: 0px;
--vertial-align-dialog: flex-end;
}
}
.form {
padding-bottom: 24px;
color: var(--primary-text-color);
@ -320,12 +295,6 @@ class DialogZoneDetail extends LitElement {
ha-user-picker {
margin-top: 16px;
}
mwc-button.warning {
--mdc-theme-primary: var(--google-red-500);
}
.error {
color: var(--google-red-500);
}
a {
color: var(--primary-color);
}

View File

@ -1,13 +1,13 @@
import { loadModule, loadCSS, loadJS } from "../../../common/dom/load_resource";
import { LovelaceResources } from "../../../data/lovelace";
import { LovelaceResource } from "../../../data/lovelace";
// CSS and JS should only be imported once. Modules and HTML are safe.
const CSS_CACHE = {};
const JS_CACHE = {};
export const loadLovelaceResources = (
resources: NonNullable<LovelaceResources>,
resources: NonNullable<LovelaceResource[]>,
hassUrl: string
) =>
resources.forEach((resource) => {

View File

@ -180,4 +180,27 @@ export const haStyleDialog = css`
border-bottom-right-radius: 0px;
}
}
/* mwc-dialog (ha-dialog) styles */
ha-dialog {
--mdc-dialog-min-width: 400px;
--mdc-dialog-max-width: 600px;
--mdc-dialog-title-ink-color: var(--primary-text-color);
--justify-action-buttons: space-between;
}
/* make dialog fullscreen on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-shape-radius: 0px;
--vertial-align-dialog: flex-end;
}
}
mwc-button.warning {
--mdc-theme-primary: var(--google-red-500);
}
.error {
color: var(--google-red-500);
}
`;

View File

@ -595,7 +595,8 @@
"generic": {
"cancel": "Cancel",
"ok": "OK",
"default_confirmation_title": "Are you sure?"
"default_confirmation_title": "Are you sure?",
"close": "close"
},
"more_info_control": {
"dismiss": "Dismiss dialog",
@ -847,6 +848,76 @@
}
}
},
"lovelace": {
"caption": "Lovelace Dashboards",
"description": "Configure your Lovelace Dashboards",
"dashboards": {
"caption": "Dashboards",
"conf_mode": {
"yaml": "YAML file",
"storage": "UI controlled"
},
"picker": {
"headers": {
"title": "Title",
"conf_mode": "Configuration method",
"require_admin": "Admin only",
"sidebar": "Show in sidebar",
"filename": "Filename"
},
"open": "Open dashboard",
"add_dashboard": "Add dashboard"
},
"confirm_delete": "Are you sure you want to delete this dashboard?",
"cant_edit_yaml": "Dashboards defined in YAML can not be edited from the UI. Change them in configuration.yaml.",
"detail": {
"edit_dashboard": "Edit dashboard",
"new_dashboard": "Add new dashboard",
"dismiss": "Close",
"show_sidebar": "Show in sidebar",
"icon": "Sidebar icon",
"title": "Sidebar title",
"url": "Url",
"url_error_msg": "The url can not contain spaces or special characters, except for _ and -",
"require_admin": "Admin only",
"delete": "Delete",
"update": "Update",
"create": "Create"
}
},
"resources": {
"caption": "Resources",
"types": {
"css": "Stylesheet",
"html": "HTML (deprecated)",
"js": "JavaScript File (deprecated)",
"module": "JavaScript Module"
},
"picker": {
"headers": {
"url": "Url",
"type": "Type"
},
"add_resource": "Add resource"
},
"confirm_delete": "Are you sure you want to delete this resource?",
"refresh_header": "Do you want to refresh?",
"refresh_body": "You have to refresh the page to complete the removal, do you want to refresh now?",
"cant_edit_yaml": "You are using Lovelace in YAML mode, therefore you can not manage your resources through the UI. Manage them in configuration.yaml.",
"detail": {
"new_resource": "Add new resource",
"dismiss": "Close",
"warning_header": "Be cautious!",
"warning_text": "Adding resources can be dangerous, make sure you know the source of the resource and trust them. Bad resources could seriously harm your system.",
"url": "Url",
"url_error_msg": "Url is a required field",
"type": "Resource type",
"delete": "Delete",
"update": "Update",
"create": "Create"
}
}
},
"server_control": {
"caption": "Server Controls",
"description": "Restart and stop the Home Assistant server",