diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index 28b657cd1c..73188403e6 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -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); diff --git a/src/components/ha-dialog.ts b/src/components/ha-dialog.ts index ff770e7bb7..290f5ad22f 100644 --- a/src/components/ha-dialog.ts +++ b/src/components/ha-dialog.ts @@ -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; +export const createCloseHeading = (hass: HomeAssistant, title: string) => html` + ${title} + +`; + @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; + } `, ]; } diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index 618c146f5f..cde4dda192 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -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 => +export const fetchResources = (conn: Connection): Promise => conn.sendMessagePromise({ type: "lovelace/resources", }); + +export const createResource = ( + hass: HomeAssistant, + values: LovelaceResourcesMutableParams +) => + hass.callWS({ + type: "lovelace/resources/create", + ...values, + }); + +export const updateResource = ( + hass: HomeAssistant, + id: string, + updates: Partial +) => + hass.callWS({ + 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 => + hass.callWS({ + type: "lovelace/dashboards/list", + }); + +export const createDashboard = ( + hass: HomeAssistant, + values: LovelaceDashboardCreateParams +) => + hass.callWS({ + type: "lovelace/dashboards/create", + ...values, + }); + +export const updateDashboard = ( + hass: HomeAssistant, + id: string, + updates: Partial +) => + hass.callWS({ + 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; - llResProm?: Promise; + llResProm?: Promise; } export interface ActionHandlerOptions { diff --git a/src/layouts/hass-tabs-subpage.ts b/src/layouts/hass-tabs-subpage.ts index a3130916df..7d904c1a5f 100644 --- a/src/layouts/hass-tabs-subpage.ts +++ b/src/layouts/hass-tabs-subpage.ts @@ -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` +
+ ${this.narrow + ? html` + + ` + : ""} + ${!this.narrow || index === activeTab + ? html` + ${page.translationKey + ? this.hass.localize(page.translationKey) + : name} + ` + : ""} + +
+ ` + ); + } + ); + 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`
` : ""} -
- ${this.tabs.map((page, index) => - (!page.component || - page.core || - isComponentLoaded(this.hass, page.component)) && - (!page.exportOnly || this.showAdvanced) - ? html` -
- ${this.narrow - ? html` - - ` - : ""} - ${!this.narrow || index === this._activeTab - ? html` - ${page.translationKey - ? this.hass.localize(page.translationKey) - : name} - ` - : ""} - -
- ` - : "" - )} -
- + ${tabs.length > 1 || !this.narrow + ? html` +
+ ${tabs} +
+ ` + : ""}
diff --git a/src/panels/config/dashboard/ha-config-navigation.ts b/src/panels/config/dashboard/ha-config-navigation.ts index 3af4194ac9..2d0116daf1 100644 --- a/src/panels/config/dashboard/ha-config-navigation.ts +++ b/src/panels/config/dashboard/ha-config-navigation.ts @@ -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` + import( + /* webpackChunkName: "panel-config-lovelace" */ "./lovelace/ha-config-lovelace" + ), + }, person: { tag: "ha-config-person", load: () => diff --git a/src/panels/config/helpers/dialog-helper-detail.ts b/src/panels/config/helpers/dialog-helper-detail.ts index 2ede201a0b..2b2ad20508 100644 --- a/src/panels/config/helpers/dialog-helper-detail.ts +++ b/src/panels/config/helpers/dialog-helper-detail.ts @@ -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; - } - `; + `, + ]; } } diff --git a/src/panels/config/lovelace/dashboards/dialog-lovelace-dashboard-detail.ts b/src/panels/config/lovelace/dashboards/dialog-lovelace-dashboard-detail.ts new file mode 100644 index 0000000000..9698c6a9fa --- /dev/null +++ b/src/panels/config/lovelace/dashboards/dialog-lovelace-dashboard-detail.ts @@ -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 { + 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` + +
+ ${this._error + ? html` +
${this._error}
+ ` + : ""} +
+ ${this.hass!.localize( + "ui.panel.config.lovelace.dashboards.detail.show_sidebar" + )} + ${this._showSidebar + ? html` + + + ` + : ""} + ${!this._params.dashboard + ? html` + + ` + : ""} + ${this.hass!.localize( + "ui.panel.config.lovelace.dashboards.detail.require_admin" + )} +
+
+ ${this._params.dashboard + ? html` + + ${this.hass!.localize( + "ui.panel.config.lovelace.dashboards.detail.delete" + )} + + ` + : html``} + + ${this._params.dashboard + ? this.hass!.localize( + "ui.panel.config.lovelace.dashboards.detail.update" + ) + : this.hass!.localize( + "ui.panel.config.lovelace.dashboards.detail.create" + )} + +
+ `; + } + + private _urlChanged(ev: PolymerChangedEvent) { + this._error = undefined; + this._urlPath = ev.detail.value; + } + + private _sidebarIconChanged(ev: PolymerChangedEvent) { + this._error = undefined; + this._sidebarIcon = ev.detail.value; + } + + private _sidebarTitleChanged(ev: PolymerChangedEvent) { + 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 = { + 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; + } +} diff --git a/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts b/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts new file mode 100644 index 0000000000..e791036abc --- /dev/null +++ b/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts @@ -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` + + ` + : 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` + + ` + : html` + - + `, + }, + sidebar: { + title: this.hass.localize( + "ui.panel.config.lovelace.dashboards.picker.headers.sidebar" + ), + type: "icon", + template: (sidebar) => + sidebar + ? html` + + ` + : html` + - + `, + }, + url_path: { + title: "", + type: "icon", + filterable: true, + template: (urlPath) => + html` + ${this.hass.localize( + "ui.panel.config.lovelace.dashboards.picker.open" + )} + `, + }, + }; + 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` + + `; + } + + return html` + + + + `; + } + + 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 { + 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; + } + `; + } +} diff --git a/src/panels/config/lovelace/dashboards/show-dialog-lovelace-dashboard-detail.ts b/src/panels/config/lovelace/dashboards/show-dialog-lovelace-dashboard-detail.ts new file mode 100644 index 0000000000..84c72d7176 --- /dev/null +++ b/src/panels/config/lovelace/dashboards/show-dialog-lovelace-dashboard-detail.ts @@ -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; + updateDashboard: ( + updates: Partial + ) => Promise; + removeDashboard: () => Promise; +} + +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, + }); +}; diff --git a/src/panels/config/lovelace/ha-config-lovelace.ts b/src/panels/config/lovelace/ha-config-lovelace.ts new file mode 100644 index 0000000000..d509d9161b --- /dev/null +++ b/src/panels/config/lovelace/ha-config-lovelace.ts @@ -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; + } +} diff --git a/src/panels/config/lovelace/resources/dialog-lovelace-resource-detail.ts b/src/panels/config/lovelace/resources/dialog-lovelace-resource-detail.ts new file mode 100644 index 0000000000..f2571805d3 --- /dev/null +++ b/src/panels/config/lovelace/resources/dialog-lovelace-resource-detail.ts @@ -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 { + 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` + +
+ ${this._error + ? html` +
${this._error}
+ ` + : ""} +
+

