1
0
mirror of https://github.com/home-assistant/frontend.git synced 2025-12-20 02:38:53 +00:00

Add undo/redo functionality to dashboard editor (#27259)

* Add undo/redo functionality to dashboard editor

* Use controller and move toast to undo stack

* Store location and navigate to view

* Await and catch errors

* Process code review
This commit is contained in:
Jan-Philipp Benecke
2025-11-25 15:09:13 +01:00
committed by GitHub
parent c01fbf5f47
commit fe50c1212a
4 changed files with 108 additions and 12 deletions

View File

@@ -2,6 +2,7 @@ import type { HomeAssistant } from "../../../types";
import type { Lovelace } from "../types"; import type { Lovelace } from "../types";
import { deleteBadge } from "./config-util"; import { deleteBadge } from "./config-util";
import type { LovelaceCardPath } from "./lovelace-path"; import type { LovelaceCardPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
export interface DeleteBadgeParams { export interface DeleteBadgeParams {
path: LovelaceCardPath; path: LovelaceCardPath;
@@ -23,14 +24,13 @@ export async function performDeleteBadge(
return; return;
} }
const action = async () => {
lovelace.saveConfig(oldConfig);
};
lovelace.showToast({ lovelace.showToast({
message: hass.localize("ui.common.successfully_deleted"), message: hass.localize("ui.common.successfully_deleted"),
duration: 8000, duration: 8000,
action: { action, text: hass.localize("ui.common.undo") }, action: {
action: () => fireEvent(window, "undo-change"),
text: hass.localize("ui.common.undo"),
},
}); });
} catch (err: any) { } catch (err: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View File

@@ -2,6 +2,7 @@ import type { HomeAssistant } from "../../../types";
import type { Lovelace } from "../types"; import type { Lovelace } from "../types";
import { deleteCard } from "./config-util"; import { deleteCard } from "./config-util";
import type { LovelaceCardPath } from "./lovelace-path"; import type { LovelaceCardPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
export interface DeleteCardParams { export interface DeleteCardParams {
path: LovelaceCardPath; path: LovelaceCardPath;
@@ -23,14 +24,13 @@ export async function performDeleteCard(
return; return;
} }
const action = async () => {
lovelace.saveConfig(oldConfig);
};
lovelace.showToast({ lovelace.showToast({
message: hass.localize("ui.common.successfully_deleted"), message: hass.localize("ui.common.successfully_deleted"),
duration: 8000, duration: 8000,
action: { action, text: hass.localize("ui.common.undo") }, action: {
action: () => fireEvent(window, "undo-change"),
text: hass.localize("ui.common.undo"),
},
}); });
} catch (err: any) { } catch (err: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View File

@@ -11,10 +11,12 @@ import {
mdiMagnify, mdiMagnify,
mdiPencil, mdiPencil,
mdiPlus, mdiPlus,
mdiRedo,
mdiRefresh, mdiRefresh,
mdiRobot, mdiRobot,
mdiShape, mdiShape,
mdiSofa, mdiSofa,
mdiUndo,
mdiViewDashboard, mdiViewDashboard,
} from "@mdi/js"; } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
@@ -50,7 +52,10 @@ import "../../components/ha-tab-group-tab";
import "../../components/ha-tooltip"; import "../../components/ha-tooltip";
import { createAreaRegistryEntry } from "../../data/area_registry"; import { createAreaRegistryEntry } from "../../data/area_registry";
import type { LovelacePanelConfig } from "../../data/lovelace"; import type { LovelacePanelConfig } from "../../data/lovelace";
import type { LovelaceConfig } from "../../data/lovelace/config/types"; import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../data/lovelace/config/types";
import { isStrategyDashboard } from "../../data/lovelace/config/types"; import { isStrategyDashboard } from "../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../data/lovelace/config/view"; import type { LovelaceViewConfig } from "../../data/lovelace/config/view";
import { import {
@@ -92,6 +97,7 @@ import "./views/hui-view";
import type { HUIView } from "./views/hui-view"; import type { HUIView } from "./views/hui-view";
import "./views/hui-view-background"; import "./views/hui-view-background";
import "./views/hui-view-container"; import "./views/hui-view-container";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
interface ActionItem { interface ActionItem {
icon: string; icon: string;
@@ -113,6 +119,11 @@ interface SubActionItem {
visible: boolean | undefined; visible: boolean | undefined;
} }
interface UndoStackItem {
location: string;
config: LovelaceRawConfig;
}
@customElement("hui-root") @customElement("hui-root")
class HUIRoot extends LitElement { class HUIRoot extends LitElement {
@property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>; @property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>;
@@ -130,12 +141,22 @@ class HUIRoot extends LitElement {
@state() private _curView?: number | "hass-unused-entities"; @state() private _curView?: number | "hass-unused-entities";
private _configChangedByUndo = false;
private _viewCache?: Record<string, HUIView>; private _viewCache?: Record<string, HUIView>;
private _viewScrollPositions: Record<string, number> = {}; private _viewScrollPositions: Record<string, number> = {};
private _restoreScroll = false; private _restoreScroll = false;
private _undoRedoController = new UndoRedoController<UndoStackItem>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => ({
location: this.route!.path,
config: this.lovelace!.rawConfig,
}),
});
private _debouncedConfigChanged: () => void; private _debouncedConfigChanged: () => void;
private _conversation = memoizeOne((_components) => private _conversation = memoizeOne((_components) =>
@@ -157,7 +178,29 @@ class HUIRoot extends LitElement {
const result: TemplateResult[] = []; const result: TemplateResult[] = [];
if (this._editMode) { if (this._editMode) {
result.push( result.push(
html`<ha-button html`<ha-icon-button
slot="toolbar-icon"
.path=${mdiUndo}
@click=${this._undo}
.disabled=${!this._undoRedoController.canUndo}
id="button-undo"
>
</ha-icon-button>
<ha-tooltip placement="bottom" for="button-undo">
${this.hass.localize("ui.common.undo")}
</ha-tooltip>
<ha-icon-button
slot="toolbar-icon"
.path=${mdiRedo}
@click=${this._redo}
.disabled=${!this._undoRedoController.canRedo}
id="button-redo"
>
</ha-icon-button>
<ha-tooltip placement="bottom" for="button-redo">
${this.hass.localize("ui.common.redo")}
</ha-tooltip>
<ha-button
appearance="filled" appearance="filled"
size="small" size="small"
class="exit-edit-mode" class="exit-edit-mode"
@@ -645,6 +688,27 @@ class HUIRoot extends LitElement {
window.history.scrollRestoration = "auto"; window.history.scrollRestoration = "auto";
} }
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("lovelace")) {
const oldLovelace = changedProperties.get("lovelace") as
| Lovelace
| undefined;
if (
oldLovelace &&
this.lovelace!.rawConfig !== oldLovelace!.rawConfig &&
!this._configChangedByUndo
) {
this._undoRedoController.commit({
location: this.route!.path,
config: oldLovelace.rawConfig,
});
} else {
this._configChangedByUndo = false;
}
}
}
protected updated(changedProperties: PropertyValues): void { protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties); super.updated(changedProperties);
@@ -1029,6 +1093,7 @@ class HUIRoot extends LitElement {
private _editModeDisable(): void { private _editModeDisable(): void {
this.lovelace!.setEditMode(false); this.lovelace!.setEditMode(false);
this._undoRedoController.reset();
} }
private async _editDashboard() { private async _editDashboard() {
@@ -1207,6 +1272,36 @@ class HUIRoot extends LitElement {
showShortcutsDialog(this); showShortcutsDialog(this);
} }
private async _applyUndoRedo(item: UndoStackItem) {
this._configChangedByUndo = true;
try {
await this.lovelace!.saveConfig(item.config);
} catch (err: any) {
this._configChangedByUndo = false;
showToast(this, {
message: this.hass.localize(
"ui.panel.lovelace.editor.undo_redo_failed_to_apply_changes",
{
error: err.message,
}
),
duration: 4000,
dismissable: true,
});
return;
}
this._navigateToView(item.location);
}
private _undo() {
this._undoRedoController.undo();
}
private _redo() {
this._undoRedoController.redo();
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@@ -7304,6 +7304,7 @@
"editor": { "editor": {
"header": "Edit UI", "header": "Edit UI",
"yaml_unsupported": "The edit UI is not available when in YAML mode.", "yaml_unsupported": "The edit UI is not available when in YAML mode.",
"undo_redo_failed_to_apply_changes": "Unable to apply changes: {error}",
"menu": { "menu": {
"open": "Open dashboard menu", "open": "Open dashboard menu",
"raw_editor": "Raw configuration editor", "raw_editor": "Raw configuration editor",