Compare commits

...
34 changed files with 1356 additions and 1114 deletions
+4 -2
View File
@@ -3,6 +3,7 @@ import { getCollection } from "home-assistant-js-websocket";
import type { HuiBadge } from "../panels/lovelace/badges/hui-badge";
import type { HuiCard } from "../panels/lovelace/cards/hui-card";
import type { HuiSection } from "../panels/lovelace/sections/hui-section";
import type { LovelacePath } from "../panels/lovelace/editor/lovelace-path";
import type { Lovelace } from "../panels/lovelace/types";
import type { HomeAssistant } from "../types";
import type { LovelaceSectionConfig } from "./lovelace/config/section";
@@ -18,7 +19,9 @@ export interface LovelaceViewElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
narrow?: boolean;
// Temporary compatibility: custom view layouts still read the view index
index?: number;
path?: LovelacePath;
cards?: HuiCard[];
badges?: HuiBadge[];
sections?: HuiSection[];
@@ -31,8 +34,7 @@ export interface LovelaceSectionElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
preview?: boolean;
viewIndex?: number;
index?: number;
path?: LovelacePath;
cards?: HuiCard[];
isStrategy: boolean;
importOnly?: boolean;
+34 -36
View File
@@ -6,14 +6,15 @@ import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-ripple";
import "../../../components/ha-sortable";
import type { HaSortableOptions } from "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import "../components/hui-badge-edit-mode";
import { moveBadge } from "../editor/config-util";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import type { LovelacePath } from "../editor/lovelace-path";
import { moveAtPath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
import type { HuiBadge } from "./hui-badge";
@@ -30,7 +31,7 @@ export class HuiViewBadges extends LitElement {
@property({ attribute: false }) public badges: HuiBadge[] = [];
@property({ attribute: false }) public viewIndex!: number;
@property({ attribute: false }) public path!: LovelacePath;
@property({ type: Boolean, attribute: "show-add-label" })
public showAddLabel!: boolean;
@@ -82,27 +83,29 @@ export class HuiViewBadges extends LitElement {
return this._badgeConfigKeys.get(badge)!;
}
private _badgeMoved(ev) {
private _badgeMoved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newConfig = moveBadge(
this.lovelace!.config,
[this.viewIndex!, oldIndex],
[this.viewIndex!, newIndex]
const newConfig = moveAtPath(
this.lovelace.config,
[...this.path, oldIndex],
[...this.path, newIndex]
);
this.lovelace!.saveConfig(newConfig);
this.lovelace.saveConfig(newConfig);
}
private _badgeAdded(ev) {
private _badgeAdded(ev: HASSDomEvent<HASSDomEvents["item-added"]>) {
ev.stopPropagation();
const { index, data } = ev.detail;
const oldPath = data as LovelaceCardPath;
const newPath = [this.viewIndex!, index] as LovelaceCardPath;
const newConfig = moveBadge(this.lovelace!.config, oldPath, newPath);
this.lovelace!.saveConfig(newConfig);
const oldPath = data as LovelacePath;
const newConfig = moveAtPath(this.lovelace.config, oldPath, [
...this.path,
index,
]);
this.lovelace.saveConfig(newConfig);
}
private _badgeRemoved(ev) {
private _badgeRemoved(ev: HASSDomEvent<HASSDomEvents["item-removed"]>) {
ev.stopPropagation();
// Do nothing, it's handled by the "item-added" event from the new parent.
}
@@ -116,7 +119,7 @@ export class HuiViewBadges extends LitElement {
}
private _addBadge() {
fireEvent(this, "ll-create-badge");
fireEvent(this, "ll-create-badge", { path: this.path });
}
render() {
@@ -139,7 +142,6 @@ export class HuiViewBadges extends LitElement {
@drag-end=${this._dragEnd}
group="badge"
draggable-selector="[data-sortable]"
.rollback=${false}
.options=${BADGE_SORTABLE_OPTIONS}
invert-swap
>
@@ -148,26 +150,20 @@ export class HuiViewBadges extends LitElement {
badges,
(badge) => this._getBadgeKey(badge),
(badge, idx) => {
const badgePath = [
this.viewIndex,
idx,
] as LovelaceCardPath;
if (!editMode) {
return badge;
}
const badgePath = [...this.path, idx];
return html`
${
editMode
? html`
<hui-badge-edit-mode
data-sortable
.lovelace=${this.lovelace}
.path=${badgePath}
.hiddenOverlay=${this._dragging}
.sortableData=${badgePath}
>
${badge}
</hui-badge-edit-mode>
`
: badge
}
<hui-badge-edit-mode
data-sortable
.lovelace=${this.lovelace}
.path=${badgePath}
.hiddenOverlay=${this._dragging}
.sortableData=${badgePath}
>
${badge}
</hui-badge-edit-mode>
`;
}
)}
@@ -238,6 +234,7 @@ export class HuiViewBadges extends LitElement {
margin-right: -8px;
margin-inline-end: -8px;
margin-inline-start: 0;
order: 2;
}
.badges > * {
@@ -252,6 +249,7 @@ export class HuiViewBadges extends LitElement {
}
.add {
order: 1;
position: relative;
display: flex;
flex-direction: row;
@@ -27,19 +27,15 @@ import {
} from "../../../data/lovelace/config/badge";
import { haStyle } from "../../../resources/styles";
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import {
findLovelaceItems,
getLovelaceContainerPath,
parseLovelaceCardPath,
} from "../editor/lovelace-path";
import type { LovelacePath } from "../editor/lovelace-path";
import { getAtPath, getParentPath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
@customElement("hui-badge-edit-mode")
export class HuiBadgeEditMode extends LitElement {
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ type: Array }) public path!: LovelaceCardPath;
@property({ attribute: false }) public path!: LovelacePath;
@property({ attribute: "hidden-overlay", type: Boolean })
public hiddenOverlay = false;
@@ -63,11 +59,15 @@ export class HuiBadgeEditMode extends LitElement {
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: string | Partial<LovelaceBadgeConfig>;
protected _clipboard?: LovelaceBadgeConfig;
private get _badges() {
const containerPath = getLovelaceContainerPath(this.path!);
return findLovelaceItems("badges", this.lovelace!.config, containerPath)!;
private get _badgeConfig() {
return ensureBadgeConfig(
getAtPath<Partial<LovelaceBadgeConfig> | string>(
this.lovelace.config,
this.path
)!
);
}
private _touchStarted = false;
@@ -207,33 +207,28 @@ export class HuiBadgeEditMode extends LitElement {
private _cutBadge(): void {
this._copyBadge();
fireEvent(this, "ll-delete-badge", { path: this.path!, silent: true });
fireEvent(this, "ll-delete-badge", { path: this.path, silent: true });
}
private _copyBadge(): void {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const cardConfig = this._badges[cardIndex];
this._clipboard = deepClone(cardConfig);
this._clipboard = deepClone(this._badgeConfig);
}
private _duplicateBadge(): void {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const containerPath = getLovelaceContainerPath(this.path!);
const badgeConfig = ensureBadgeConfig(this._badges![cardIndex]);
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: containerPath as [number],
badgeConfig,
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: getParentPath(this.path),
badgeConfig: this._badgeConfig,
});
}
private _editBadge(): void {
fireEvent(this, "ll-edit-badge", { path: this.path! });
fireEvent(this, "ll-edit-badge", { path: this.path });
}
private _deleteBadge(): void {
fireEvent(this, "ll-delete-badge", { path: this.path!, silent: false });
fireEvent(this, "ll-delete-badge", { path: this.path, silent: false });
}
static get styles(): CSSResultGroup {
@@ -21,14 +21,14 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import { haStyle } from "../../../resources/styles";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
@customElement("hui-card-edit-mode")
export class HuiCardEditMode extends LitElement {
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ type: Array }) public path!: LovelaceCardPath;
@property({ attribute: false }) public path!: LovelacePath;
@property({ type: Boolean, attribute: "hidden-overlay" })
public hiddenOverlay = false;
@@ -21,6 +21,7 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { saveConfig } from "../../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { isStrategyView } from "../../../data/lovelace/config/view";
import {
showAlertDialog,
@@ -31,16 +32,16 @@ import type { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size";
import {
addCard,
deleteCard,
moveCardToContainer,
moveCardToIndex,
} from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import {
type LovelaceCardPath,
type LovelaceContainerPath,
findLovelaceItems,
getLovelaceContainerPath,
parseLovelaceCardPath,
deleteAtPath,
getAtPath,
getParentPath,
getViewPath,
normalizeCardPath,
} from "../editor/lovelace-path";
import { showSelectViewDialog } from "../editor/select-view/show-select-view-dialog";
import type { Lovelace, LovelaceCard } from "../types";
@@ -52,7 +53,7 @@ export class HuiCardOptions extends LitElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Array }) public path?: LovelaceCardPath;
@property({ attribute: false }) public path?: LovelacePath;
@queryAssignedElements() private _assignedElements?: LovelaceCard[];
@@ -73,24 +74,37 @@ export class HuiCardOptions extends LitElement {
: 1;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// Temporary compatibility: custom view layouts still set [view, card] index tuples
if (changedProps.has("path") && this.path) {
this.path = normalizeCardPath(this.path);
}
}
protected updated(changedProps: PropertyValues<this>) {
if (!changedProps.has("path") || !this.path) {
return;
}
const { viewIndex } = parseLovelaceCardPath(this.path);
this.classList.toggle(
"panel",
this.lovelace!.config.views[viewIndex].panel
const viewPath = getViewPath(this.path);
const viewConfig = getAtPath<LovelaceViewConfig>(
this.lovelace!.config,
viewPath
);
this.classList.toggle("panel", viewConfig?.panel);
}
private get _cards() {
const containerPath = getLovelaceContainerPath(this.path!);
return findLovelaceItems("cards", this.lovelace!.config, containerPath)!;
const cardsPath = getParentPath(this.path!);
return getAtPath<LovelaceCardConfig[]>(this.lovelace!.config, cardsPath)!;
}
private get _cardIndex(): number {
const path = this.path!;
return path[path.length - 1] as number;
}
protected render(): TemplateResult {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const cardIndex = this._cardIndex;
return html`
<div class="card"><slot></slot></div>
@@ -298,21 +312,23 @@ export class HuiCardOptions extends LitElement {
private _decreaseCardPosiion(): void {
const lovelace = this.lovelace!;
const path = this.path!;
const { cardIndex } = parseLovelaceCardPath(path);
lovelace.saveConfig(moveCardToIndex(lovelace.config, path, cardIndex - 1));
lovelace.saveConfig(
moveCardToIndex(lovelace.config, path, this._cardIndex - 1)
);
}
private _increaseCardPosition(): void {
const lovelace = this.lovelace!;
const path = this.path!;
const { cardIndex } = parseLovelaceCardPath(path);
lovelace.saveConfig(moveCardToIndex(lovelace.config, path, cardIndex + 1));
lovelace.saveConfig(
moveCardToIndex(lovelace.config, path, this._cardIndex + 1)
);
}
private async _changeCardPosition(): Promise<void> {
const lovelace = this.lovelace!;
const path = this.path!;
const { cardIndex } = parseLovelaceCardPath(path);
const cardIndex = this._cardIndex;
const positionString = await showPromptDialog(this, {
title: this.hass!.localize(
"ui.panel.lovelace.editor.change_position.title"
@@ -363,7 +379,7 @@ export class HuiCardOptions extends LitElement {
return;
}
const toPath: LovelaceContainerPath = [viewIndex];
const toPath: LovelacePath = ["views", viewIndex];
if (urlPath === this.lovelace!.urlPath) {
this.lovelace!.saveConfig(
@@ -382,15 +398,17 @@ export class HuiCardOptions extends LitElement {
return;
}
try {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const card = this._cards[cardIndex];
const card = getAtPath<LovelaceCardConfig>(
this.lovelace.config,
this.path!
)!;
await saveConfig(
this.hass!,
urlPath,
addCard(newConfig, toPath, card)
);
this.lovelace!.saveConfig(
deleteCard(this.lovelace!.config, this.path!)
deleteAtPath(this.lovelace!.config, this.path!)
);
this.lovelace.showToast({
@@ -14,11 +14,13 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { deleteSection, duplicateSection } from "../editor/config-util";
import { findLovelaceContainer } from "../editor/lovelace-path";
import { duplicateSection } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { deleteAtPath, getAtPath } from "../editor/lovelace-path";
import { showEditSectionDialog } from "../editor/section-editor/show-edit-section-dialog";
import type { Lovelace } from "../types";
@@ -28,9 +30,7 @@ export class HuiSectionEditMode extends LitElement {
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ attribute: false }) public index!: number;
@property({ attribute: false }) public viewIndex!: number;
@property({ attribute: false }) public path!: LovelacePath;
protected render(): TemplateResult {
return html`
@@ -98,26 +98,22 @@ export class HuiSectionEditMode extends LitElement {
saveConfig: (newConfig) => {
this.lovelace!.saveConfig(newConfig);
},
viewIndex: this.viewIndex,
sectionIndex: this.index,
path: this.path,
});
}
private _duplicateSection(): void {
const newConfig = duplicateSection(
this.lovelace!.config,
this.viewIndex,
this.index
);
const newConfig = duplicateSection(this.lovelace!.config, this.path);
this.lovelace!.saveConfig(newConfig);
}
private async _deleteSection() {
const path = [this.viewIndex, this.index] as [number, number];
const section = getAtPath<LovelaceSectionRawConfig>(
this.lovelace!.config,
this.path
);
const section = findLovelaceContainer(this.lovelace!.config, path);
const cardCount = "cards" in section && section.cards?.length;
const cardCount = section && "cards" in section && section.cards?.length;
if (cardCount) {
const confirm = await showConfirmationDialog(this, {
@@ -134,11 +130,7 @@ export class HuiSectionEditMode extends LitElement {
if (!confirm) return;
}
const newConfig = deleteSection(
this.lovelace!.config,
this.viewIndex,
this.index
);
const newConfig = deleteAtPath(this.lovelace!.config, this.path);
this.lovelace!.saveConfig(newConfig);
}
@@ -128,7 +128,7 @@ export const addEntitiesToLovelaceView = async (
alert(hass.localize("ui.panel.lovelace.add_entities.saving_failed"));
}
},
path: [0],
path: ["views", 0],
entities,
});
return;
@@ -154,7 +154,7 @@ export const addEntitiesToLovelaceView = async (
);
}
},
path: [viewIndex],
path: ["views", viewIndex],
entities,
});
},
@@ -11,12 +11,10 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { addBadge } from "../config-util";
import { findLovelaceContainer } from "../lovelace-path";
import { appendAtPath, getAtPath, getParentPath } from "../lovelace-path";
import "./hui-badge-picker";
import "./hui-badge-suggestion-picker";
import type { CreateBadgeDialogParams } from "./show-create-badge-dialog";
@@ -33,7 +31,7 @@ export class HuiCreateDialogBadge
@state() private _open = false;
@state() private _containerConfig!: LovelaceViewConfig;
@state() private _containerConfig!: { title?: string };
@state() private _currTab: "badge" | "entity" = "entity";
@@ -46,10 +44,11 @@ export class HuiCreateDialogBadge
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const containerConfig = findLovelaceContainer(
const containerPath = getParentPath(params.path);
const containerConfig = getAtPath<{ title?: string }>(
params.lovelaceConfig,
params.path
);
containerPath
)!;
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
@@ -221,7 +220,7 @@ export class HuiCreateDialogBadge
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
const newConfig = addBadge(lovelaceConfig, containerPath, config);
const newConfig = appendAtPath(lovelaceConfig, containerPath, config);
await saveConfig(newConfig);
this.closeDialog();
}
@@ -16,7 +16,6 @@ import "../../../../components/ha-icon-button";
import "../../../../components/ha-spinner";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import { ensureBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import {
getCustomBadgeEntry,
isCustomType,
@@ -33,11 +32,16 @@ import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge";
import { getConfigEntityId } from "../../common/get-config-entity-id";
import { addBadge, replaceBadge } from "../config-util";
import { getBadgeDefaultConfig } from "../get-badge-default-config";
import { getBadgeDocumentationURL } from "../get-dashboard-documentation-url";
import type { ConfigChangedEvent } from "../hui-element-editor";
import { findLovelaceContainer } from "../lovelace-path";
import type { LovelacePath } from "../lovelace-path";
import {
appendAtPath,
getAtPath,
getParentPath,
setAtPath,
} from "../lovelace-path";
import type { GUIModeChangedEvent } from "../types";
import "./hui-badge-element-editor";
import type { HuiBadgeElementEditor } from "./hui-badge-element-editor";
@@ -69,7 +73,7 @@ export class HuiDialogEditBadge
@state() private _badgeConfig?: LovelaceBadgeConfig;
@state() private _containerConfig!: LovelaceViewConfig;
@state() private _containerConfig!: { title?: string };
@state() private _saving = false;
@@ -90,10 +94,11 @@ export class HuiDialogEditBadge
this._guiModeAvailable = true;
this._open = true;
const containerConfig = findLovelaceContainer(
const containerPath = getParentPath(this._collectionPath);
const containerConfig = getAtPath<{ title?: string }>(
params.lovelaceConfig,
params.path
);
containerPath
)!;
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
@@ -104,7 +109,10 @@ export class HuiDialogEditBadge
if ("badgeConfig" in params) {
this._badgeConfig = params.badgeConfig;
} else {
const badge = this._containerConfig.badges?.[params.badgeIndex];
const badge = getAtPath<Partial<LovelaceBadgeConfig> | string>(
params.lovelaceConfig,
params.path
);
this._badgeConfig = badge != null ? ensureBadgeConfig(badge) : badge;
}
@@ -125,6 +133,11 @@ export class HuiDialogEditBadge
}
}
private get _collectionPath(): LovelacePath {
const params = this._params!;
return "badgeConfig" in params ? params.path : getParentPath(params.path);
}
public closeDialog(): boolean {
if (this.isEffectiveDirtyState) {
this._confirmCancel();
@@ -408,15 +421,15 @@ export class HuiDialogEditBadge
return;
}
this._saving = true;
const path = this._params!.path;
await this._params!.saveConfig(
"badgeConfig" in this._params!
? addBadge(this._params!.lovelaceConfig, path, this._badgeConfig!)
: replaceBadge(
this._params!.lovelaceConfig,
[...path, this._params!.badgeIndex],
const params = this._params!;
await params.saveConfig(
"badgeConfig" in params
? appendAtPath(
params.lovelaceConfig,
this._collectionPath,
this._badgeConfig!
)
: setAtPath(params.lovelaceConfig, params.path, this._badgeConfig!)
);
this._saving = false;
this._markDirtyStateClean();
@@ -1,10 +1,11 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelacePath } from "../lovelace-path";
export interface CreateBadgeDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: [number];
path: LovelacePath;
suggestedBadges?: string[];
entities?: string[]; // We can pass entity id's that will be added to the config when a badge is picked
}
@@ -1,19 +1,13 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelacePath } from "../lovelace-path";
export type EditBadgeDialogParams = {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: [number];
} & (
| {
badgeIndex: number;
}
| {
badgeConfig: LovelaceBadgeConfig;
}
);
path: LovelacePath;
} & ({ badgeConfig: LovelaceBadgeConfig } | {});
export const importEditBadgeDialog = () => import("./hui-dialog-edit-badge");
@@ -11,13 +11,20 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type {
LovelaceSectionConfig,
LovelaceSectionRawConfig,
} from "../../../../data/lovelace/config/section";
import type {
LovelaceViewConfig,
LovelaceViewRawConfig,
} from "../../../../data/lovelace/config/view";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { addCard } from "../config-util";
import { findLovelaceContainer } from "../lovelace-path";
import { addCardAtPath, getCardSectionConfig } from "../config-util";
import type { LovelacePath } from "../lovelace-path";
import { getAtPath, getParentPath, getPathTarget } from "../lovelace-path";
import "./hui-card-picker";
import "./hui-suggestion-picker";
import type { CreateCardDialogParams } from "./show-create-card-dialog";
@@ -48,10 +55,13 @@ export class HuiCreateDialogCard
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
params.path
);
const containerConfig = getAtPath<
LovelaceViewRawConfig | LovelaceSectionRawConfig
>(params.lovelaceConfig, this._containerPath(params.path));
if (!containerConfig) {
throw new Error("Container does not exist");
}
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
@@ -66,6 +76,13 @@ export class HuiCreateDialogCard
return true;
}
private _containerPath(path: LovelacePath): LovelacePath {
const parentPath = getParentPath(path);
return getPathTarget(path) === "slot"
? getParentPath(parentPath)
: parentPath;
}
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
@@ -103,7 +120,7 @@ export class HuiCreateDialogCard
<span slot="title">${title}</span>
${
!this._params.saveCard
getPathTarget(this._params.path) !== "slot"
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
<ha-tab-group-tab
@@ -223,15 +240,10 @@ export class HuiCreateDialogCard
ev: CustomEvent<{ config: LovelaceCardConfig }>
): Promise<void> {
const config = ev.detail.config;
if (this._params!.saveCard) {
await this._params!.saveCard(config);
} else {
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
const newConfig = addCard(lovelaceConfig, containerPath, config);
await saveConfig(newConfig);
}
const lovelaceConfig = this._params!.lovelaceConfig;
const path = this._params!.path;
const saveConfig = this._params!.saveConfig;
await saveConfig(addCardAtPath(lovelaceConfig, path, config));
this.closeDialog();
}
@@ -245,34 +257,17 @@ export class HuiCreateDialogCard
}
}
if (this._params!.saveCard) {
showEditCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveCardConfig: this._params!.saveCard,
cardConfig: config,
isNew: true,
});
this.closeDialog();
return;
}
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const path = this._params!.path;
const saveConfig = this._params!.saveConfig;
const sectionConfig =
containerPath.length === 2
? findLovelaceContainer(lovelaceConfig, containerPath)
: undefined;
showEditCardDialog(this, {
lovelaceConfig,
saveCardConfig: async (newCardConfig) => {
const newConfig = addCard(lovelaceConfig, containerPath, newCardConfig);
await saveConfig(newConfig);
await saveConfig(addCardAtPath(lovelaceConfig, path, newCardConfig));
},
cardConfig: config,
sectionConfig,
sectionConfig: getCardSectionConfig(lovelaceConfig, path),
isNew: true,
});
@@ -13,6 +13,7 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
@@ -21,8 +22,8 @@ import "../../cards/hui-card";
import "../../sections/hui-section";
import { getViewType } from "../../views/get-view-type";
import { addCards, addSection } from "../config-util";
import type { LovelaceContainerPath } from "../lovelace-path";
import { parseLovelaceContainerPath } from "../lovelace-path";
import type { LovelacePath } from "../lovelace-path";
import { getAtPath, getParentPath, getViewPath } from "../lovelace-path";
import { showCreateCardDialog } from "./show-create-card-dialog";
import type { SuggestCardDialogParams } from "./show-suggest-card-dialog";
@@ -74,11 +75,16 @@ export class HuiDialogSuggestCard extends LitElement {
return false;
}
const { viewIndex } = parseLovelaceContainerPath(this._params.path);
const viewConfig = this._params!.lovelaceConfig.views[viewIndex];
const viewPath = getViewPath(this._params.path);
const viewConfig = getAtPath<LovelaceViewRawConfig>(
this._params.lovelaceConfig,
viewPath
);
return (
!isStrategyView(viewConfig) && getViewType(viewConfig) === "sections"
!!viewConfig &&
!isStrategyView(viewConfig) &&
getViewType(viewConfig) === "sections"
);
}
@@ -228,7 +234,7 @@ export class HuiDialogSuggestCard extends LitElement {
showCreateCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: this._params!.path,
path: [...this._params!.path, "cards"],
entities: this._params!.entities,
});
this.closeDialog();
@@ -236,28 +242,27 @@ export class HuiDialogSuggestCard extends LitElement {
private _computeNewConfig(
config: LovelaceConfig,
path: LovelaceContainerPath
path: LovelacePath
): LovelaceConfig {
if (!this._viewSupportsSection) {
return addCards(config, path, this._cardConfig!);
}
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
// If container is a view, add a section
if (sectionIndex === undefined) {
const parentPath = getParentPath(path);
if (parentPath[parentPath.length - 1] !== "sections") {
const newSection = this._sectionConfig ?? {
type: "grid",
cards: this._cardConfig,
};
return addSection(config, viewIndex, newSection);
return addSection(config, path, newSection);
}
// Else add cards to section
const newCards = this._sectionConfig
? this._sectionConfig.cards || []
: this._cardConfig!;
return addCards(config, [viewIndex, sectionIndex], newCards);
return addCards(config, path, newCards);
}
private async _save(): Promise<void> {
@@ -1,15 +1,13 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceContainerPath } from "../lovelace-path";
import type { LovelacePath } from "../lovelace-path";
export interface CreateCardDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelaceContainerPath;
path: LovelacePath;
suggestedCards?: string[];
entities?: string[]; // We can pass entity id's that will be added to the config when a card is picked
saveCard?: (cardConfig: LovelaceCardConfig) => void; // Optional: pick a single card and return it via callback, hides entity tab
}
export const importCreateCardDialog = () => import("./hui-dialog-create-card");
@@ -2,13 +2,13 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceContainerPath } from "../lovelace-path";
import type { LovelacePath } from "../lovelace-path";
export interface SuggestCardDialogParams {
lovelaceConfig?: LovelaceConfig;
yaml?: boolean;
saveConfig?: (config: LovelaceConfig) => void;
path?: LovelaceContainerPath;
path?: LovelacePath;
entities?: string[]; // We pass this to create dialog when user chooses "Pick own"
cardConfig: LovelaceCardConfig[]; // We can pass a suggested config,s
sectionConfig?: LovelaceSectionConfig;
+77 -323
View File
@@ -1,180 +1,97 @@
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";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type {
LovelaceSectionConfig,
LovelaceSectionRawConfig,
} from "../../../data/lovelace/config/section";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { isStrategyView } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardPath, LovelaceContainerPath } from "./lovelace-path";
import type { LovelacePath } from "./lovelace-path";
import {
findLovelaceContainer,
findLovelaceItems,
getLovelaceContainerPath,
parseLovelaceCardPath,
parseLovelaceContainerPath,
updateLovelaceContainer,
updateLovelaceItems,
appendAtPath,
deleteAtPath,
getAtPath,
getParentPath,
insertAtPath,
getPathTarget,
moveAtPath,
pathEquals,
setAtPath,
} from "./lovelace-path";
export const addCard = (
config: LovelaceConfig,
path: LovelaceContainerPath,
containerPath: LovelacePath,
cardConfig: LovelaceCardConfig
): LovelaceConfig => {
const cards = findLovelaceItems("cards", config, path);
const newCards = cards ? [...cards, cardConfig] : [cardConfig];
const newConfig = updateLovelaceItems("cards", config, path, newCards);
return newConfig;
};
): LovelaceConfig =>
appendAtPath(config, [...containerPath, "cards"], cardConfig);
export const addCards = (
config: LovelaceConfig,
path: LovelaceContainerPath,
containerPath: LovelacePath,
cardConfigs: LovelaceCardConfig[]
): LovelaceConfig => {
const cards = findLovelaceItems("cards", config, path);
const newCards = cards ? [...cards, ...cardConfigs] : [...cardConfigs];
const newConfig = updateLovelaceItems("cards", config, path, newCards);
return newConfig;
};
): LovelaceConfig =>
cardConfigs.reduce(
(newConfig, cardConfig) => addCard(newConfig, containerPath, cardConfig),
config
);
export const replaceCard = (
export const addCardAtPath = (
config: LovelaceConfig,
path: LovelaceCardPath,
path: LovelacePath,
cardConfig: LovelaceCardConfig
): LovelaceConfig => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const cards = findLovelaceItems("cards", config, containerPath);
const newCards = (cards ?? []).map((origConf, ind) =>
ind === cardIndex ? cardConfig : origConf
);
const newConfig = updateLovelaceItems(
"cards",
config,
containerPath,
newCards
);
return newConfig;
};
export const deleteCard = (
config: LovelaceConfig,
path: LovelaceCardPath
): LovelaceConfig => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const cards = findLovelaceItems("cards", config, containerPath);
const newCards = (cards ?? []).filter((_origConf, ind) => ind !== cardIndex);
const newConfig = updateLovelaceItems(
"cards",
config,
containerPath,
newCards
);
return newConfig;
};
export const insertCard = (
config: LovelaceConfig,
path: LovelaceCardPath,
cardConfig: LovelaceCardConfig
) => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const cards = findLovelaceItems("cards", config, containerPath);
const newCards = cards
? [...cards.slice(0, cardIndex), cardConfig, ...cards.slice(cardIndex)]
: [cardConfig];
const newConfig = updateLovelaceItems(
"cards",
config,
containerPath,
newCards
);
return newConfig;
};
): LovelaceConfig =>
getPathTarget(path) === "slot"
? setAtPath(config, path, cardConfig)
: appendAtPath(config, path, cardConfig);
export const moveCardToIndex = (
config: LovelaceConfig,
path: LovelaceCardPath,
cardPath: LovelacePath,
index: number
): LovelaceConfig => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const cards = findLovelaceItems("cards", config, containerPath);
const newCards = cards ? [...cards] : [];
const oldIndex = cardIndex;
const newIndex = Math.max(Math.min(index, newCards.length - 1), 0);
const card = newCards[oldIndex];
newCards.splice(oldIndex, 1);
newCards.splice(newIndex, 0, card);
const newConfig = updateLovelaceItems(
"cards",
config,
containerPath,
newCards
);
return newConfig;
const collectionPath = getParentPath(cardPath);
const cards = getAtPath<LovelaceCardConfig[]>(config, collectionPath) ?? [];
const newIndex = Math.max(Math.min(index, cards.length - 1), 0);
return moveAtPath(config, cardPath, [...collectionPath, newIndex]);
};
export const moveCardToContainer = (
config: LovelaceConfig,
fromPath: LovelaceCardPath,
toPath: LovelaceContainerPath
cardPath: LovelacePath,
containerPath: LovelacePath
): LovelaceConfig => {
const {
cardIndex: fromCardIndex,
viewIndex: fromViewIndex,
sectionIndex: fromSectionIndex,
} = parseLovelaceCardPath(fromPath);
const { viewIndex: toViewIndex, sectionIndex: toSectionIndex } =
parseLovelaceContainerPath(toPath);
if (fromViewIndex === toViewIndex && fromSectionIndex === toSectionIndex) {
const fromCardsPath = getParentPath(cardPath);
const toCardsPath = [...containerPath, "cards"];
if (pathEquals(fromCardsPath, toCardsPath)) {
throw new Error("You cannot move a card to the view or section it is in.");
}
const fromContainerPath = getLovelaceContainerPath(fromPath);
const cards = findLovelaceItems("cards", config, fromContainerPath);
const card = cards![fromCardIndex];
let newConfig = addCard(config, toPath, card);
newConfig = deleteCard(newConfig, fromPath);
return newConfig;
const card = getAtPath<LovelaceCardConfig>(config, cardPath)!;
const newConfig = addCard(config, containerPath, card);
return deleteAtPath(newConfig, cardPath);
};
export const moveCard = (
export const getCardSectionConfig = (
config: LovelaceConfig,
fromPath: LovelaceCardPath,
toPath: LovelaceCardPath
): LovelaceConfig => {
const { cardIndex: fromCardIndex } = parseLovelaceCardPath(fromPath);
const fromContainerPath = getLovelaceContainerPath(fromPath);
const cards = findLovelaceItems("cards", config, fromContainerPath);
const card = cards![fromCardIndex];
let newConfig = deleteCard(config, fromPath);
newConfig = insertCard(newConfig, toPath, card);
return newConfig;
path: LovelacePath
): LovelaceSectionConfig | undefined => {
const parentPath = getParentPath(path);
const containerPath =
getPathTarget(path) === "item" ? getParentPath(parentPath) : parentPath;
if (
containerPath[containerPath.length - 2] !== "sections" ||
typeof containerPath[containerPath.length - 1] !== "number"
) {
return undefined;
}
const section = getAtPath<LovelaceSectionRawConfig>(config, containerPath);
if (!section || isStrategySection(section)) {
return undefined;
}
return section;
};
export const addView = (
@@ -266,197 +183,34 @@ export const moveViewToDashboard = (
export const addSection = (
config: LovelaceConfig,
viewIndex: number,
containerPath: LovelacePath,
sectionConfig: LovelaceSectionRawConfig
): LovelaceConfig => {
const view = findLovelaceContainer(config, [viewIndex]);
if (isStrategyView(view)) {
throw new Error("Deleting sections in a strategy is not supported.");
}
const sections = view.sections
? [...view.sections, sectionConfig]
: [sectionConfig];
const newConfig = updateLovelaceContainer(config, [viewIndex], {
...view,
sections,
});
return newConfig;
};
export const deleteSection = (
config: LovelaceConfig,
viewIndex: number,
sectionIndex: number
): LovelaceConfig => {
const view = findLovelaceContainer(config, [viewIndex]);
if (isStrategyView(view)) {
throw new Error("Deleting sections in a strategy is not supported.");
}
const sections = view.sections?.filter(
(_origSection, index) => index !== sectionIndex
);
const newConfig = updateLovelaceContainer(config, [viewIndex], {
...view,
sections,
});
return newConfig;
};
): LovelaceConfig =>
appendAtPath(config, [...containerPath, "sections"], sectionConfig);
export const duplicateSection = (
config: LovelaceConfig,
viewIndex: number,
sectionIndex: number
sectionPath: LovelacePath
): 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,
sectionIndex: number,
sectionConfig: LovelaceSectionRawConfig
): LovelaceConfig => {
const view = findLovelaceContainer(config, [viewIndex]);
if (isStrategyView(view)) {
throw new Error("Inserting sections in a strategy is not supported.");
}
const sections = view.sections
? [
...view.sections.slice(0, sectionIndex),
sectionConfig,
...view.sections.slice(sectionIndex),
]
: [sectionConfig];
const newConfig = updateLovelaceContainer(config, [viewIndex], {
...view,
sections,
});
return newConfig;
};
export const moveSection = (
config: LovelaceConfig,
fromPath: [number, number],
toPath: [number, number]
): LovelaceConfig => {
const section = findLovelaceContainer(config, fromPath);
let newConfig = deleteSection(config, fromPath[0], fromPath[1]);
newConfig = insertSection(newConfig, toPath[0], toPath[1], section);
return newConfig;
const index = sectionPath[sectionPath.length - 1] as number;
const sectionsPath = getParentPath(sectionPath);
const section = getAtPath<LovelaceSectionRawConfig>(config, sectionPath);
return insertAtPath(config, [...sectionsPath, index + 1], deepClone(section));
};
export const addBadge = (
config: LovelaceConfig,
path: LovelaceContainerPath,
containerPath: LovelacePath,
badgeConfig: LovelaceBadgeConfig
): LovelaceConfig => {
const badges = findLovelaceItems("badges", config, path);
const newBadges = badges ? [...badges, badgeConfig] : [badgeConfig];
const newConfig = updateLovelaceItems("badges", config, path, newBadges);
return newConfig;
};
): LovelaceConfig =>
appendAtPath(config, [...containerPath, "badges"], badgeConfig);
export const addBadges = (
config: LovelaceConfig,
path: LovelaceContainerPath,
badgeConfig: LovelaceBadgeConfig[]
): LovelaceConfig => {
const badges = findLovelaceItems("badges", config, path);
const newBadges = badges ? [...badges, ...badgeConfig] : [...badgeConfig];
const newConfig = updateLovelaceItems("badges", config, path, newBadges);
return newConfig;
};
export const replaceBadge = (
config: LovelaceConfig,
path: LovelaceCardPath,
cardConfig: LovelaceBadgeConfig
): LovelaceConfig => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const badges = findLovelaceItems("badges", config, containerPath);
const newBadges = (badges ?? []).map((origConf, ind) =>
ind === cardIndex ? cardConfig : origConf
containerPath: LovelacePath,
badgeConfigs: LovelaceBadgeConfig[]
): LovelaceConfig =>
badgeConfigs.reduce(
(newConfig, badgeConfig) => addBadge(newConfig, containerPath, badgeConfig),
config
);
const newConfig = updateLovelaceItems(
"badges",
config,
containerPath,
newBadges
);
return newConfig;
};
export const deleteBadge = (
config: LovelaceConfig,
path: LovelaceCardPath
): LovelaceConfig => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const badges = findLovelaceItems("badges", config, containerPath);
const newBadges = (badges ?? []).filter(
(_origConf, ind) => ind !== cardIndex
);
const newConfig = updateLovelaceItems(
"badges",
config,
containerPath,
newBadges
);
return newConfig;
};
export const insertBadge = (
config: LovelaceConfig,
path: LovelaceCardPath,
badgeConfig: LovelaceBadgeConfig
) => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const badges = findLovelaceItems("badges", config, containerPath);
const newBadges = badges
? [...badges.slice(0, cardIndex), badgeConfig, ...badges.slice(cardIndex)]
: [badgeConfig];
const newConfig = updateLovelaceItems(
"badges",
config,
containerPath,
newBadges
);
return newConfig;
};
export const moveBadge = (
config: LovelaceConfig,
fromPath: LovelaceCardPath,
toPath: LovelaceCardPath
): LovelaceConfig => {
const { cardIndex: fromCardIndex } = parseLovelaceCardPath(fromPath);
const fromContainerPath = getLovelaceContainerPath(fromPath);
const badges = findLovelaceItems("badges", config, fromContainerPath);
const badge = badges![fromCardIndex];
let newConfig = deleteBadge(config, fromPath);
newConfig = insertBadge(newConfig, toPath, ensureBadgeConfig(badge));
return newConfig;
};
+4 -4
View File
@@ -1,11 +1,11 @@
import type { HomeAssistant } from "../../../types";
import type { Lovelace } from "../types";
import { deleteBadge } from "./config-util";
import type { LovelaceCardPath } from "./lovelace-path";
import type { LovelacePath } from "./lovelace-path";
import { deleteAtPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
export interface DeleteBadgeParams {
path: LovelaceCardPath;
path: LovelacePath;
silent: boolean;
}
@@ -17,7 +17,7 @@ export async function performDeleteBadge(
try {
const { path, silent } = params;
const oldConfig = lovelace.config;
const newConfig = deleteBadge(oldConfig, path);
const newConfig = deleteAtPath(oldConfig, path);
await lovelace.saveConfig(newConfig);
if (silent) {
+4 -4
View File
@@ -1,11 +1,11 @@
import type { HomeAssistant } from "../../../types";
import type { Lovelace } from "../types";
import { deleteCard } from "./config-util";
import type { LovelaceCardPath } from "./lovelace-path";
import type { LovelacePath } from "./lovelace-path";
import { deleteAtPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
export interface DeleteCardParams {
path: LovelaceCardPath;
path: LovelacePath;
silent: boolean;
}
@@ -17,7 +17,7 @@ export async function performDeleteCard(
try {
const { path, silent } = params;
const oldConfig = lovelace.config;
const newConfig = deleteCard(oldConfig, path);
const newConfig = deleteAtPath(oldConfig, path);
await lovelace.saveConfig(newConfig);
if (silent) {
+217 -184
View File
@@ -1,212 +1,245 @@
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../data/lovelace/config/view";
import { isStrategyView } from "../../../data/lovelace/config/view";
export type LovelaceCardPath = [number, number] | [number, number, number];
export type LovelaceContainerPath = [number] | [number, number];
export type LovelacePath = (string | number)[];
export type LovelaceItemKind = "view" | "section" | "card" | "badge";
export type LovelacePathTarget = "item" | "list" | "slot" | "node";
export const parseLovelaceCardPath = (
path: LovelaceCardPath
): { viewIndex: number; sectionIndex?: number; cardIndex: number } => {
if (path.length === 2) {
return {
viewIndex: path[0],
cardIndex: path[1],
};
const LIST_KEYS: Record<string, LovelaceItemKind> = {
views: "view",
sections: "section",
cards: "card",
badges: "badge",
};
const SLOT_KEYS: Record<string, LovelaceItemKind> = {
card: "card",
};
export const stringifyPath = (path: LovelacePath): string => path.join("/");
export const parsePath = (path: string): LovelacePath =>
path === ""
? []
: path
.split("/")
.map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment));
export const pathEquals = (a: LovelacePath, b: LovelacePath): boolean =>
a.length === b.length && a.every((segment, index) => segment === b[index]);
export const isAncestorPath = (
ancestor: LovelacePath,
path: LovelacePath
): boolean =>
ancestor.length < path.length &&
ancestor.every((segment, index) => segment === path[index]);
export const getParentPath = (path: LovelacePath): LovelacePath =>
path.slice(0, -1);
export const getViewPath = (path: LovelacePath): LovelacePath =>
path.slice(0, 2);
export const getPathTarget = (path: LovelacePath): LovelacePathTarget => {
const last = path[path.length - 1];
if (typeof last === "number") {
return "item";
}
if (last in LIST_KEYS) {
return "list";
}
if (last in SLOT_KEYS) {
return "slot";
}
return "node";
};
// Temporary compatibility: custom view layouts still pass [view, card] or [view, section, card] index tuples
export const normalizeCardPath = (path: LovelacePath): LovelacePath => {
if (path.length < 2 || path.some((segment) => typeof segment === "string")) {
return path;
}
const [viewIndex, ...rest] = path;
return rest.length === 1
? ["views", viewIndex, "cards", rest[0]]
: ["views", viewIndex, "sections", rest[0], "cards", rest[1]];
};
export const getItemKind = (
path: LovelacePath
): LovelaceItemKind | undefined => {
for (let index = path.length - 1; index >= 0; index--) {
const segment = path[index];
if (typeof segment === "string") {
return LIST_KEYS[segment] ?? SLOT_KEYS[segment];
}
}
return undefined;
};
const isRecord = (node: unknown): node is Record<string, unknown> =>
typeof node === "object" && node !== null && !Array.isArray(node);
const isStrategyNode = (node: unknown): boolean =>
isRecord(node) && "strategy" in node;
const strategyError = (path: LovelacePath, depth: number): Error =>
new Error(
`Cannot edit inside a strategy: ${stringifyPath(path.slice(0, depth))}`
);
const getChild = (node: unknown, segment: string | number): unknown => {
if (Array.isArray(node)) {
return typeof segment === "number" ? node[segment] : undefined;
}
if (isRecord(node) && typeof segment === "string") {
return node[segment];
}
return undefined;
};
const readAtPath = (node: unknown, path: LovelacePath, depth = 0): unknown => {
if (depth === path.length) {
return node;
}
if (node === undefined) {
return undefined;
}
if (isStrategyNode(node)) {
throw strategyError(path, depth);
}
return readAtPath(getChild(node, path[depth]), path, depth + 1);
};
const updateAtPath = (
node: unknown,
path: LovelacePath,
updater: (node: unknown) => unknown,
depth = 0
): unknown => {
if (depth === path.length) {
return updater(node);
}
if (isStrategyNode(node)) {
throw strategyError(path, depth);
}
const segment = path[depth];
if (typeof segment === "number") {
if (!Array.isArray(node) || segment >= node.length) {
throw new Error(
`Cannot edit missing item: ${stringifyPath(path.slice(0, depth + 1))}`
);
}
const items = node.slice();
items[segment] = updateAtPath(items[segment], path, updater, depth + 1);
return items;
}
const record = isRecord(node) ? node : {};
return {
viewIndex: path[0],
sectionIndex: path[1],
cardIndex: path[2],
...record,
[segment]: updateAtPath(record[segment], path, updater, depth + 1),
};
};
export const parseLovelaceContainerPath = (
path: LovelaceContainerPath
): { viewIndex: number; sectionIndex?: number } => {
if (path.length === 1) {
return {
viewIndex: path[0],
};
}
return {
viewIndex: path[0],
sectionIndex: path[1],
};
};
export const getLovelaceContainerPath = (
path: LovelaceCardPath
): LovelaceContainerPath => path.slice(0, -1) as LovelaceContainerPath;
interface FindLovelaceContainer {
(config: LovelaceConfig, path: [number]): LovelaceViewRawConfig;
(config: LovelaceConfig, path: [number, number]): LovelaceSectionRawConfig;
(
config: LovelaceConfig,
path: LovelaceContainerPath
): LovelaceViewRawConfig | LovelaceSectionRawConfig;
}
export const findLovelaceContainer: FindLovelaceContainer = ((
const update = (
config: LovelaceConfig,
path: LovelaceContainerPath
): LovelaceViewRawConfig | LovelaceSectionRawConfig => {
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
path: LovelacePath,
updater: (node: unknown) => unknown
): LovelaceConfig => updateAtPath(config, path, updater) as LovelaceConfig;
const view = config.views[viewIndex];
if (!view) {
throw new Error("View does not exist");
}
if (sectionIndex === undefined) {
return view;
}
if (isStrategyView(view)) {
throw new Error("Can not find section in a strategy view");
}
const section = view.sections?.[sectionIndex];
if (!section) {
throw new Error("Section does not exist");
}
return section;
}) as FindLovelaceContainer;
export const updateLovelaceContainer = (
export const getAtPath = <T = unknown>(
config: LovelaceConfig,
path: LovelaceContainerPath,
containerConfig: LovelaceViewRawConfig | LovelaceSectionRawConfig
path: LovelacePath
): T | undefined => readAtPath(config, path) as T | undefined;
export const setAtPath = (
config: LovelaceConfig,
path: LovelacePath,
value: unknown
): LovelaceConfig => update(config, path, () => value);
export const insertAtPath = (
config: LovelaceConfig,
path: LovelacePath,
value: unknown
): LovelaceConfig => {
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
let updated = false;
const newViews = config.views.map((view, vIndex) => {
if (vIndex !== viewIndex) return view;
if (sectionIndex === undefined) {
updated = true;
return containerConfig as LovelaceViewRawConfig;
}
if (isStrategyView(view)) {
throw new Error("Can not update section in a strategy view");
}
if (view.sections === undefined) {
throw new Error("Section does not exist");
}
const newSections = view.sections.map((section, sIndex) => {
if (sIndex !== sectionIndex) return section;
updated = true;
return containerConfig as LovelaceSectionRawConfig;
});
return {
...view,
sections: newSections,
};
});
if (!updated) {
throw new Error("Can not update cards in a non-existing view/section");
const index = path[path.length - 1];
if (typeof index !== "number") {
return setAtPath(config, path, value);
}
return {
...config,
views: newViews,
};
return update(config, getParentPath(path), (node) => {
const items = Array.isArray(node) ? node.slice() : [];
items.splice(Math.max(0, Math.min(index, items.length)), 0, value);
return items;
});
};
interface LovelaceItemKeys {
cards: LovelaceCardConfig[];
badges: (Partial<LovelaceBadgeConfig> | string)[];
}
export const updateLovelaceItems = <T extends keyof LovelaceItemKeys>(
key: T,
export const appendAtPath = (
config: LovelaceConfig,
path: LovelaceContainerPath,
items: LovelaceItemKeys[T]
collectionPath: LovelacePath,
value: unknown
): LovelaceConfig =>
update(config, collectionPath, (node) =>
Array.isArray(node) ? [...node, value] : [value]
);
export const deleteAtPath = (
config: LovelaceConfig,
path: LovelacePath
): LovelaceConfig => {
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
let updated = false;
const newViews = config.views.map((view, vIndex) => {
if (vIndex !== viewIndex) return view;
if (isStrategyView(view)) {
throw new Error(`Can not update ${key} in a strategy view`);
if (path.length === 0 || getAtPath(config, path) === undefined) {
return config;
}
const key = path[path.length - 1];
return update(config, getParentPath(path), (node) => {
if (typeof key === "number") {
return Array.isArray(node)
? node.filter((_item, index) => index !== key)
: node;
}
if (sectionIndex === undefined) {
updated = true;
return {
...view,
[key]: items,
};
if (!isRecord(node)) {
return node;
}
if (view.sections === undefined) {
throw new Error("Section does not exist");
}
const newSections = view.sections.map((section, sIndex) => {
if (sIndex !== sectionIndex) return section;
if (isStrategySection(section)) {
throw new Error(`Can not update ${key} in a strategy section`);
}
updated = true;
return {
...section,
[key]: items,
};
});
return {
...view,
sections: newSections,
};
const record = { ...node };
delete record[key];
return record;
});
if (!updated) {
throw new Error(`Can not update ${key} in a non-existing view/section`);
}
return {
...config,
views: newViews,
};
};
export const findLovelaceItems = <T extends keyof LovelaceItemKeys>(
key: T,
export const moveAtPath = (
config: LovelaceConfig,
path: LovelaceContainerPath
): LovelaceItemKeys[T] | undefined => {
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
const view = config.views[viewIndex];
if (!view) {
throw new Error("View does not exist");
from: LovelacePath,
to: LovelacePath
): LovelaceConfig => {
if (pathEquals(from, to)) {
return config;
}
if (isStrategyView(view)) {
throw new Error("Can not find cards in a strategy view");
if (isAncestorPath(from, to)) {
throw new Error(
`Cannot move ${stringifyPath(from)} into itself: ${stringifyPath(to)}`
);
}
if (sectionIndex === undefined) {
return view[key] as LovelaceItemKeys[T] | undefined;
const value = getAtPath(config, from);
if (value === undefined) {
throw new Error(`Nothing to move at ${stringifyPath(from)}`);
}
const section = view.sections?.[sectionIndex];
if (!section) {
throw new Error("Section does not exist");
const deleted = deleteAtPath(config, from);
const target = [...to];
const sameList =
to.length === from.length &&
pathEquals(getParentPath(from), getParentPath(to));
const shiftedIndex = from.length - 1;
const fromIndex = from[shiftedIndex];
const toIndex = to[shiftedIndex];
if (
!sameList &&
to.length > from.length &&
isAncestorPath(getParentPath(from), to) &&
typeof toIndex === "number" &&
typeof fromIndex === "number" &&
toIndex > fromIndex
) {
target[shiftedIndex] = toIndex - 1;
}
if (isStrategySection(section)) {
throw new Error("Can not find cards in a strategy section");
}
if (key === "cards") {
return section[key as "cards"] as LovelaceItemKeys[T] | undefined;
}
throw new Error(`${key} is not supported in section`);
return insertAtPath(deleted, target, value);
};
@@ -24,10 +24,8 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { saveConfig } from "../../../../data/lovelace/config/types";
import {
isStrategyView,
type LovelaceViewConfig,
} from "../../../../data/lovelace/config/view";
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
@@ -37,10 +35,13 @@ import {
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { Lovelace } from "../../types";
import { addSection, deleteSection, moveSection } from "../config-util";
import { addSection } from "../config-util";
import {
findLovelaceContainer,
updateLovelaceContainer,
deleteAtPath,
getAtPath,
getViewPath,
moveAtPath,
setAtPath,
} from "../lovelace-path";
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
import "./hui-section-settings-editor";
@@ -65,7 +66,7 @@ export class HuiDialogEditSection
@state() private _config?: LovelaceSectionRawConfig;
@state() private _viewConfig?: LovelaceViewConfig;
@state() private _viewConfig?: LovelaceViewRawConfig;
@state() private _yamlMode = false;
@@ -91,13 +92,15 @@ export class HuiDialogEditSection
this.lovelace = params.lovelace;
this._config = findLovelaceContainer(this._params.lovelaceConfig, [
this._params.viewIndex,
this._params.sectionIndex,
]);
this._viewConfig = findLovelaceContainer(this._params.lovelaceConfig, [
this._params.viewIndex,
]);
this._config = getAtPath<LovelaceSectionRawConfig>(
params.lovelaceConfig,
params.path
);
const viewPath = getViewPath(params.path);
this._viewConfig = getAtPath<LovelaceViewRawConfig>(
params.lovelaceConfig,
viewPath
);
this._initDirtyTracking({ type: "deep" }, this._config);
}
@@ -316,8 +319,7 @@ export class HuiDialogEditSection
return;
}
const fromViewIndex = this._params.viewIndex;
const fromSectionIndex = this._params.sectionIndex;
const fromPath = this._params.path;
// Same dashboard
if (urlPath === this.lovelace.urlPath) {
@@ -325,11 +327,12 @@ export class HuiDialogEditSection
const toIndex = toView.sections?.length ?? 0;
try {
await this.lovelace.saveConfig(
moveSection(
oldConfig,
[fromViewIndex, fromSectionIndex],
[viewIndex, toIndex]
)
moveAtPath(oldConfig, fromPath, [
"views",
viewIndex,
"sections",
toIndex,
])
);
this.lovelace.showToast({
message: this.hass!.localize(
@@ -361,20 +364,18 @@ export class HuiDialogEditSection
const oldFromConfig = this.lovelace.config;
const oldToConfig = selectedDashConfig;
try {
const section = findLovelaceContainer(oldFromConfig, [
fromViewIndex,
fromSectionIndex,
]) as LovelaceSectionRawConfig;
const section = getAtPath<LovelaceSectionRawConfig>(
oldFromConfig,
fromPath
)!;
await saveConfig(
this.hass!,
urlPath,
addSection(oldToConfig, viewIndex, section)
addSection(oldToConfig, ["views", viewIndex], section)
);
await this.lovelace.saveConfig(
deleteSection(oldFromConfig, fromViewIndex, fromSectionIndex)
);
await this.lovelace.saveConfig(deleteAtPath(oldFromConfig, fromPath));
this.lovelace.showToast({
message: this.hass!.localize(
@@ -426,9 +427,9 @@ export class HuiDialogEditSection
if (!this._params || !this._config) {
return;
}
const newConfig = updateLovelaceContainer(
const newConfig = setAtPath(
this._params.lovelaceConfig,
[this._params.viewIndex, this._params.sectionIndex],
this._params.path,
this._config
);
@@ -1,13 +1,13 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
export interface EditSectionDialogParams {
lovelace: Lovelace;
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
viewIndex: number;
sectionIndex: number;
path: LovelacePath;
}
const importEditSectionDialog = () => import("./hui-dialog-edit-section");
@@ -119,7 +119,7 @@ export class HuiUnusedEntities extends LitElement {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
path: [0],
path: ["views", 0],
entities: this._selectedEntities,
cardConfig,
sectionConfig,
@@ -133,7 +133,7 @@ export class HuiUnusedEntities extends LitElement {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
path: [viewIndex],
path: ["views", viewIndex],
entities: this._selectedEntities,
cardConfig,
sectionConfig,
@@ -17,8 +17,8 @@ import type { HomeAssistant } from "../../../types";
import type { HuiCard } from "../cards/hui-card";
import { computeCardGridSize } from "../common/compute-card-grid-size";
import "../components/hui-card-edit-mode";
import { moveCard } from "../editor/config-util";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import type { LovelacePath } from "../editor/lovelace-path";
import { moveAtPath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
const CARD_SORTABLE_OPTIONS: HaSortableOptions = {
@@ -44,9 +44,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public viewIndex?: number;
@property({ attribute: false }) public path?: LovelacePath;
@property({ attribute: false }) public isStrategy = false;
@@ -110,11 +108,9 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
const { rows, columns } = computeCardGridSize(gridOptions);
const cardPath: LovelaceCardPath = [
this.viewIndex!,
this.index!,
idx,
];
const cardPath: LovelacePath | undefined = editMode
? [...this.path!, "cards", idx]
: undefined;
return html`
<div
style=${styleMap({
@@ -133,7 +129,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
? html`
<hui-card-edit-mode
.lovelace=${this.lovelace!}
.path=${cardPath}
.path=${cardPath!}
.hiddenOverlay=${this._dragging}
.noEdit=${this.importOnly}
.noDuplicate=${this.importOnly}
@@ -174,19 +170,22 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
private _cardMoved(ev) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newConfig = moveCard(
const newConfig = moveAtPath(
this.lovelace!.config,
[this.viewIndex!, this.index!, oldIndex],
[this.viewIndex!, this.index!, newIndex]
[...this.path!, "cards", oldIndex],
[...this.path!, "cards", newIndex]
);
this.lovelace!.saveConfig(newConfig);
}
private _cardAdded(ev) {
ev.stopPropagation();
const { index, data } = ev.detail;
const oldPath = data as LovelaceCardPath;
const newPath = [this.viewIndex!, this.index!, index] as LovelaceCardPath;
const newConfig = moveCard(this.lovelace!.config, oldPath, newPath);
const newConfig = moveAtPath(this.lovelace!.config, data as LovelacePath, [
...this.path!,
"cards",
index,
]);
this.lovelace!.saveConfig(newConfig);
}
@@ -204,7 +203,10 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
}
private _addCard() {
fireEvent(this, "ll-create-card", { suggested: ["tile", "heading"] });
fireEvent(this, "ll-create-card", {
path: [...this.path!, "cards"],
suggested: ["tile", "heading"],
});
}
static get styles(): CSSResultGroup {
+6 -95
View File
@@ -1,8 +1,6 @@
import deepClone from "deep-clone-simple";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { storage } from "../../../common/decorators/storage";
import { deepEqual } from "../../../common/util/deep-equal";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -20,11 +18,7 @@ import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-m
import "../cards/hui-card";
import type { HuiCard } from "../cards/hui-card";
import { createSectionElement } from "../create-element/create-section-element";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { addCard, replaceCard } from "../editor/config-util";
import { performDeleteCard } from "../editor/delete-card";
import { parseLovelaceCardPath } from "../editor/lovelace-path";
import type { LovelacePath } from "../editor/lovelace-path";
import {
checkStrategyShouldRegenerate,
generateLovelaceSectionStrategy,
@@ -53,9 +47,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
@property({ type: Boolean, attribute: "import-only" })
public importOnly = false;
@property({ type: Number }) public index!: number;
@property({ attribute: false }) public viewIndex!: number;
@property({ attribute: false }) public path!: LovelacePath;
@state() private _cards: HuiCard[] = [];
@@ -63,14 +55,6 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
private _layoutElement?: LovelaceSectionElement;
@storage({
key: "dashboardCardClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: LovelaceCardConfig;
private _createCardElement(cardConfig: LovelaceCardConfig) {
const element = document.createElement("hui-card");
element.hass = this.hass;
@@ -193,6 +177,9 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
if (changedProperties.has("importOnly")) {
this._layoutElement.importOnly = this.importOnly;
}
if (changedProperties.has("path")) {
this._layoutElement.path = this.path;
}
if (changedProperties.has("_cards")) {
this._layoutElement.cards = this._cards;
}
@@ -251,8 +238,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
this._layoutElement!.isStrategy = isStrategy;
this._layoutElement!.hass = this.hass;
this._layoutElement!.lovelace = this.lovelace;
this._layoutElement!.index = this.index;
this._layoutElement!.viewIndex = this.viewIndex;
this._layoutElement!.path = this.path;
this._layoutElement!.cards = this._cards;
if (addLayoutElement) {
@@ -317,81 +303,6 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
config
) as LovelaceSectionElement;
this._layoutElementType = config.type;
this._layoutElement.addEventListener("ll-create-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.viewIndex, this.index],
suggestedCards: ev.detail?.suggested,
});
});
this._layoutElement.addEventListener("ll-edit-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const sectionConfig = this.config;
if (isStrategySection(sectionConfig)) {
return;
}
const cardConfig = sectionConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = replaceCard(
this.lovelace!.config,
[this.viewIndex, this.index, cardIndex],
newCardConfig
);
await this.lovelace!.saveConfig(newConfig);
},
sectionConfig,
cardConfig,
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
performDeleteCard(this.hass, this.lovelace, ev.detail);
});
this._layoutElement.addEventListener("ll-duplicate-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const sectionConfig = this.config;
if (isStrategySection(sectionConfig)) {
return;
}
const cardConfig = sectionConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = addCard(
this.lovelace!.config,
[this.viewIndex, this.index],
newCardConfig
);
await this.lovelace!.saveConfig(newConfig);
},
cardConfig,
sectionConfig,
isNew: true,
});
});
this._layoutElement.addEventListener("ll-copy-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const sectionConfig = this.config;
if (isStrategySection(sectionConfig)) {
return;
}
const cardConfig = sectionConfig.cards![cardIndex];
this._clipboard = deepClone(cardConfig);
});
}
private _createCards(config: LovelaceSectionConfig): void {
@@ -13,6 +13,7 @@ import type { HuiBadge } from "../badges/hui-badge";
import "../badges/hui-view-badges";
import type { HuiCard } from "../cards/hui-card";
import { computeCardSize } from "../common/compute-card-size";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
// Find column with < 5 size, else smallest column
@@ -41,7 +42,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public path?: LovelacePath;
@property({ attribute: false }) public isStrategy = false;
@@ -85,7 +86,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
<hui-view-badges
.badges=${this.badges}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
.path=${[...this.path!, "badges"]}
show-add-label
></hui-view-badges>
<div
@@ -160,7 +161,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
}
private _addCard(): void {
fireEvent(this, "ll-create-card");
fireEvent(this, "ll-create-card", { path: [...this.path!, "cards"] });
}
private _createRootElement(columns: HTMLDivElement[]) {
@@ -268,7 +269,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
const wrapper = document.createElement("hui-card-options");
wrapper.hass = this.hass;
wrapper.lovelace = this.lovelace;
wrapper.path = [this.index!, index];
wrapper.path = [...this.path!, "cards", index];
card.preview = true;
wrapper.appendChild(card);
columnEl.appendChild(wrapper);
+4 -3
View File
@@ -12,6 +12,7 @@ import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { HuiCard } from "../cards/hui-card";
import type { HuiCardOptions } from "../components/hui-card-options";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
let editCodeLoaded = false;
@@ -22,7 +23,7 @@ export class PanelView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public path?: LovelacePath;
@property({ attribute: false }) public isStrategy = false;
@@ -96,7 +97,7 @@ export class PanelView extends LitElement implements LovelaceViewElement {
}
private _addCard(): void {
fireEvent(this, "ll-create-card");
fireEvent(this, "ll-create-card", { path: [...this.path!, "cards"] });
}
private _createCard(): void {
@@ -117,7 +118,7 @@ export class PanelView extends LitElement implements LovelaceViewElement {
const wrapper = document.createElement("hui-card-options");
wrapper.hass = this.hass;
wrapper.lovelace = this.lovelace;
wrapper.path = [this.index!, 0];
wrapper.path = [...this.path!, "cards", 0];
wrapper.hidePosition = true;
card.preview = true;
wrapper.appendChild(card);
+39 -40
View File
@@ -22,13 +22,9 @@ import type { HomeAssistant } from "../../../types";
import type { HuiBadge } from "../badges/hui-badge";
import type { HuiCard } from "../cards/hui-card";
import "../components/hui-section-edit-mode";
import { addSection, moveCard, moveSection } from "../editor/config-util";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import {
findLovelaceItems,
getLovelaceContainerPath,
parseLovelaceCardPath,
} from "../editor/lovelace-path";
import { addSection } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { getAtPath, moveAtPath } from "../editor/lovelace-path";
import type { HuiSection } from "../sections/hui-section";
import "../sections/hui-section-background";
import type { Lovelace } from "../types";
@@ -48,7 +44,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public path?: LovelacePath;
@property({ attribute: false }) public isStrategy = false;
@@ -223,7 +219,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.hass=${this.hass}
.badges=${this.badges}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
.path=${this.path!}
.config=${this._config?.header}
></hui-view-header>
${
@@ -287,8 +283,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<hui-section-edit-mode
.hass=${this.hass}
.lovelace=${this.lovelace}
.index=${idx}
.viewIndex=${this.index}
.path=${[...this.path!, "sections", idx]}
>
${this._renderSection(
section,
@@ -354,7 +349,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.hass=${this.hass}
.badges=${this.badges}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
.path=${this.path!}
.config=${this._config.sidebar}
@sidebar-visibility-changed=${
this._handleSidebarVisibilityChanged
@@ -367,7 +362,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<hui-view-footer
.hass=${this.hass}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
.path=${this.path!}
.config=${this._config?.footer}
></hui-view-footer>
<div class="imported-cards-section">
@@ -394,7 +389,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.config=${this._importedCardSectionConfig(
this._config.cards
)}
.viewIndex=${this.index}
.path=${this.path!}
preview
import-only
></hui-section>
@@ -408,32 +403,36 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
private _handleCardAdded(ev) {
const { data } = ev.detail;
const oldPath = data as LovelaceCardPath;
const { cardIndex } = parseLovelaceCardPath(oldPath);
const containerPath = getLovelaceContainerPath(oldPath);
const cards = findLovelaceItems(
"cards",
this.lovelace!.config,
containerPath
);
const cardConfig = cards![cardIndex];
ev.stopPropagation();
// The dropped node only serves as a drop target, the new section renders the card from the config
(ev.detail.item as HTMLElement).remove();
const oldPath = ev.detail.data as LovelacePath;
const config = this.lovelace!.config;
const cardConfig = getAtPath<LovelaceCardConfig>(config, oldPath);
if (!cardConfig) {
return;
}
const configWithNewSection = addSection(
this.lovelace!.config,
this.index!,
config,
this.path!,
generateDefaultSection(this.hass.localize, cardConfig.type !== "heading") // If we move a heading card, we don't want to include a heading in the new section
);
const viewConfig = configWithNewSection.views[
this.index!
] as LovelaceViewConfig;
const newPath = [
this.index!,
viewConfig.sections!.length - 1,
1,
] as LovelaceCardPath;
const newConfig = moveCard(configWithNewSection, oldPath, newPath);
const sectionsPath = [...this.path!, "sections"];
const newIndex =
getAtPath<unknown[]>(configWithNewSection, sectionsPath)!.length - 1;
const cardCount =
getAtPath<unknown[]>(configWithNewSection, [
...sectionsPath,
newIndex,
"cards",
])?.length ?? 0;
const newConfig = moveAtPath(configWithNewSection, oldPath, [
...sectionsPath,
newIndex,
"cards",
cardCount,
]);
this.lovelace!.saveConfig(newConfig);
}
@@ -471,7 +470,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
private _createSection(): void {
const newConfig = addSection(
this.lovelace!.config,
this.index!,
this.path!,
generateDefaultSection(this.hass.localize, true)
);
this.lovelace!.saveConfig(newConfig);
@@ -481,10 +480,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newConfig = moveSection(
const newConfig = moveAtPath(
this.lovelace!.config,
[this.index!, oldIndex],
[this.index!, newIndex]
[...this.path!, "sections", oldIndex],
[...this.path!, "sections", newIndex]
);
this.lovelace!.saveConfig(newConfig);
}
@@ -11,7 +11,8 @@ import type { HuiBadge } from "../badges/hui-badge";
import "../badges/hui-view-badges";
import type { HuiCard } from "../cards/hui-card";
import type { HuiCardOptions } from "../components/hui-card-options";
import { replaceCard } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { setAtPath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
@customElement("hui-sidebar-view")
@@ -20,7 +21,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public path?: LovelacePath;
@property({ attribute: false }) public isStrategy = false;
@@ -93,7 +94,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
<hui-view-badges
.badges=${this.badges}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
.path=${[...this.path!, "badges"]}
show-add-label
></hui-view-badges>
<div
@@ -113,7 +114,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
}
private _addCard(): void {
fireEvent(this, "ll-create-card");
fireEvent(this, "ll-create-card", { path: [...this.path!, "cards"] });
}
private _createCards(): void {
@@ -156,7 +157,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
element = document.createElement("hui-card-options");
element.hass = this.hass;
element.lovelace = this.lovelace;
element.path = [this.index!, idx];
element.path = [...this.path!, "cards", idx];
card.preview = true;
const movePositionButton = document.createElement("ha-icon-button");
movePositionButton.slot = "buttons";
@@ -168,7 +169,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
movePositionButton.appendChild(moveIcon);
movePositionButton.addEventListener("click", () => {
this.lovelace!.saveConfig(
replaceCard(this.lovelace!.config, [this.index!, idx], {
setAtPath(this.lovelace!.config, [...this.path!, "cards", idx], {
...cardConfig!,
view_layout: {
position:
+11 -44
View File
@@ -9,15 +9,14 @@ import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import {
DEFAULT_FOOTER_MAX_WIDTH_PX,
type LovelaceViewConfig,
type LovelaceViewFooterConfig,
} from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { HuiCard } from "../cards/hui-card";
import { computeCardGridSize } from "../common/compute-card-grid-size";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { replaceView } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { setAtPath } from "../editor/lovelace-path";
import { showEditViewFooterDialog } from "../editor/view-footer/show-edit-view-footer-dialog";
import type { Lovelace } from "../types";
@@ -31,7 +30,7 @@ export class HuiViewFooter extends LitElement {
@property({ attribute: false }) public config?: LovelaceViewFooterConfig;
@property({ attribute: false }) public viewIndex!: number;
@property({ attribute: false }) public path!: LovelacePath;
public connectedCallback(): void {
super.connectedCallback();
@@ -90,24 +89,18 @@ export class HuiViewFooter extends LitElement {
return element;
}
private get _cardPath(): LovelacePath {
return [...this.path, "footer", "card"];
}
private _addCard() {
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.viewIndex],
saveCard: (newCardConfig: LovelaceCardConfig) => {
this._saveFooterConfig({ ...this.config, card: newCardConfig });
},
path: this._cardPath,
});
}
private _deleteCard(ev) {
ev.stopPropagation();
const newConfig = { ...this.config };
delete newConfig.card;
this._saveFooterConfig(newConfig);
}
private _configure() {
showEditViewFooterDialog(this, {
config: this.config || {},
@@ -117,34 +110,10 @@ export class HuiViewFooter extends LitElement {
});
}
private _editCard(ev) {
ev.stopPropagation();
const cardConfig = this.config?.card;
if (!cardConfig) return;
showEditCardDialog(this, {
cardConfig,
lovelaceConfig: this.lovelace.config,
saveCardConfig: (newCardConfig: LovelaceCardConfig) => {
this._saveFooterConfig({ ...this.config, card: newCardConfig });
},
});
}
private _saveFooterConfig(footerConfig: LovelaceViewFooterConfig) {
const viewConfig = this.lovelace.config.views[
this.viewIndex
] as LovelaceViewConfig;
const config = { ...viewConfig, footer: footerConfig };
const updatedConfig = replaceView(
this.hass,
this.lovelace.config,
this.viewIndex,
config
this.lovelace.saveConfig(
setAtPath(this.lovelace.config, [...this.path, "footer"], footerConfig)
);
this.lovelace.saveConfig(updatedConfig);
}
private _renderCard(card: HuiCard, editMode: boolean) {
@@ -164,10 +133,8 @@ export class HuiViewFooter extends LitElement {
editMode
? html`
<hui-card-edit-mode
@ll-edit-card=${this._editCard}
@ll-delete-card=${this._deleteCard}
.lovelace=${this.lovelace!}
.path=${[0]}
.path=${this._cardPath}
no-duplicate
no-move
>
+13 -50
View File
@@ -8,16 +8,14 @@ import "../../../components/ha-ripple";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type {
LovelaceViewConfig,
LovelaceViewHeaderConfig,
} from "../../../data/lovelace/config/view";
import type { LovelaceViewHeaderConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { HuiBadge } from "../badges/hui-badge";
import "../badges/hui-view-badges";
import type { HuiCard } from "../cards/hui-card";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { replaceView } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { setAtPath } from "../editor/lovelace-path";
import { showEditViewHeaderDialog } from "../editor/view-header/show-edit-view-header-dialog";
import type { Lovelace } from "../types";
@@ -37,7 +35,7 @@ export class HuiViewHeader extends LitElement {
@property({ attribute: false }) public config?: LovelaceViewHeaderConfig;
@property({ attribute: false }) public viewIndex!: number;
@property({ attribute: false }) public path!: LovelacePath;
private _checkHidden() {
const allHidden =
@@ -129,55 +127,22 @@ export class HuiViewHeader extends LitElement {
cardConfig,
lovelaceConfig: this.lovelace.config,
saveCardConfig: (newCardConfig: LovelaceCardConfig) => {
const newConfig = { ...this.config };
newConfig.card = newCardConfig;
this._saveHeaderConfig(newConfig);
this.lovelace.saveConfig(
setAtPath(this.lovelace.config, this._cardPath, newCardConfig)
);
},
isNew: true,
});
}
private _deleteCard(ev) {
ev.stopPropagation();
const newConfig = { ...this.config };
delete newConfig.card;
this._saveHeaderConfig(newConfig);
}
private _editCard(ev) {
ev.stopPropagation();
const cardConfig = this.config!.card;
if (!cardConfig) {
return;
}
showEditCardDialog(this, {
cardConfig,
lovelaceConfig: this.lovelace.config,
saveCardConfig: (newCardConfig: LovelaceCardConfig) => {
const newConfig = { ...this.config };
newConfig.card = newCardConfig;
this._saveHeaderConfig(newConfig);
},
});
private get _cardPath(): LovelacePath {
return [...this.path, "header", "card"];
}
private _saveHeaderConfig(headerConfig: LovelaceViewHeaderConfig) {
const viewConfig = this.lovelace.config.views[
this.viewIndex
] as LovelaceViewConfig;
const config = { ...viewConfig };
config.header = headerConfig;
const updatedConfig = replaceView(
this.hass,
this.lovelace.config,
this.viewIndex,
config
this.lovelace.saveConfig(
setAtPath(this.lovelace.config, [...this.path, "header"], headerConfig)
);
this.lovelace.saveConfig(updatedConfig);
}
private _configure = () => {
@@ -243,10 +208,8 @@ export class HuiViewHeader extends LitElement {
? card
? html`
<hui-card-edit-mode
@ll-edit-card=${this._editCard}
@ll-delete-card=${this._deleteCard}
.lovelace=${this.lovelace!}
.path=${[0]}
.path=${this._cardPath}
no-duplicate
no-move
>
@@ -277,7 +240,7 @@ export class HuiViewHeader extends LitElement {
<hui-view-badges
.badges=${this.badges}
.lovelace=${this.lovelace!}
.viewIndex=${this.viewIndex!}
.path=${[...this.path, "badges"]}
.showAddLabel=${this.badges.length === 0}
></hui-view-badges>
</div>
@@ -7,6 +7,7 @@ import type { LovelaceViewSidebarConfig } from "../../../data/lovelace/config/vi
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import type { HomeAssistant } from "../../../types";
import "../sections/hui-section";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@@ -22,7 +23,7 @@ export class HuiViewSidebar extends ConditionalListenerMixin<LovelaceViewSidebar
@property({ attribute: false }) public config?: LovelaceViewSidebarConfig;
@property({ attribute: false }) public viewIndex!: number;
@property({ attribute: false }) public path!: LovelacePath;
private _visible = true;
@@ -63,12 +64,12 @@ export class HuiViewSidebar extends ConditionalListenerMixin<LovelaceViewSidebar
${repeat(
this.config?.sections ?? [],
(section) => this._getSectionKey(section),
(section) => html`
(section, idx) => html`
<hui-section
.config=${section}
.hass=${this.hass}
.preview=${this.lovelace.editMode}
.viewIndex=${this.viewIndex}
.path=${[...this.path, "sidebar", "sections", idx]}
></hui-section>
`
)}
+148 -95
View File
@@ -3,6 +3,7 @@ import { consume } from "@lit/context";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { debounce } from "../../../common/util/debounce";
@@ -32,7 +33,7 @@ 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 { addCard, replaceCard } from "../editor/config-util";
import { getCardSectionConfig } from "../editor/config-util";
import {
type DeleteBadgeParams,
performDeleteBadge,
@@ -41,8 +42,15 @@ import {
type DeleteCardParams,
performDeleteCard,
} from "../editor/delete-card";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import { parseLovelaceCardPath } from "../editor/lovelace-path";
import type { LovelacePath } from "../editor/lovelace-path";
import {
appendAtPath,
getAtPath,
getParentPath,
getPathTarget,
normalizeCardPath,
setAtPath,
} from "../editor/lovelace-path";
import { createErrorSectionConfig } from "../sections/hui-error-section";
import "../sections/hui-section";
import type { HuiSection } from "../sections/hui-section";
@@ -56,13 +64,13 @@ import { getViewType } from "./get-view-type";
declare global {
// for fire event
interface HASSDomEvents {
"ll-create-card": { suggested?: string[] } | undefined;
"ll-edit-card": { path: LovelaceCardPath };
"ll-create-card": { path: LovelacePath; suggested?: string[] };
"ll-edit-card": { path: LovelacePath };
"ll-delete-card": DeleteCardParams;
"ll-duplicate-card": { path: LovelaceCardPath };
"ll-copy-card": { path: LovelaceCardPath };
"ll-create-badge": undefined;
"ll-edit-badge": { path: LovelaceCardPath };
"ll-duplicate-card": { path: LovelacePath };
"ll-copy-card": { path: LovelacePath };
"ll-create-badge": { path: LovelacePath };
"ll-edit-badge": { path: LovelacePath };
"ll-delete-badge": DeleteBadgeParams;
}
interface HTMLElementEventMap {
@@ -108,6 +116,8 @@ export class HUIView extends ReactiveElement {
@consume({ context: childPanelReadyContext })
private _registerChildPanelReady?: RegisterChildPanelReady;
private _path = memoizeOne((index: number): LovelacePath => ["views", index]);
@storage({
key: "dashboardCardClipboard",
state: false,
@@ -116,6 +126,132 @@ export class HUIView extends ReactiveElement {
})
protected _clipboard?: LovelaceCardConfig;
constructor() {
super();
this.addEventListener(
"ll-create-card",
(ev: HASSDomEvent<HASSDomEvents["ll-create-card"]>) => {
// Temporary compatibility: custom view layouts still fire this event without a path
const detail = ev.detail as HASSDomEvents["ll-create-card"] | undefined;
const path = detail?.path ?? [...this._path(this.index), "cards"];
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path,
suggestedCards: detail?.suggested,
});
}
);
this.addEventListener(
"ll-edit-card",
(ev: HASSDomEvent<HASSDomEvents["ll-edit-card"]>) => {
const path = normalizeCardPath(ev.detail.path);
const cardConfig = this._getCardConfig(path);
if (!cardConfig) {
return;
}
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = setAtPath(
this.lovelace.config,
path,
newCardConfig
);
await this.lovelace.saveConfig(newConfig);
},
sectionConfig: getCardSectionConfig(this.lovelace.config, path),
cardConfig,
});
}
);
this.addEventListener(
"ll-delete-card",
(ev: HASSDomEvent<HASSDomEvents["ll-delete-card"]>) => {
if (!this.lovelace) return;
performDeleteCard(this.hass, this.lovelace, {
...ev.detail,
path: normalizeCardPath(ev.detail.path),
});
}
);
this.addEventListener(
"ll-duplicate-card",
(ev: HASSDomEvent<HASSDomEvents["ll-duplicate-card"]>) => {
const path = normalizeCardPath(ev.detail.path);
if (getPathTarget(path) !== "item") {
return;
}
const cardConfig = this._getCardConfig(path);
if (!cardConfig) {
return;
}
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveCardConfig: async (newCardConfig) => {
const cardsPath = getParentPath(path);
const newConfig = appendAtPath(
this.lovelace.config,
cardsPath,
newCardConfig
);
await this.lovelace.saveConfig(newConfig);
},
sectionConfig: getCardSectionConfig(this.lovelace.config, path),
cardConfig,
isNew: true,
});
}
);
this.addEventListener(
"ll-copy-card",
(ev: HASSDomEvent<HASSDomEvents["ll-copy-card"]>) => {
if (!this.lovelace) return;
const cardConfig = this._getCardConfig(
normalizeCardPath(ev.detail.path)
);
if (!cardConfig) {
return;
}
this._clipboard = deepClone(cardConfig);
}
);
this.addEventListener(
"ll-create-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-create-badge"]>) => {
showCreateBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: ev.detail.path,
});
}
);
this.addEventListener(
"ll-edit-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-edit-badge"]>) => {
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: ev.detail.path,
});
}
);
this.addEventListener(
"ll-delete-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-delete-badge"]>) => {
if (!this.lovelace) return;
performDeleteBadge(this.hass, this.lovelace, ev.detail);
}
);
}
private _getCardConfig(path: LovelacePath): LovelaceCardConfig | undefined {
if (isStrategyView(this.lovelace.config.views[this.index])) {
return undefined;
}
return getAtPath<LovelaceCardConfig>(this.lovelace.config, path);
}
private _createCardElement(cardConfig: LovelaceCardConfig) {
const element = document.createElement("hui-card");
element.hass = this.hass;
@@ -148,7 +284,6 @@ export class HUIView extends ReactiveElement {
element.hass = this.hass;
element.lovelace = this.lovelace;
element.config = sectionConfig;
element.viewIndex = this.index;
element.preview = this.lovelace.editMode;
element.addEventListener(
"ll-rebuild",
@@ -323,6 +458,7 @@ export class HUIView extends ReactiveElement {
this._layoutElement!.narrow = this.narrow;
this._layoutElement!.lovelace = this.lovelace;
this._layoutElement!.index = this.index;
this._layoutElement!.path = this._path(this.index);
this._layoutElement!.cards = this._cards;
this._layoutElement!.badges = this._badges;
this._layoutElement!.sections = this._sections;
@@ -364,89 +500,6 @@ export class HUIView extends ReactiveElement {
},
{ once: true }
);
this._layoutElement.addEventListener("ll-create-card", (ev) => {
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
suggestedCards: ev.detail?.suggested,
});
});
this._layoutElement.addEventListener("ll-edit-card", (ev) => {
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const viewConfig = this.lovelace!.config.views[this.index];
if (isStrategyView(viewConfig)) {
return;
}
const cardConfig = viewConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = replaceCard(
this.lovelace!.config,
[this.index, cardIndex],
newCardConfig
);
await this.lovelace.saveConfig(newConfig);
},
cardConfig,
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
if (!this.lovelace) return;
performDeleteCard(this.hass, this.lovelace, ev.detail);
});
this._layoutElement.addEventListener("ll-create-badge", async () => {
showCreateBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
});
});
this._layoutElement.addEventListener("ll-edit-badge", (ev) => {
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
badgeIndex: cardIndex,
});
});
this._layoutElement.addEventListener("ll-delete-badge", async (ev) => {
if (!this.lovelace) return;
performDeleteBadge(this.hass, this.lovelace, ev.detail);
});
this._layoutElement.addEventListener("ll-duplicate-card", (ev) => {
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const viewConfig = this.lovelace!.config.views[this.index];
if (isStrategyView(viewConfig)) {
return;
}
const cardConfig = viewConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = addCard(
this.lovelace!.config,
[this.index],
newCardConfig
);
await this.lovelace!.saveConfig(newConfig);
},
cardConfig,
isNew: true,
});
});
this._layoutElement.addEventListener("ll-copy-card", (ev) => {
if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const viewConfig = this.lovelace!.config.views[this.index];
if (isStrategyView(viewConfig)) {
return;
}
const cardConfig = viewConfig.cards![cardIndex];
this._clipboard = deepClone(cardConfig);
});
}
private _createBadges(config: LovelaceViewConfig): void {
@@ -482,7 +535,7 @@ export class HUIView extends ReactiveElement {
this._sections = config.sections.map((sectionConfig, index) => {
const element = this.createSectionElement(sectionConfig);
element.index = index;
element.path = [...this._path(this.index), "sections", index];
return element;
});
}
@@ -492,7 +545,7 @@ export class HUIView extends ReactiveElement {
config: LovelaceSectionConfig
): void {
const newSectionEl = this.createSectionElement(config);
newSectionEl.index = sectionElToReplace.index;
newSectionEl.path = sectionElToReplace.path;
if (sectionElToReplace.parentElement) {
sectionElToReplace.parentElement!.replaceChild(
newSectionEl,
@@ -20,7 +20,11 @@ describe("moveCardToContainer", () => {
],
};
const result = moveCardToContainer(config, [1, 0], [0]);
const result = moveCardToContainer(
config,
["views", 1, "cards", 0],
["views", 0]
);
const expected: LovelaceConfig = {
views: [
{
@@ -46,7 +50,11 @@ describe("moveCardToContainer", () => {
],
};
const result = moveCardToContainer(config, [1, 0], [0]);
const result = moveCardToContainer(
config,
["views", 1, "cards", 0],
["views", 0]
);
const expected: LovelaceConfig = {
views: [
{
@@ -73,7 +81,7 @@ describe("moveCardToContainer", () => {
};
const result = () => {
moveCardToContainer(config, [1, 0], [1]);
moveCardToContainer(config, ["views", 1, "cards", 0], ["views", 1]);
};
assert.throws(
result,
@@ -158,7 +166,7 @@ describe("duplicateSection", () => {
],
};
const result = duplicateSection(config, 0, 0);
const result = duplicateSection(config, ["views", 0, "sections", 0]);
const expected: LovelaceConfig = {
views: [
@@ -189,7 +197,7 @@ describe("duplicateSection", () => {
],
};
const result = duplicateSection(config, 0, 0);
const result = duplicateSection(config, ["views", 0, "sections", 0]);
const view = result.views[0] as LovelaceViewConfig;
assert.equal(view.sections!.length, 2);
@@ -211,7 +219,7 @@ describe("duplicateSection", () => {
],
};
const result = duplicateSection(config, 0, 0);
const result = duplicateSection(config, ["views", 0, "sections", 0]);
const resultSections = (result.views[0] as LovelaceViewConfig).sections!;
assert.equal(resultSections.length, 2);
@@ -0,0 +1,537 @@
import { assert, describe, it } from "vitest";
import type { LovelaceCardConfig } from "../../../../src/data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
import type { LovelacePath } from "../../../../src/panels/lovelace/editor/lovelace-path";
import {
appendAtPath,
deleteAtPath,
getAtPath,
getItemKind,
getParentPath,
getViewPath,
insertAtPath,
isAncestorPath,
getPathTarget,
moveAtPath,
normalizeCardPath,
parsePath,
pathEquals,
setAtPath,
stringifyPath,
} from "../../../../src/panels/lovelace/editor/lovelace-path";
const createConfig = (): LovelaceConfig => ({
views: [
{
title: "Home",
badges: ["sensor.badge0", { type: "entity", entity: "sensor.badge1" }],
cards: [{ type: "v0-c0" }, { type: "v0-c1" }],
},
{
title: "Areas",
sections: [
{
type: "grid",
cards: [{ type: "s0-c0" }, { type: "s0-c1" }, { type: "s0-c2" }],
},
{ type: "grid", cards: [{ type: "s1-c0" }] },
{ strategy: { type: "areas" } },
],
},
{ strategy: { type: "original-states" } },
],
});
describe("stringifyPath / parsePath", () => {
it("round trips a nested card path", () => {
const path: LovelacePath = ["views", 0, "sections", 1, "cards", 2];
assert.strictEqual(stringifyPath(path), "views/0/sections/1/cards/2");
assert.deepEqual(parsePath("views/0/sections/1/cards/2"), path);
});
it("round trips a slot path", () => {
const path: LovelacePath = ["views", 0, "header", "card"];
assert.strictEqual(stringifyPath(path), "views/0/header/card");
assert.deepEqual(parsePath("views/0/header/card"), path);
});
it("parses numeric segments as numbers", () => {
const parsed = parsePath("views/12");
assert.deepEqual(parsed, ["views", 12]);
assert.strictEqual(typeof parsed[1], "number");
});
it("handles the root path", () => {
assert.strictEqual(stringifyPath([]), "");
assert.deepEqual(parsePath(""), []);
});
});
describe("path helpers", () => {
it("normalizes legacy card index tuples", () => {
assert.deepEqual(normalizeCardPath([0, 2]), ["views", 0, "cards", 2]);
assert.deepEqual(normalizeCardPath([0, 1, 2]), [
"views",
0,
"sections",
1,
"cards",
2,
]);
assert.deepEqual(normalizeCardPath(["views", 0, "cards", 2]), [
"views",
0,
"cards",
2,
]);
});
it("resolves the item kind from the last string segment", () => {
assert.strictEqual(getItemKind(["views", 0, "header", "card"]), "card");
assert.strictEqual(getItemKind(["views", 0, "cards", 1]), "card");
assert.strictEqual(getItemKind(["views", 0, "badges", 1]), "badge");
assert.strictEqual(getItemKind(["views", 0]), "view");
assert.strictEqual(
getItemKind(["views", 0, "sidebar", "sections", 0]),
"section"
);
assert.strictEqual(getItemKind(["views", 0, "header"]), undefined);
assert.strictEqual(getItemKind([]), undefined);
});
it("detects slot paths", () => {
assert.strictEqual(getPathTarget(["views", 0, "header", "card"]), "slot");
assert.strictEqual(getPathTarget(["views", 0, "cards"]), "list");
assert.strictEqual(getPathTarget(["views", 0, "cards", 1]), "item");
assert.strictEqual(getPathTarget(["views", 0, "header"]), "node");
assert.strictEqual(getPathTarget(["views", 0]), "item");
});
it("returns the parent path", () => {
assert.deepEqual(getParentPath(["views", 0, "cards", 1]), [
"views",
0,
"cards",
]);
assert.deepEqual(getParentPath([]), []);
});
it("returns the view path", () => {
assert.deepEqual(getViewPath(["views", 1, "sections", 0, "cards", 2]), [
"views",
1,
]);
assert.deepEqual(getViewPath(["views", 1]), ["views", 1]);
});
it("compares paths", () => {
assert.strictEqual(pathEquals(["views", 0], ["views", 0]), true);
assert.strictEqual(pathEquals(["views", 0], ["views", 1]), false);
assert.strictEqual(pathEquals(["views", 0], ["views", 0, "cards"]), false);
});
it("does not consider a path its own ancestor", () => {
assert.strictEqual(
isAncestorPath(["views", 0], ["views", 0, "cards", 1]),
true
);
assert.strictEqual(isAncestorPath(["views", 0], ["views", 0]), false);
assert.strictEqual(
isAncestorPath(["views", 0, "cards", 1], ["views", 0]),
false
);
assert.strictEqual(
isAncestorPath(["views", 1], ["views", 0, "cards", 1]),
false
);
});
});
describe("getAtPath", () => {
it("reads a nested card", () => {
const config = createConfig();
assert.deepEqual(
getAtPath(config, ["views", 1, "sections", 0, "cards", 1]),
{
type: "s0-c1",
}
);
});
it("returns undefined for a missing leaf", () => {
const config = createConfig();
assert.strictEqual(getAtPath(config, ["views", 0, "cards", 5]), undefined);
});
it("returns undefined for a missing intermediate", () => {
const config = createConfig();
assert.strictEqual(
getAtPath(config, ["views", 0, "sidebar", "sections", 0]),
undefined
);
});
it("throws when descending through a strategy view", () => {
const config = createConfig();
assert.throws(
() => getAtPath(config, ["views", 2, "cards"]),
"Cannot edit inside a strategy: views/2"
);
});
it("throws when descending through a strategy section", () => {
const config = createConfig();
assert.throws(
() => getAtPath(config, ["views", 1, "sections", 2, "cards"]),
"Cannot edit inside a strategy: views/1/sections/2"
);
});
it("returns a strategy view when it is the final target", () => {
const config = createConfig();
assert.deepEqual(getAtPath(config, ["views", 2]), {
strategy: { type: "original-states" },
});
});
it("throws for any non-empty path on a strategy dashboard", () => {
const config = {
strategy: { type: "original-states" },
} as unknown as LovelaceConfig;
assert.throws(
() => getAtPath(config, ["views"]),
"Cannot edit inside a strategy: "
);
assert.deepEqual(getAtPath(config, []), config);
});
});
describe("setAtPath", () => {
it("replaces a list item", () => {
const config = createConfig();
const result = setAtPath(config, ["views", 0, "cards", 1], {
type: "new-card",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c0" },
{ type: "new-card" },
]);
});
it("replaces a slot", () => {
const config: LovelaceConfig = {
views: [{ header: { card: { type: "old" } } }],
};
const result = setAtPath(config, ["views", 0, "header", "card"], {
type: "new",
});
assert.deepEqual(getAtPath(result, ["views", 0, "header"]), {
card: { type: "new" },
});
});
it("refuses to create a missing list item", () => {
const config = createConfig();
assert.throws(
() => setAtPath(config, ["views", 9, "header", "card"], { type: "x" }),
"Cannot edit missing item: views/9"
);
assert.throws(
() => setAtPath(config, ["views", 0, "cards", 5], { type: "x" }),
"Cannot edit missing item: views/0/cards/5"
);
});
it("creates a missing header object", () => {
const config = createConfig();
const result = setAtPath(config, ["views", 0, "header", "card"], {
type: "heading",
});
assert.deepEqual(getAtPath(result, ["views", 0, "header"]), {
card: { type: "heading" },
});
});
it("does not mutate the input config", () => {
const config = createConfig();
setAtPath(config, ["views", 1, "sections", 0, "cards", 0], {
type: "new-card",
});
assert.deepEqual(config, createConfig());
});
it("keeps untouched branches identical", () => {
const config = createConfig();
const result = setAtPath(config, ["views", 1, "sections", 0, "cards", 0], {
type: "new-card",
});
// Renderers key repeat() on config object identity, so untouched nodes must keep their reference.
assert.strictEqual(
getAtPath(result, ["views", 1, "sections", 0, "cards", 1]),
getAtPath(config, ["views", 1, "sections", 0, "cards", 1])
);
assert.strictEqual(
getAtPath(result, ["views", 1, "sections", 1]),
getAtPath(config, ["views", 1, "sections", 1])
);
assert.strictEqual(
getAtPath(result, ["views", 0]),
getAtPath(config, ["views", 0])
);
});
});
describe("insertAtPath", () => {
it("splices in the middle", () => {
const config = createConfig();
const result = insertAtPath(config, ["views", 0, "cards", 1], {
type: "inserted",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c0" },
{ type: "inserted" },
{ type: "v0-c1" },
]);
});
it("clamps an index beyond the length to the end", () => {
const config = createConfig();
const result = insertAtPath(config, ["views", 0, "cards", 99], {
type: "inserted",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c0" },
{ type: "v0-c1" },
{ type: "inserted" },
]);
});
it("clamps a negative index to the start", () => {
const config = createConfig();
const result = insertAtPath(config, ["views", 0, "cards", -1], {
type: "inserted",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "inserted" },
{ type: "v0-c0" },
{ type: "v0-c1" },
]);
});
it("creates a missing cards list", () => {
const config: LovelaceConfig = { views: [{ title: "Empty" }] };
const result = insertAtPath(config, ["views", 0, "cards", 0], {
type: "first",
});
assert.deepEqual(result, {
views: [{ title: "Empty", cards: [{ type: "first" }] }],
});
});
it("behaves like set when the last segment is a string", () => {
const config = createConfig();
const card: LovelaceCardConfig = { type: "heading" };
assert.deepEqual(
insertAtPath(config, ["views", 0, "header", "card"], card),
setAtPath(config, ["views", 0, "header", "card"], card)
);
});
});
describe("appendAtPath", () => {
it("appends to an existing list", () => {
const config = createConfig();
const result = appendAtPath(config, ["views", 0, "cards"], {
type: "appended",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c0" },
{ type: "v0-c1" },
{ type: "appended" },
]);
});
it("creates a missing collection", () => {
const config: LovelaceConfig = { views: [{ title: "Empty" }] };
const result = appendAtPath(config, ["views", 0, "sidebar", "sections"], {
type: "grid",
});
assert.deepEqual(result, {
views: [{ title: "Empty", sidebar: { sections: [{ type: "grid" }] } }],
});
});
});
describe("deleteAtPath", () => {
it("removes a list item", () => {
const config = createConfig();
const result = deleteAtPath(config, ["views", 0, "cards", 0]);
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c1" },
]);
});
it("keeps an emptied list", () => {
const config = createConfig();
const result = deleteAtPath(config, [
"views",
1,
"sections",
1,
"cards",
0,
]);
assert.deepEqual(getAtPath(result, ["views", 1, "sections", 1]), {
type: "grid",
cards: [],
});
});
it("removes a slot key", () => {
const config: LovelaceConfig = {
views: [{ header: { layout: "start", card: { type: "old" } } }],
};
const result = deleteAtPath(config, ["views", 0, "header", "card"]);
assert.deepEqual(getAtPath(result, ["views", 0, "header"]), {
layout: "start",
});
});
it("returns the same config when the target is missing", () => {
const config = createConfig();
assert.strictEqual(deleteAtPath(config, ["views", 0, "cards", 9]), config);
assert.strictEqual(deleteAtPath(config, []), config);
});
});
describe("moveAtPath", () => {
it("moves forward inside the same list", () => {
const config: LovelaceConfig = {
views: [{ cards: [{ type: "a" }, { type: "b" }, { type: "c" }] }],
};
const result = moveAtPath(
config,
["views", 0, "cards", 0],
["views", 0, "cards", 2]
);
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "b" },
{ type: "c" },
{ type: "a" },
]);
});
it("moves backward inside the same list", () => {
const config: LovelaceConfig = {
views: [{ cards: [{ type: "a" }, { type: "b" }, { type: "c" }] }],
};
const result = moveAtPath(
config,
["views", 0, "cards", 2],
["views", 0, "cards", 0]
);
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "c" },
{ type: "a" },
{ type: "b" },
]);
});
it("moves a card between two sections", () => {
const config = createConfig();
const result = moveAtPath(
config,
["views", 1, "sections", 0, "cards", 0],
["views", 1, "sections", 1, "cards", 0]
);
assert.deepEqual(getAtPath(result, ["views", 1, "sections", 0, "cards"]), [
{ type: "s0-c1" },
{ type: "s0-c2" },
]);
assert.deepEqual(getAtPath(result, ["views", 1, "sections", 1, "cards"]), [
{ type: "s0-c0" },
{ type: "s1-c0" },
]);
});
it("moves a view card into the header slot", () => {
const config = createConfig();
const result = moveAtPath(
config,
["views", 0, "cards", 0],
["views", 0, "header", "card"]
);
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c1" },
]);
assert.deepEqual(getAtPath(result, ["views", 0, "header"]), {
card: { type: "v0-c0" },
});
});
it("targets the shifted sibling when moving into a later sibling", () => {
const config: LovelaceConfig = {
views: [
{
sections: [
{ type: "grid", cards: [{ type: "s0-c0" }] },
{ type: "grid", cards: [{ type: "s1-c0" }] },
{ type: "grid", cards: [{ type: "s2-c0" }] },
],
},
],
};
const result = moveAtPath(
config,
["views", 0, "sections", 0],
["views", 0, "sections", 2, "cards", 0]
);
assert.deepEqual(result, {
views: [
{
sections: [
{ type: "grid", cards: [{ type: "s1-c0" }] },
{
type: "grid",
cards: [
{ type: "grid", cards: [{ type: "s0-c0" }] },
{ type: "s2-c0" },
],
},
],
},
],
});
});
it("returns the same config when source and target are equal", () => {
const config = createConfig();
assert.strictEqual(
moveAtPath(config, ["views", 0, "cards", 0], ["views", 0, "cards", 0]),
config
);
});
it("throws when moving into its own descendant", () => {
const config = createConfig();
assert.throws(
() =>
moveAtPath(
config,
["views", 1, "sections", 0],
["views", 1, "sections", 0, "cards", 0]
),
"Cannot move views/1/sections/0 into itself: views/1/sections/0/cards/0"
);
});
it("throws when the source does not exist", () => {
const config = createConfig();
assert.throws(
() =>
moveAtPath(config, ["views", 0, "cards", 9], ["views", 0, "cards", 0]),
"Nothing to move at views/0/cards/9"
);
});
});