+ ${this.hass!.localize( + "ui.panel.config.lovelace.resources.detail.warning_header" + )} +

+ ${this.hass!.localize( + "ui.panel.config.lovelace.resources.detail.warning_text" + )} + +
+ + + + ${this.hass!.localize( + "ui.panel.config.lovelace.resources.types.module" + )} + + ${this._type === "js" + ? html` + + ${this.hass!.localize( + "ui.panel.config.lovelace.resources.types.js" + )} + + ` + : ""} + + ${this.hass!.localize( + "ui.panel.config.lovelace.resources.types.css" + )} + + ${this._type === "html" + ? html` + + ${this.hass!.localize( + "ui.panel.config.lovelace.resources.types.html" + )} + + ` + : ""} + + +
+
+ ${this._params.resource + ? html` + + ${this.hass!.localize( + "ui.panel.config.lovelace.resources.detail.delete" + )} + + ` + : html``} + + ${this._params.resource + ? this.hass!.localize( + "ui.panel.config.lovelace.resources.detail.update" + ) + : this.hass!.localize( + "ui.panel.config.lovelace.resources.detail.create" + )} + +
+ `; + } + + private _urlChanged(ev: PolymerChangedEvent) { + 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; + } +} diff --git a/src/panels/config/lovelace/resources/ha-config-lovelace-resources.ts b/src/panels/config/lovelace/resources/ha-config-lovelace-resources.ts new file mode 100644 index 0000000000..bda6b20e9b --- /dev/null +++ b/src/panels/config/lovelace/resources/ha-config-lovelace-resources.ts @@ -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` + + `; + } + + return html` + + + + `; + } + + 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 { + 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; + } + `; + } +} diff --git a/src/panels/config/lovelace/resources/show-dialog-lovelace-resource-detail.ts b/src/panels/config/lovelace/resources/show-dialog-lovelace-resource-detail.ts new file mode 100644 index 0000000000..815f32f642 --- /dev/null +++ b/src/panels/config/lovelace/resources/show-dialog-lovelace-resource-detail.ts @@ -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; + updateResource: ( + updates: Partial + ) => Promise; + removeResource: () => Promise; +} + +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, + }); +}; diff --git a/src/panels/config/person/dialog-person-detail.ts b/src/panels/config/person/dialog-person-detail.ts index e22cec2b71..95d16c9284 100644 --- a/src/panels/config/person/dialog-person-detail.ts +++ b/src/panels/config/person/dialog-person-detail.ts @@ -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")} - - `; return html`
${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); } diff --git a/src/panels/config/zone/dialog-zone-detail.ts b/src/panels/config/zone/dialog-zone-detail.ts index 24242c6822..580cd5f87c 100644 --- a/src/panels/config/zone/dialog-zone-detail.ts +++ b/src/panels/config/zone/dialog-zone-detail.ts @@ -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")} - - `; 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") + )} >
${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); } diff --git a/src/panels/lovelace/common/load-resources.ts b/src/panels/lovelace/common/load-resources.ts index b08736bbee..1b58eeb509 100644 --- a/src/panels/lovelace/common/load-resources.ts +++ b/src/panels/lovelace/common/load-resources.ts @@ -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, + resources: NonNullable, hassUrl: string ) => resources.forEach((resource) => { diff --git a/src/resources/styles.ts b/src/resources/styles.ts index 95838b464e..972e734538 100644 --- a/src/resources/styles.ts +++ b/src/resources/styles.ts @@ -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); + } `; diff --git a/src/translations/en.json b/src/translations/en.json index 870d45e079..19985e2b29 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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",