Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 31d066c0d7 Highlight the pasted card
After a paste it was hard to see which card was new. The pasted card now
fades out a primary-color outline for 1.5 s, the same hint the history
filter pane shows when it is already open. The animation lives in a shared
highlightElement helper in common/dom that the filter pane now uses too.
It animates the full outline shorthand, so callers need no transparent
outline of their own, and holds a steady outline instead of fading when
reduced motion is preferred.

Paste remembers the pasted card object. The wrapper whose slotted card
renders that object claims it once and runs the animation. Keying on the
object rather than the path matters: the view pushes the new config into
the old section elements before rebuilding them, so an old wrapper at the
pasted index would otherwise claim the highlight and be torn down. A
failed save clears it.
2026-10-23 11:47:30 +02:00
Maarten Lakerveld fcbf5c1d22 Drop the cut toast when a paste finished first
A cut whose save was still pending could show its Undo toast after a
later paste. Saves apply optimistically, so Undo would then remove the
pasted card instead of restoring the cut one. The cut still updates the
clipboard when its save succeeds, but skips the toast once a newer
clipboard action has started.
2026-10-23 11:29:47 +02:00
Maarten Lakerveld 672d144f43 Use the generic undo for the replace toast
Binding Undo to the replaced card captured the pre-paste dashboard, so
clicking it discarded every edit made while the toast was visible, and a
failed restore went unhandled. The toast now fires undo-change like the
cut and delete toasts, sharing their behavior and limitations.
2026-10-23 11:20:57 +02:00
Maarten Lakerveld fd4a26d3c8 Make paste generic like copy and cut
performPaste now takes the clipboard item type like performCopy and
performCut, so badges can reuse it. The sidebar rule moved out of the
helper: the view handler adopts the target card's view_layout through
adoptViewLayout before pasting, because only it knows the view type.
2026-10-23 11:07:56 +02:00
Maarten Lakerveld 57a42b420b Bind the replace toast's Undo to the replaced card
The toast after replacing a panel card used the generic undo-change event,
which undoes the latest change. An edit made before clicking Undo was
reverted instead of the replacement. The action now puts the replaced card
back at its path.
2026-10-23 10:50:26 +02:00
Maarten Lakerveld 41adc02408 Rerender storage-backed state that changed while disconnected
The storage decorator unsubscribes while an element is disconnected and
subscribes again on reconnect, but never asked for a render when the
value changed in between. Cached dashboard views kept stale card menus:
copy a card in another view and the Paste item was missing on return.

The decorator now remembers the value at disconnect and requests an
update on reconnect when it differs.
2026-10-23 10:50:26 +02:00
Maarten Lakerveld 46291451d7 Add Paste to the dashboard card menu
The card menu now offers Paste when a card is on the clipboard, inserting a
copy right after the card the menu was opened on, like Paste for sections.
In panel views, which show a single card, Paste replaces that card and
offers Undo in a toast. In sidebar views the pasted card takes the target
card's view_layout so it lands in the same column.

Paste closes the edit toast before saving, because the Cut toast's Undo
undoes the latest change and would otherwise undo the paste.

Paste is hidden in the view header and footer slots and the import-only
section, where adding a card makes no sense.
2026-10-22 17:11:16 +02:00
13 changed files with 483 additions and 17 deletions

No files matched your search

