diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts index a5bd5d4f5c..6af38a9047 100644 --- a/src/panels/config/automation/action/ha-automation-action.ts +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -198,7 +198,7 @@ export default class HaAutomationAction extends AutomationSortableListMixin { let actions: Action[]; if (action === PASTE_VALUE) { - actions = this.actions.concat(deepClone(this._clipboard!.action)); + actions = this.actions.concat(deepClone(this._clipboard!.action!)); } else if (action in VIRTUAL_ACTIONS) { actions = this.actions.concat(VIRTUAL_ACTIONS[action]); } else if (isDynamic(action)) { diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index 371ca3f960..fe97b8ce1e 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -287,7 +287,7 @@ export default class HaAutomationCondition extends AutomationSortableListMixin { let triggers: Trigger[]; if (value === PASTE_VALUE) { - triggers = this.triggers.concat(deepClone(this._clipboard!.trigger)); + triggers = this.triggers.concat(deepClone(this._clipboard!.trigger!)); } else if (isDynamic(value)) { triggers = this.triggers.concat({ trigger: getValueFromDynamic(value), diff --git a/src/panels/lovelace/components/hui-badge-edit-mode.ts b/src/panels/lovelace/components/hui-badge-edit-mode.ts index dfb7cbfa5f..d89120244c 100644 --- a/src/panels/lovelace/components/hui-badge-edit-mode.ts +++ b/src/panels/lovelace/components/hui-badge-edit-mode.ts @@ -19,8 +19,10 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown"; import "../../../components/ha-dropdown-item"; import "../../../components/ha-icon-button"; import "../../../components/ha-svg-icon"; -import { ensureBadgeConfig } from "../../../data/lovelace/config/badge"; -import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; +import { + ensureBadgeConfig, + type LovelaceBadgeConfig, +} from "../../../data/lovelace/config/badge"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog"; @@ -58,7 +60,7 @@ export class HuiBadgeEditMode extends LitElement { subscribe: false, storage: "sessionStorage", }) - protected _clipboard?: LovelaceCardConfig; + protected _clipboard?: string | Partial; private get _badges() { const containerPath = getLovelaceContainerPath(this.path!); diff --git a/src/panels/lovelace/components/hui-section-edit-mode.ts b/src/panels/lovelace/components/hui-section-edit-mode.ts index 8a7ee75ac8..cca4f82f47 100644 --- a/src/panels/lovelace/components/hui-section-edit-mode.ts +++ b/src/panels/lovelace/components/hui-section-edit-mode.ts @@ -1,13 +1,23 @@ -import { mdiDelete, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js"; +import "@home-assistant/webawesome/dist/components/divider/divider"; +import { + mdiDelete, + mdiDotsVertical, + mdiDragHorizontalVariant, + mdiPencil, + mdiPlusCircleMultipleOutline, +} from "@mdi/js"; import type { CSSResultGroup, TemplateResult } from "lit"; import { LitElement, css, html } from "lit"; import { customElement, property } from "lit/decorators"; +import "../../../components/ha-dropdown"; +import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown"; +import "../../../components/ha-dropdown-item"; import "../../../components/ha-icon-button"; import "../../../components/ha-svg-icon"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; -import { deleteSection } from "../editor/config-util"; +import { deleteSection, duplicateSection } from "../editor/config-util"; import { findLovelaceContainer } from "../editor/lovelace-path"; import { showEditSectionDialog } from "../editor/section-editor/show-edit-section-dialog"; import type { Lovelace } from "../types"; @@ -31,16 +41,32 @@ export class HuiSectionEditMode extends LitElement { class="handle" .path=${mdiDragHorizontalVariant} > - - + + + + + ${this.hass.localize("ui.common.edit")} + + + + ${this.hass.localize("ui.common.duplicate")} + + + + + ${this.hass.localize("ui.common.delete")} + +
@@ -49,8 +75,23 @@ export class HuiSectionEditMode extends LitElement { `; } - private async _editSection(ev) { - ev.stopPropagation(); + private _handleDropdownSelect(ev: HaDropdownSelectEvent): void { + const action = ev.detail?.item?.value; + if (!action) return; + switch (action) { + case "edit": + this._editSection(); + break; + case "duplicate": + this._duplicateSection(); + break; + case "delete": + this._deleteSection(); + break; + } + } + + private async _editSection() { showEditSectionDialog(this, { lovelace: this.lovelace!, lovelaceConfig: this.lovelace!.config, @@ -62,8 +103,16 @@ export class HuiSectionEditMode extends LitElement { }); } - private async _deleteSection(ev) { - ev.stopPropagation(); + private _duplicateSection(): void { + const newConfig = duplicateSection( + this.lovelace!.config, + this.viewIndex, + this.index + ); + this.lovelace!.saveConfig(newConfig); + } + + private async _deleteSection() { const path = [this.viewIndex, this.index] as [number, number]; const section = findLovelaceContainer(this.lovelace!.config, path); diff --git a/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts b/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts index 413cb87d48..16c92f184f 100644 --- a/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts +++ b/src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts @@ -153,7 +153,7 @@ export class HaCardConditionsEditor extends LitElement { } if (condition === "paste") { - const newCondition = deepClone(this._clipboard); + const newCondition = deepClone(this._clipboard!); conditions.push(newCondition); } else { const elClass = customElements.get(`ha-card-condition-${condition}`) as diff --git a/src/panels/lovelace/editor/config-util.ts b/src/panels/lovelace/editor/config-util.ts index 10c0e37e87..6f306deb3d 100644 --- a/src/panels/lovelace/editor/config-util.ts +++ b/src/panels/lovelace/editor/config-util.ts @@ -1,3 +1,4 @@ +import deepClone from "deep-clone-simple"; import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; import { ensureBadgeConfig } from "../../../data/lovelace/config/badge"; import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; @@ -303,6 +304,19 @@ export const deleteSection = ( return newConfig; }; +export const duplicateSection = ( + config: LovelaceConfig, + viewIndex: number, + sectionIndex: number +): LovelaceConfig => { + const view = findLovelaceContainer(config, [viewIndex]); + if (isStrategyView(view)) { + throw new Error("Duplicating sections in a strategy is not supported."); + } + const clone = deepClone(view.sections![sectionIndex]); + return insertSection(config, viewIndex, sectionIndex + 1, clone); +}; + export const insertSection = ( config: LovelaceConfig, viewIndex: number, diff --git a/src/types/deep-clone-simple.d.ts b/src/types/deep-clone-simple.d.ts new file mode 100644 index 0000000000..047c89a065 --- /dev/null +++ b/src/types/deep-clone-simple.d.ts @@ -0,0 +1,3 @@ +declare module "deep-clone-simple" { + export default function deepClone(data: T): T; +} diff --git a/test/panels/lovelace/editor/config-util.test.ts b/test/panels/lovelace/editor/config-util.test.ts index 2fcc73c67a..a0a78639ba 100644 --- a/test/panels/lovelace/editor/config-util.test.ts +++ b/test/panels/lovelace/editor/config-util.test.ts @@ -1,7 +1,10 @@ import { assert, describe, it } from "vitest"; import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types"; +import type { LovelaceSectionConfig } from "../../../../src/data/lovelace/config/section"; +import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view"; import { + duplicateSection, moveCardToContainer, swapView, } from "../../../../src/panels/lovelace/editor/config-util"; @@ -141,3 +144,84 @@ describe("swapView", () => { assert.deepEqual(expected, result); }); }); + +describe("duplicateSection", () => { + it("inserts a clone immediately after the original section", () => { + const config: LovelaceConfig = { + views: [ + { + sections: [ + { type: "grid", cards: [{ type: "button" }] }, + { type: "grid", cards: [{ type: "heading" }] }, + ], + }, + ], + }; + + const result = duplicateSection(config, 0, 0); + + const expected: LovelaceConfig = { + views: [ + { + sections: [ + { type: "grid", cards: [{ type: "button" }] }, + { type: "grid", cards: [{ type: "button" }] }, + { type: "grid", cards: [{ type: "heading" }] }, + ], + }, + ], + }; + assert.deepEqual(expected, result); + }); + + it("preserves all cards and properties within the cloned section", () => { + const config: LovelaceConfig = { + views: [ + { + sections: [ + { + type: "grid", + column_span: 2, + cards: [{ type: "button" }, { type: "heading" }], + }, + ], + }, + ], + }; + + const result = duplicateSection(config, 0, 0); + const view = result.views[0] as LovelaceViewConfig; + + assert.equal(view.sections!.length, 2); + assert.deepEqual(view.sections![0], view.sections![1]); + }); + + it("produces a deep clone, changes do not affect the original", () => { + const config: LovelaceConfig = { + views: [ + { + sections: [ + { + type: "grid", + column_span: 2, + cards: [{ type: "button" }, { type: "heading" }], + }, + ], + }, + ], + }; + + const result = duplicateSection(config, 0, 0); + const resultSections = (result.views[0] as LovelaceViewConfig).sections!; + + assert.equal(resultSections.length, 2); + assert.deepEqual(resultSections[0], resultSections[1]); + + (resultSections[1] as LovelaceSectionConfig).cards![0].type = "heading"; + + assert.equal( + (resultSections[0] as LovelaceSectionConfig).cards![0].type, + "button" + ); + }); +});