diff --git a/src/panels/lovelace/editor/config-util.ts b/src/panels/lovelace/editor/config-util.ts index e04e4206ff..10c0e37e87 100644 --- a/src/panels/lovelace/editor/config-util.ts +++ b/src/panels/lovelace/editor/config-util.ts @@ -179,12 +179,21 @@ export const moveCard = ( export const addView = ( hass: HomeAssistant, config: LovelaceConfig, - viewConfig: LovelaceViewConfig + viewConfig: LovelaceViewConfig, + tolerantPath = false ): LovelaceConfig => { if (viewConfig.path && config.views.some((v) => v.path === viewConfig.path)) { - throw new Error( - hass.localize("ui.panel.lovelace.editor.edit_view.error_same_url") - ); + if (!tolerantPath) { + throw new Error( + hass.localize("ui.panel.lovelace.editor.edit_view.error_same_url") + ); + } else { + // add a suffix to the path + viewConfig = { + ...viewConfig, + path: `${viewConfig.path}-2`, + }; + } } return { ...config, @@ -240,6 +249,20 @@ export const deleteView = ( views: config.views.filter((_origView, index) => index !== viewIndex), }); +export const moveViewToDashboard = ( + hass: HomeAssistant, + fromConfig: LovelaceConfig, + toConfig: LovelaceConfig, + viewIndex: number +): [LovelaceConfig, LovelaceConfig] => { + const view = fromConfig.views[viewIndex]; + + return [ + deleteView(fromConfig, viewIndex), + addView(hass, toConfig, view, true), + ]; +}; + export const addSection = ( config: LovelaceConfig, viewIndex: number, diff --git a/src/panels/lovelace/editor/select-dashboard/hui-dialog-select-dashboard.ts b/src/panels/lovelace/editor/select-dashboard/hui-dialog-select-dashboard.ts new file mode 100644 index 0000000000..f132c66638 --- /dev/null +++ b/src/panels/lovelace/editor/select-dashboard/hui-dialog-select-dashboard.ts @@ -0,0 +1,193 @@ +import { mdiClose } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, query, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-md-dialog"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-md-select"; +import "../../../../components/ha-md-select-option"; +import "../../../../components/ha-button"; +import "../../../../components/ha-circular-progress"; +import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; +import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard"; +import { fetchDashboards } from "../../../../data/lovelace/dashboard"; +import { haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import type { SelectDashboardDialogParams } from "./show-select-dashboard-dialog"; +import type { HaMdDialog } from "../../../../components/ha-md-dialog"; + +@customElement("hui-dialog-select-dashboard") +export class HuiDialogSelectDashboard extends LitElement { + public hass!: HomeAssistant; + + @state() private _params?: SelectDashboardDialogParams; + + @state() private _dashboards?: LovelaceDashboard[]; + + @state() private _fromUrlPath?: string | null; + + @state() private _toUrlPath?: string | null; + + @state() private _config?: LovelaceConfig; + + @state() private _saving = false; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public showDialog(params: SelectDashboardDialogParams): void { + this._config = params.lovelaceConfig; + this._fromUrlPath = params.urlPath; + this._params = params; + this._getDashboards(); + } + + public closeDialog(): void { + this._saving = false; + this._dashboards = undefined; + this._toUrlPath = undefined; + this._dialog?.close(); + } + + private _dialogClosed(): void { + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params) { + return nothing; + } + + const dialogTitle = + this._params.header || + this.hass.localize("ui.panel.lovelace.editor.select_dashboard.header"); + + return html` + + + + ${dialogTitle} + + + ${this._dashboards && !this._saving + ? html` + + ${this._dashboards.map( + (dashboard) => html` + ${dashboard.title} + ` + )} + + ` + : html` + + `} + + + + ${this.hass!.localize("ui.common.cancel")} + + + ${this._params.actionLabel || this.hass!.localize("ui.common.move")} + + + + `; + } + + private async _getDashboards() { + this._dashboards = [ + { + id: "lovelace", + url_path: "lovelace", + require_admin: false, + show_in_sidebar: true, + title: this.hass.localize("ui.common.default"), + mode: this.hass.panels.lovelace?.config?.mode, + }, + ...(this._params!.dashboards || (await fetchDashboards(this.hass))), + ].filter( + (dashboard) => this.hass.user!.is_admin || !dashboard.require_admin + ); + + const currentPath = this._fromUrlPath || this.hass.defaultPanel; + for (const dashboard of this._dashboards!) { + if (dashboard.url_path !== currentPath) { + this._toUrlPath = dashboard.url_path; + break; + } + } + } + + private async _dashboardChanged(ev) { + const urlPath: string = ev.target.value; + if (urlPath === this._toUrlPath) { + return; + } + this._toUrlPath = urlPath; + } + + private async _selectDashboard() { + this._saving = true; + if (this._toUrlPath! === "lovelace") { + this._toUrlPath = null; + } + this._params!.dashboardSelectedCallback(this._toUrlPath!); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-md-select { + width: 100%; + } + .loading { + display: flex; + justify-content: center; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-dialog-select-dashboard": HuiDialogSelectDashboard; + } +} diff --git a/src/panels/lovelace/editor/select-dashboard/show-select-dashboard-dialog.ts b/src/panels/lovelace/editor/select-dashboard/show-select-dashboard-dialog.ts new file mode 100644 index 0000000000..e36eacfac8 --- /dev/null +++ b/src/panels/lovelace/editor/select-dashboard/show-select-dashboard-dialog.ts @@ -0,0 +1,23 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; +import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard"; + +export interface SelectDashboardDialogParams { + lovelaceConfig: LovelaceConfig; + dashboards?: LovelaceDashboard[]; + urlPath?: string | null; + header?: string; + actionLabel?: string; + dashboardSelectedCallback: (urlPath: string | null) => any; +} + +export const showSelectDashboardDialog = ( + element: HTMLElement, + selectViewDialogParams: SelectDashboardDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "hui-dialog-select-dashboard", + dialogImport: () => import("./hui-dialog-select-dashboard"), + dialogParams: selectViewDialogParams, + }); +}; diff --git a/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts b/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts index 7d89b320ee..f3c52d3719 100644 --- a/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts +++ b/src/panels/lovelace/editor/view-editor/hui-dialog-edit-view.ts @@ -1,8 +1,12 @@ -import "@material/mwc-button"; import type { ActionDetail } from "@material/mwc-list"; import "@material/mwc-tab-bar/mwc-tab-bar"; import "@material/mwc-tab/mwc-tab"; -import { mdiClose, mdiDotsVertical, mdiPlaylistEdit } from "@mdi/js"; +import { + mdiClose, + mdiDotsVertical, + mdiFileMoveOutline, + mdiPlaylistEdit, +} from "@mdi/js"; import type { CSSResultGroup, PropertyValues } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; @@ -31,12 +35,25 @@ import "../../components/hui-entity-editor"; import { SECTIONS_VIEW_LAYOUT } from "../../views/const"; import { generateDefaultSection } from "../../views/default-section"; import { getViewType } from "../../views/get-view-type"; -import { addView, deleteView, replaceView } from "../config-util"; +import { + addView, + deleteView, + moveViewToDashboard, + replaceView, +} from "../config-util"; import type { ViewEditEvent, ViewVisibilityChangeEvent } from "../types"; import "./hui-view-background-editor"; import "./hui-view-editor"; import "./hui-view-visibility-editor"; import type { EditViewDialogParams } from "./show-edit-view-dialog"; +import { showSelectDashboardDialog } from "../select-dashboard/show-select-dashboard-dialog"; +import { + fetchConfig, + isStrategyDashboard, + saveConfig, + type LovelaceConfig, +} from "../../../../data/lovelace/config/types"; +import type { Lovelace } from "../../types"; const TABS = ["tab-settings", "tab-background", "tab-visibility"] as const; @@ -46,6 +63,8 @@ export class HuiDialogEditView extends LitElement { @state() private _params?: EditViewDialogParams; + @state() private _lovelace?: Lovelace; + @state() private _config?: LovelaceViewConfig; @state() private _saving = false; @@ -83,7 +102,10 @@ export class HuiDialogEditView extends LitElement { this._dirty = false; return; } - const view = this._params.lovelace!.config.views[this._params.viewIndex]; + + this._lovelace = this._params.lovelace; + + const view = this._lovelace.config.views[this._params.viewIndex]; // Todo : add better support for strategy views if (isStrategyView(view)) { const { strategy, ...viewConfig } = view; @@ -212,6 +234,15 @@ export class HuiDialogEditView extends LitElement { .path=${mdiPlaylistEdit} > + + ${this.hass!.localize( + "ui.panel.lovelace.editor.edit_view.move_to_dashboard" + )} + + ${convertToSection ? html` @@ -300,9 +331,102 @@ export class HuiDialogEditView extends LitElement { case 0: this._yamlMode = !this._yamlMode; break; + case 1: + this._openSelectDashboard(); + break; } } + private _openSelectDashboard(): void { + showSelectDashboardDialog(this, { + lovelaceConfig: this._lovelace!.config, + dashboardSelectedCallback: this._moveViewToDashboard, + urlPath: this._lovelace!.urlPath, + }); + } + + private _moveViewToDashboard = async (urlPath: string | null) => { + let errorMessage; + let toConfig; + let undoAction; + + try { + toConfig = (await fetchConfig( + this.hass!.connection, + urlPath, + false + )) as LovelaceConfig; + } catch (err: any) { + errorMessage = this.hass!.localize( + "ui.panel.lovelace.editor.select_dashboard.get_config_failed" + ); + // eslint-disable-next-line no-console + console.error(err); + } + + if (toConfig && isStrategyDashboard(toConfig)) { + errorMessage = this.hass!.localize( + "ui.panel.lovelace.editor.select_dashboard.cannot_move_to_strategy" + ); + } + + if (!errorMessage) { + const [newFromConfig, newToConfig] = moveViewToDashboard( + this.hass!, + this._lovelace!.config, + toConfig, + this._params!.viewIndex! + ); + + const oldFromConfig = this._lovelace!.config; + const oldToConfig = toConfig; + + undoAction = async () => { + await saveConfig(this.hass!, urlPath, oldToConfig); + await this._lovelace!.saveConfig(oldFromConfig); + }; + + try { + await this._lovelace!.saveConfig(newFromConfig); + await saveConfig(this.hass!, urlPath, newToConfig); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + try { + await undoAction(); + errorMessage = this.hass!.localize( + "ui.panel.lovelace.editor.select_dashboard.move_failed" + ); + } catch (revertError) { + // eslint-disable-next-line no-console + console.error(revertError); + errorMessage = this.hass!.localize( + "ui.panel.lovelace.editor.select_dashboard.revert_failed" + ); + } + } + } + + if (errorMessage) { + showAlertDialog(this, { + text: errorMessage, + }); + } else { + this._lovelace!.showToast({ + message: this.hass!.localize( + "ui.panel.lovelace.editor.select_dashboard.success" + ), + duration: 4000, + action: { + action: undoAction, + text: this.hass!.localize("ui.common.undo"), + }, + }); + this.closeDialog(); + navigate(`/${window.location.pathname.split("/")[1]}`); + } + }; + private async _convertToSection() { if (!this._params || !this._config) { return; diff --git a/src/translations/en.json b/src/translations/en.json index a58f72c290..13bc5c84de 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5929,7 +5929,8 @@ "edit_ui": "Edit in visual editor", "edit_yaml": "Edit in YAML", "saving_failed": "Saving failed", - "error_same_url": "You cannot save a view with the same URL as a different existing view." + "error_same_url": "You cannot save a view with the same URL as a different existing view.", + "move_to_dashboard": "Move to dashboard" }, "edit_badges": { "view_no_badges": "Badges are not be supported by the current view type." @@ -6022,6 +6023,14 @@ "no_views": "No views in this dashboard.", "strategy_type": "strategy" }, + "select_dashboard": { + "header": "Choose a dasboard", + "cannot_move_to_strategy": "The view cannot be moved because the selected dashboard is auto generated.", + "get_config_failed": "Failed to load selected dashboard config.", + "move_failed": "Failed to move the view to the new dashboard, please try again.", + "revert_failed": "Failed to move the view to the new dashboard, your dashboards are in an unknown state. Please reload the page and check if everything is in place.", + "success": "View moved successfully" + }, "section": { "add_badge": "Add badge", "add_card": "[%key:ui::panel::lovelace::editor::edit_card::add%]",