+9
View File
@@ -185,6 +185,7 @@ export function storage(options: {
if (options.subscribe) {
const connectedCallback = proto.connectedCallback;
const disconnectedCallback = proto.disconnectedCallback;
const disconnectedValues = new WeakMap<ReactiveElement, unknown>();
proto.connectedCallback = function () {
connectedCallback.call(this);
@@ -192,12 +193,20 @@ export function storage(options: {
if (!el.__unbsubLocalStorage) {
el.__unbsubLocalStorage = subscribeChanges?.(this);
}
if (disconnectedValues.has(el)) {
const oldValue = disconnectedValues.get(el);
disconnectedValues.delete(el);
if (oldValue !== storageInstance.getValue(storageKey)) {
el.requestUpdate(propertyKey, oldValue);
}
}
};
proto.disconnectedCallback = function () {
disconnectedCallback.call(this);
const el = this as unknown as ReactiveStorageElement;
el.__unbsubLocalStorage?.();
el.__unbsubLocalStorage = undefined;
disconnectedValues.set(el, storageInstance.getValue(storageKey));
};
}
+18
View File
@@ -0,0 +1,18 @@
const HIGHLIGHT_DURATION = 1500;
export const highlightElement = (element: HTMLElement): void => {
const style = getComputedStyle(element);
const outline = `${style.getPropertyValue("--ha-border-width-md") || "2px"} solid`;
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
element.getAnimations().forEach((animation) => animation.cancel());
element.animate(
[
{ outline: `${outline} ${style.getPropertyValue("--primary-color")}` },
{ outline: `${outline} transparent` },
],
{
duration: HIGHLIGHT_DURATION,
easing: reducedMotion ? "steps(1, end)" : "ease-out",
}
);
};
+2 -9
View File
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { highlightElement } from "../common/dom/highlight-element";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-adaptive-dialog";
@@ -13,8 +14,6 @@ import "./ha-dialog-footer";
import "./ha-filter-pane-chip";
import "./ha-icon-button";
const HIGHLIGHT_DURATION = 1500;
/**
* Filter pane for a filtered page: a column next to the content on wide
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
@@ -116,12 +115,7 @@ export class HaFilterPane extends LitElement {
}
public highlight() {
const color = getComputedStyle(this).getPropertyValue("--primary-color");
this.getAnimations().forEach((animation) => animation.cancel());
this.animate([{ outlineColor: color }, { outlineColor: "transparent" }], {
duration: HIGHLIGHT_DURATION,
easing: "ease-out",
});
highlightElement(this);
}
private _close() {
@@ -144,7 +138,6 @@ export class HaFilterPane extends LitElement {
box-sizing: border-box;
overflow: hidden;
border-inline-end: 1px solid var(--divider-color);
outline: var(--ha-border-width-md) solid transparent;
outline-offset: calc(-1 * var(--ha-border-width-md));
}
@@ -2,25 +2,37 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
mdiCursorMove,
mdiDelete,
mdiDotsVertical,
mdiPencil,
mdiPlusCircleMultipleOutline,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAssignedElements,
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { storage } from "../../../common/decorators/storage";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { fireEvent } from "../../../common/dom/fire_event";
import { highlightElement } from "../../../common/dom/highlight-element";
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 type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { haStyle } from "../../../resources/styles";
import type { HuiCard } from "../cards/hui-card";
import { takeCardHighlight } from "../editor/card-highlight";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
@@ -46,12 +58,26 @@ export class HuiCardEditMode extends LitElement {
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@storage({
key: "dashboardCardClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
private _clipboard?: LovelaceCardConfig;
@state()
public _hover = false;
@state()
public _focused = false;
@query(".card-wrapper") private _cardWrapper!: HTMLDivElement;
@queryAssignedElements({ selector: "hui-card" })
private _huiCards?: HuiCard[];
private _touchStarted = false;
protected firstUpdated(): void {
@@ -95,6 +121,15 @@ export class HuiCardEditMode extends LitElement {
document.removeEventListener("click", this._documentClicked);
};
protected updated(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has("path") &&
takeCardHighlight(this._huiCards?.[0]?.config)
) {
highlightElement(this._cardWrapper);
}
}
protected render(): TemplateResult {
const showOverlay = (this._hover || this._focused) && !this.hiddenOverlay;
@@ -175,6 +210,21 @@ export class HuiCardEditMode extends LitElement {
></ha-svg-icon>
${this._localize("ui.panel.lovelace.editor.edit_card.cut")}
</ha-dropdown-item>
${
this._clipboard && !this.noDuplicate
? html`
<ha-dropdown-item value="paste">
<ha-svg-icon
slot="icon"
.path=${mdiContentPaste}
></ha-svg-icon>
${this._localize(
"ui.panel.lovelace.editor.edit_card.paste"
)}
</ha-dropdown-item>
`
: nothing
}
`
}
${
@@ -227,6 +277,9 @@ export class HuiCardEditMode extends LitElement {
case "cut":
this._cutCard();
break;
case "paste":
this._pasteCard();
break;
case "delete":
this._deleteCard();
break;
@@ -249,6 +302,10 @@ export class HuiCardEditMode extends LitElement {
fireEvent(this, "ll-copy-card", { path: this.path! });
}
private _pasteCard(): void {
fireEvent(this, "ll-paste-card", { path: this.path! });
}
private _deleteCard(): void {
fireEvent(this, "ll-delete-card", { path: this.path!, silent: false });
}
@@ -277,6 +334,10 @@ export class HuiCardEditMode extends LitElement {
position: relative;
height: 100%;
z-index: 0;
border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.control {
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
mdiDelete,
mdiDotsVertical,
mdiFileMoveOutline,
@@ -11,9 +12,15 @@ import {
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, queryAssignedElements } from "lit/decorators";
import {
customElement,
property,
queryAssignedElements,
state,
} from "lit/decorators";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import { highlightElement } from "../../../common/dom/highlight-element";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
@@ -29,7 +36,9 @@ import {
} from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HuiCard } from "../cards/hui-card";
import { computeCardSize } from "../common/compute-card-size";
import { takeCardHighlight } from "../editor/card-highlight";
import {
addCard,
moveCardToContainer,
@@ -58,16 +67,20 @@ export class HuiCardOptions extends LitElement {
@queryAssignedElements() private _assignedElements?: LovelaceCard[];
@queryAssignedElements({ selector: "hui-card" })
private _huiCards?: HuiCard[];
@property({ attribute: "hide-position", type: Boolean })
public hidePosition = false;
@state()
@storage({
key: "dashboardCardClipboard",
state: false,
subscribe: false,
state: true,
subscribe: true,
storage: "sessionStorage",
})
protected _clipboard?: LovelaceCardConfig;
private _clipboard?: LovelaceCardConfig;
public getCardSize() {
return this._assignedElements
@@ -92,6 +105,9 @@ export class HuiCardOptions extends LitElement {
viewPath
);
this.classList.toggle("panel", viewConfig?.panel);
if (takeCardHighlight(this._huiCards?.[0]?.config)) {
highlightElement(this);
}
}
private get _cards() {
@@ -189,6 +205,21 @@ export class HuiCardOptions extends LitElement {
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
${this.hass!.localize("ui.panel.lovelace.editor.edit_card.cut")}
</ha-dropdown-item>
${
this._clipboard
? html`
<ha-dropdown-item value="paste">
<ha-svg-icon
slot="icon"
.path=${mdiContentPaste}
></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.paste"
)}
</ha-dropdown-item>
`
: nothing
}
<wa-divider></wa-divider>
<ha-dropdown-item value="delete" variant="danger">
<ha-svg-icon
@@ -283,6 +314,9 @@ export class HuiCardOptions extends LitElement {
case "cut":
this._cutCard();
break;
case "paste":
this._pasteCard();
break;
case "delete":
this._deleteCard();
break;
@@ -305,6 +339,10 @@ export class HuiCardOptions extends LitElement {
fireEvent(this, "ll-copy-card", { path: this.path! });
}
private _pasteCard(): void {
fireEvent(this, "ll-paste-card", { path: this.path! });
}
private _deleteCard(): void {
fireEvent(this, "ll-delete-card", { path: this.path!, silent: false });
}
@@ -0,0 +1,22 @@
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
type PastedItem = LovelaceCardConfig | LovelaceBadgeConfig;
let pending: PastedItem | undefined;
export const setCardHighlight = (item: PastedItem): void => {
pending = item;
};
export const clearCardHighlight = (): void => {
pending = undefined;
};
export const takeCardHighlight = (item: PastedItem | undefined): boolean => {
if (!item || pending !== item) {
return false;
}
pending = undefined;
return true;
};
@@ -7,7 +7,9 @@ import type { Lovelace } from "../types";
import { LOVELACE_EDIT_TOAST_ID } from "../types";
import { performDeleteBadge } from "./delete-badge";
import { performDeleteCard } from "./delete-card";
import { clearCardHighlight, setCardHighlight } from "./card-highlight";
import type { LovelacePath } from "./lovelace-path";
import { getParentPath, insertAtPath, setAtPath } from "./lovelace-path";
interface ClipboardItemConfigs {
card: LovelaceCardConfig;
@@ -53,6 +55,56 @@ export function performCopy<K extends ClipboardItemType>(
});
}
export interface PasteParams<K extends ClipboardItemType> {
type: K;
path: LovelacePath;
config: ClipboardItemConfigs[K];
replace?: boolean;
}
export async function performPaste<K extends ClipboardItemType>(
localize: LocalizeFunc,
lovelace: Lovelace,
{ type, path, config, replace }: PasteParams<K>
): Promise<void> {
++lastVersion;
lovelace.showToast({ id: LOVELACE_EDIT_TOAST_ID, message: "", duration: 0 });
try {
const index = path[path.length - 1];
if (typeof index !== "number") {
throw new Error("Paste target is not an item");
}
const item = deepClone(config);
const newConfig = replace
? setAtPath(lovelace.config, path, item)
: insertAtPath(
lovelace.config,
[...getParentPath(path), index + 1],
item
);
setCardHighlight(item);
await lovelace.saveConfig(newConfig);
} catch (_err: unknown) {
clearCardHighlight();
lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: localize(`ui.panel.lovelace.editor.edit_${type}.paste_error`),
});
return;
}
if (replace) {
lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: localize(`ui.panel.lovelace.editor.edit_${type}.replaced`),
duration: 8000,
action: {
action: () => fireEvent(window, "undo-change"),
text: localize("ui.common.undo"),
},
});
}
}
export async function performCut<K extends ClipboardItemType>(
localize: LocalizeFunc,
lovelace: Lovelace,
@@ -70,6 +122,9 @@ export async function performCut<K extends ClipboardItemType>(
}
clipboardVersions[type] = version;
setClipboard(deepClone(config));
if (lastVersion !== version) {
return;
}
lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: localize(`ui.panel.lovelace.editor.edit_${type}.cut_to_clipboard`),
+10
View File
@@ -75,6 +75,16 @@ export const moveCardToContainer = (
return deleteAtPath(newConfig, cardPath);
};
export const adoptViewLayout = (
card: LovelaceCardConfig,
target: LovelaceCardConfig
): LovelaceCardConfig => {
const { view_layout: _viewLayout, ...rest } = card;
return target.view_layout === undefined
? rest
: { ...rest, view_layout: target.view_layout };
};
export const getCardSectionConfig = (
config: LovelaceConfig,
path: LovelacePath
+37 -2
View File
@@ -32,9 +32,13 @@ import { showCreateBadgeDialog } from "../editor/badge-editor/show-create-badge-
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { getCardSectionConfig } from "../editor/config-util";
import { adoptViewLayout, getCardSectionConfig } from "../editor/config-util";
import type { ClipboardActionParams } from "../editor/clipboard-actions";
import { performCopy, performCut } from "../editor/clipboard-actions";
import {
performCopy,
performCut,
performPaste,
} from "../editor/clipboard-actions";
import {
type DeleteBadgeParams,
performDeleteBadge,
@@ -47,8 +51,10 @@ import type { LovelacePath } from "../editor/lovelace-path";
import {
appendAtPath,
getAtPath,
getItemKind,
getParentPath,
getPathTarget,
getViewPath,
normalizeCardPath,
setAtPath,
} from "../editor/lovelace-path";
@@ -60,6 +66,7 @@ import {
generateLovelaceViewStrategy,
} from "../strategies/get-strategy";
import type { Lovelace } from "../types";
import { PANEL_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT } from "./const";
import { getViewType } from "./get-view-type";
declare global {
@@ -71,6 +78,7 @@ declare global {
"ll-duplicate-card": { path: LovelacePath };
"ll-copy-card": { path: LovelacePath };
"ll-cut-card": { path: LovelacePath };
"ll-paste-card": { path: LovelacePath };
"ll-create-badge": { path: LovelacePath };
"ll-edit-badge": { path: LovelacePath };
"ll-delete-badge": DeleteBadgeParams;
@@ -84,6 +92,7 @@ declare global {
"ll-duplicate-card": HASSDomEvent<HASSDomEvents["ll-duplicate-card"]>;
"ll-copy-card": HASSDomEvent<HASSDomEvents["ll-copy-card"]>;
"ll-cut-card": HASSDomEvent<HASSDomEvents["ll-cut-card"]>;
"ll-paste-card": HASSDomEvent<HASSDomEvents["ll-paste-card"]>;
"ll-create-badge": HASSDomEvent<HASSDomEvents["ll-create-badge"]>;
"ll-edit-badge": HASSDomEvent<HASSDomEvents["ll-edit-badge"]>;
"ll-delete-badge": HASSDomEvent<HASSDomEvents["ll-delete-badge"]>;
@@ -238,6 +247,32 @@ export class HUIView extends ReactiveElement {
}
}
);
this.addEventListener(
"ll-paste-card",
(ev: HASSDomEvent<HASSDomEvents["ll-paste-card"]>) => {
const config = this._clipboard;
const path = normalizeCardPath(ev.detail.path);
const target =
getPathTarget(path) === "item" && getItemKind(path) === "card"
? this._getCardConfig(path)
: undefined;
if (!config || !target) {
return;
}
const viewType = getViewType(
getAtPath<LovelaceViewConfig>(this.lovelace.config, getViewPath(path))
);
performPaste(this.hass.localize, this.lovelace, {
type: "card",
path,
config:
viewType === SIDEBAR_VIEW_LAYOUT
? adoptViewLayout(config, target)
: config,
replace: viewType === PANEL_VIEW_LAYOUT,
});
}
);
this.addEventListener(
"ll-create-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-create-badge"]>) => {
+5
View File
@@ -10293,6 +10293,9 @@
"cut": "Cut",
"copied_to_clipboard": "Card copied to clipboard",
"cut_to_clipboard": "Card cut to clipboard",
"paste": "Paste",
"paste_error": "Error while pasting card",
"replaced": "Card replaced",
"duplicate": "Duplicate",
"move": "Move to view",
"move_before": "Move card before",
@@ -10337,6 +10340,8 @@
"cut": "[%key:ui::panel::lovelace::editor::edit_card::cut%]",
"copied_to_clipboard": "Badge copied to clipboard",
"cut_to_clipboard": "Badge cut to clipboard",
"paste_error": "Error while pasting badge",
"replaced": "Badge replaced",
"duplicate": "[%key:ui::panel::lovelace::editor::edit_card::duplicate%]",
"tab_config": "[%key:ui::panel::lovelace::editor::edit_card::tab_config%]",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_card::tab_visibility%]"
+56
View File
@@ -0,0 +1,56 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { afterEach, describe, expect, it } from "vitest";
import { getStorage, storage } from "../../../src/common/decorators/storage";
const KEY = "test-storage-subscriber-value";
@customElement("test-storage-subscriber")
class TestStorageSubscriber extends ReactiveElement {
@storage({
key: KEY,
state: true,
subscribe: true,
storage: "sessionStorage",
})
public value?: string;
public updates = 0;
protected update(changedProperties) {
super.update(changedProperties);
this.updates++;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-storage-subscriber": TestStorageSubscriber;
}
}
describe("storage decorator", () => {
afterEach(() => {
document.body.replaceChildren();
getStorage("sessionStorage").setValue(KEY, undefined);
});
it("renders a value that changed while the element was disconnected", async () => {
const element = document.createElement("test-storage-subscriber");
document.body.append(element);
await element.updateComplete;
expect(element.updates).toBe(1);
element.remove();
document.body.append(element);
await element.updateComplete;
expect(element.updates).toBe(1);
element.remove();
getStorage("sessionStorage").setValue(KEY, "changed");
document.body.append(element);
await element.updateComplete;
expect(element.updates).toBe(2);
expect(element.value).toBe("changed");
});
});
@@ -5,7 +5,11 @@ import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types"
import {
performCopy,
performCut,
performPaste,
} from "../../../../src/panels/lovelace/editor/clipboard-actions";
import { takeCardHighlight } from "../../../../src/panels/lovelace/editor/card-highlight";
import type { LovelacePath } from "../../../../src/panels/lovelace/editor/lovelace-path";
import { getAtPath } from "../../../../src/panels/lovelace/editor/lovelace-path";
import type { Lovelace } from "../../../../src/panels/lovelace/types";
const CARD_A: LovelaceCardConfig = { type: "card-a" };
@@ -118,4 +122,143 @@ describe("performCut", () => {
expect(clipboard).toEqual(CARD_A);
});
it("keeps the clipboard but not the toast when a paste finished first", async () => {
let finishSave!: () => void;
const lovelace = createLovelace(
() =>
new Promise<void>((resolve) => {
finishSave = resolve;
})
);
let clipboard: LovelaceCardConfig | undefined;
const setClipboard = (config: LovelaceCardConfig) => {
clipboard = config;
};
const cut = performCut(
localize,
lovelace,
cardParams(CARD_A, 0, setClipboard)
);
await performPaste(
localize,
createLovelace(() => Promise.resolve()),
{
type: "card",
path: ["views", 0, "cards", 1],
config: CARD_B,
}
);
vi.mocked(lovelace.showToast).mockClear();
finishSave();
await cut;
expect(clipboard).toEqual(CARD_A);
expect(lovelace.showToast).not.toHaveBeenCalled();
});
});
describe("performPaste", () => {
const CLIPBOARD: LovelaceCardConfig = {
type: "clipboard",
entities: ["light.a"],
};
const pasteInto = async (
config: LovelaceConfig,
params: Omit<Parameters<typeof performPaste<"card">>[2], "type" | "config">
) => {
const saveConfig = vi.fn<Lovelace["saveConfig"]>(() => Promise.resolve());
const lovelace = {
config,
rawConfig: config,
saveConfig,
showToast: vi.fn(),
} as Partial<Lovelace> as Lovelace;
await performPaste(localize, lovelace, {
type: "card",
config: CLIPBOARD,
...params,
});
const saved = saveConfig.mock.calls[0][0] as LovelaceConfig;
return {
cardsAt: (cardsPath: LovelacePath) =>
getAtPath<LovelaceCardConfig[]>(saved, cardsPath)!,
showToast: lovelace.showToast,
};
};
it("inserts a copy of the clipboard card after the target card", async () => {
const { cardsAt } = await pasteInto(
{ views: [{ cards: [CARD_A, CARD_B] }] },
{ path: ["views", 0, "cards", 0] }
);
expect(cardsAt(["views", 0, "cards"])).toEqual([CARD_A, CLIPBOARD, CARD_B]);
expect(takeCardHighlight(cardsAt(["views", 0, "cards"])[1])).toBe(true);
const { cardsAt: nestedCardsAt } = await pasteInto(
{ views: [{ sections: [{ cards: [CARD_A, CARD_B] }] }] },
{ path: ["views", 0, "sections", 0, "cards", 1] }
);
expect(nestedCardsAt(["views", 0, "sections", 0, "cards"])).toEqual([
CARD_A,
CARD_B,
CLIPBOARD,
]);
});
it("pastes an independent copy", async () => {
const { cardsAt } = await pasteInto(
{ views: [{ cards: [CARD_A] }] },
{ path: ["views", 0, "cards", 0] }
);
const pasted = cardsAt(["views", 0, "cards"])[1];
expect(pasted).not.toBe(CLIPBOARD);
expect(pasted.entities).not.toBe(CLIPBOARD.entities);
});
it("replaces the target card in panel views", async () => {
const { cardsAt, showToast } = await pasteInto(
{ views: [{ panel: true, cards: [CARD_A] }] },
{ path: ["views", 0, "cards", 0], replace: true }
);
expect(cardsAt(["views", 0, "cards"])).toEqual([CLIPBOARD]);
expect(takeCardHighlight(cardsAt(["views", 0, "cards"])[0])).toBe(true);
expect(showToast).toHaveBeenLastCalledWith(
expect.objectContaining({
message: "ui.panel.lovelace.editor.edit_card.replaced",
action: expect.objectContaining({ text: "ui.common.undo" }),
})
);
});
it("shows an error toast when saving fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const config: LovelaceConfig = { views: [{ cards: [CARD_A] }] };
const lovelace = {
config,
rawConfig: config,
saveConfig: vi.fn(() => Promise.reject(new Error("save failed"))),
showToast: vi.fn(),
} as Partial<Lovelace> as Lovelace;
await performPaste(localize, lovelace, {
type: "card",
path: ["views", 0, "cards", 0],
config: CLIPBOARD,
});
expect(lovelace.showToast).toHaveBeenLastCalledWith(
expect.objectContaining({
message: "ui.panel.lovelace.editor.edit_card.paste_error",
})
);
const attempted = vi.mocked(lovelace.saveConfig).mock
.calls[0][0] as LovelaceConfig;
expect(
takeCardHighlight(
getAtPath<LovelaceCardConfig[]>(attempted, ["views", 0, "cards"])![1]
)
).toBe(false);
});
});
@@ -4,6 +4,7 @@ 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 {
adoptViewLayout,
duplicateSection,
moveCardToContainer,
swapView,
@@ -233,3 +234,23 @@ describe("duplicateSection", () => {
);
});
});
describe("adoptViewLayout", () => {
it("takes the target card's view layout", () => {
const card = { type: "card1", view_layout: { position: "sidebar" } };
const target = { type: "card2", view_layout: { position: "main" } };
assert.deepEqual(adoptViewLayout(card, target), {
type: "card1",
view_layout: { position: "main" },
});
});
it("drops the card's own view layout when the target has none", () => {
const card = { type: "card1", view_layout: { position: "sidebar" } };
assert.deepEqual(adoptViewLayout(card, { type: "card2" }), {
type: "card1",
});
});
});