mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-23 22:24:24 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d066c0d7 | ||
|
|
fcbf5c1d22 | ||
|
|
672d144f43 | ||
|
|
fd4a26d3c8 | ||
|
|
57a42b420b | ||
|
|
41adc02408 | ||
|
|
46291451d7 |
No files matched your search
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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`),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]>) => {
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user