Add experimental sections view (#19846)

This commit is contained in:
Paul Bottein 2024-02-22 20:51:48 +01:00 committed by GitHub
parent 47f7cf5419
commit d95bf64edf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2423 additions and 484 deletions

View File

@ -20,14 +20,14 @@ function findNestedItem(
}, obj);
}
export function nestedArrayMove<T>(
obj: T | T[],
export function nestedArrayMove<A>(
obj: A,
oldIndex: number,
newIndex: number,
oldPath?: ItemPath,
newPath?: ItemPath
): T | T[] {
const newObj = Array.isArray(obj) ? [...obj] : { ...obj };
): A {
const newObj = (Array.isArray(obj) ? [...obj] : { ...obj }) as A;
const from = oldPath ? findNestedItem(newObj, oldPath) : newObj;
const to = newPath ? findNestedItem(newObj, newPath, true) : newObj;

View File

@ -14,9 +14,16 @@ declare global {
oldPath?: ItemPath;
newPath?: ItemPath;
};
"drag-start": undefined;
"drag-end": undefined;
}
}
export type HaSortableOptions = Omit<
SortableInstance.SortableOptions,
"onStart" | "onChoose" | "onEnd"
>;
@customElement("ha-sortable")
export class HaSortable extends LitElement {
private _sortable?: SortableInstance;
@ -36,14 +43,17 @@ export class HaSortable extends LitElement {
@property({ type: String, attribute: "handle-selector" })
public handleSelector?: string;
@property({ type: String, attribute: "group" })
public group?: string;
@property({ type: Number, attribute: "swap-threshold" })
public swapThreshold?: number;
@property({ type: String })
public group?: string | SortableInstance.GroupOptions;
@property({ type: Boolean, attribute: "invert-swap" })
public invertSwap?: boolean;
public invertSwap: boolean = false;
@property({ attribute: false })
public options?: HaSortableOptions;
@property({ type: Boolean })
public rollback: boolean = true;
protected updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("disabled")) {
@ -114,26 +124,20 @@ export class HaSortable extends LitElement {
const options: SortableInstance.Options = {
animation: 150,
swapThreshold: 1,
...this.options,
onChoose: this._handleChoose,
onStart: this._handleStart,
onEnd: this._handleEnd,
};
if (this.draggableSelector) {
options.draggable = this.draggableSelector;
}
if (this.swapThreshold !== undefined) {
options.swapThreshold = this.swapThreshold;
}
if (this.invertSwap !== undefined) {
options.invertSwap = this.invertSwap;
}
if (this.handleSelector) {
options.handle = this.handleSelector;
}
if (this.draggableSelector) {
options.draggable = this.draggableSelector;
if (this.invertSwap !== undefined) {
options.invertSwap = this.invertSwap;
}
if (this.group) {
options.group = this.group;
@ -143,8 +147,9 @@ export class HaSortable extends LitElement {
}
private _handleEnd = async (evt: SortableEvent) => {
fireEvent(this, "drag-end");
// put back in original location
if ((evt.item as any).placeholder) {
if (this.rollback && (evt.item as any).placeholder) {
(evt.item as any).placeholder.replaceWith(evt.item);
delete (evt.item as any).placeholder;
}
@ -170,7 +175,12 @@ export class HaSortable extends LitElement {
});
};
private _handleStart = () => {
fireEvent(this, "drag-start");
};
private _handleChoose = (evt: SortableEvent) => {
if (!this.rollback) return;
(evt.item as any).placeholder = document.createComment("sort-placeholder");
evt.item.after((evt.item as any).placeholder);
};

View File

@ -10,8 +10,10 @@ import {
LovelaceCard,
} from "../panels/lovelace/types";
import { HomeAssistant } from "../types";
import { LovelaceSectionConfig } from "./lovelace/config/section";
import { fetchConfig, LegacyLovelaceConfig } from "./lovelace/config/types";
import { LovelaceViewConfig } from "./lovelace/config/view";
import { HuiSection } from "../panels/lovelace/sections/hui-section";
export interface LovelacePanelConfig {
mode: "yaml" | "storage";
@ -24,10 +26,21 @@ export interface LovelaceViewElement extends HTMLElement {
index?: number;
cards?: Array<LovelaceCard | HuiErrorCard>;
badges?: LovelaceBadge[];
sections?: HuiSection[];
isStrategy: boolean;
setConfig(config: LovelaceViewConfig): void;
}
export interface LovelaceSectionElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
viewIndex?: number;
index?: number;
cards?: Array<LovelaceCard | HuiErrorCard>;
isStrategy: boolean;
setConfig(config: LovelaceSectionConfig): void;
}
type LovelaceUpdatedEvent = HassEventBase & {
event_type: "lovelace_updated";
data: {

View File

@ -0,0 +1,26 @@
import type { LovelaceCardConfig } from "./card";
import type { LovelaceStrategyConfig } from "./strategy";
export interface LovelaceBaseSectionConfig {
title?: string;
}
export interface LovelaceSectionConfig extends LovelaceBaseSectionConfig {
type?: string;
cards?: LovelaceCardConfig[];
}
export interface LovelaceStrategySectionConfig
extends LovelaceBaseSectionConfig {
strategy: LovelaceStrategyConfig;
}
export type LovelaceSectionRawConfig =
| LovelaceSectionConfig
| LovelaceStrategySectionConfig;
export function isStrategySection(
section: LovelaceSectionRawConfig
): section is LovelaceStrategySectionConfig {
return "strategy" in section;
}

View File

@ -1,5 +1,6 @@
import type { LovelaceBadgeConfig } from "./badge";
import type { LovelaceCardConfig } from "./card";
import type { LovelaceSectionRawConfig } from "./section";
import type { LovelaceStrategyConfig } from "./strategy";
export interface ShowViewConfig {
@ -23,6 +24,7 @@ export interface LovelaceViewConfig extends LovelaceBaseViewConfig {
type?: string;
badges?: Array<string | LovelaceBadgeConfig>;
cards?: LovelaceCardConfig[];
sections?: LovelaceSectionRawConfig[];
}
export interface LovelaceStrategyViewConfig extends LovelaceBaseViewConfig {

View File

@ -27,7 +27,10 @@ import { addEntitiesToLovelaceView } from "../../../lovelace/editor/add-entities
import type { LovelaceRowConfig } from "../../../lovelace/entity-rows/types";
import { LovelaceRow } from "../../../lovelace/entity-rows/types";
import { EntityRegistryStateEntry } from "../ha-config-device-page";
import { computeCards } from "../../../lovelace/common/generate-lovelace-config";
import {
computeCards,
computeSection,
} from "../../../lovelace/common/generate-lovelace-config";
@customElement("ha-device-entities-card")
export class HaDeviceEntitiesCard extends LitElement {
@ -235,6 +238,9 @@ export class HaDeviceEntitiesCard extends LitElement {
computeCards(this.hass.states, entities, {
title: this.deviceName,
}),
computeSection(entities, {
title: this.deviceName,
}),
entities
);
}

View File

@ -147,6 +147,16 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
);
}
public getGridSize(): [number, number] {
if (
(this._config?.show_icon && this._config?.show_name) ||
this._config?.show_state
) {
return [2, 2];
}
return [1, 1];
}
public setConfig(config: ButtonCardConfig): void {
if (config.entity && !isValidEntityId(config.entity)) {
throw new Error("Invalid entity");

View File

@ -72,6 +72,10 @@ class HuiSensorCard extends HuiEntityCard {
super.setConfig(entityCardConfig);
}
public getSize(): [number, number] {
return [2, 2];
}
static get styles(): CSSResultGroup {
return [
HuiEntityCard.styles,

View File

@ -124,6 +124,18 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
);
}
public getGridSize(): [number, number] {
const width = 2;
let height = 1;
if (this._config?.features?.length) {
height += Math.ceil((this._config.features.length * 2) / 3);
}
if (this._config?.vertical) {
height++;
}
return [width, height];
}
private _handleAction(ev: ActionHandlerEvent) {
handleAction(this, this.hass!, this._config!, ev.detail.action!);
}
@ -441,12 +453,16 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.secondary=${localizedState}
></ha-tile-info>
</div>
<hui-card-features
.hass=${this.hass}
.stateObj=${stateObj}
.color=${this._config.color}
.features=${this._config.features}
></hui-card-features>
${this._config.features
? html`
<hui-card-features
.hass=${this.hass}
.stateObj=${stateObj}
.color=${this._config.color}
.features=${this._config.features}
></hui-card-features>
`
: nothing}
</ha-card>
`;
}
@ -469,6 +485,9 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
transition:
box-shadow 180ms ease-in-out,
border-color 180ms ease-in-out;
display: flex;
flex-direction: column;
justify-content: space-between;
}
ha-card.active {
--tile-color: var(--state-icon-color);

View File

@ -545,7 +545,7 @@ export interface TileCardConfig extends LovelaceCardConfig {
state_content?: string | string[];
icon?: string;
color?: string;
show_entity_picture?: string;
show_entity_picture?: boolean;
vertical?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;

View File

@ -8,12 +8,14 @@ import { stripPrefixFromEntityName } from "../../../common/entity/strip_prefix_f
import { stringCompare } from "../../../common/string/compare";
import { LocalizeFunc } from "../../../common/translations/localize";
import type { AreaFilterValue } from "../../../components/ha-area-filter";
import { areaCompare } from "../../../data/area_registry";
import {
EnergyPreferences,
GridSourceTypeEnergyPreference,
} from "../../../data/energy";
import { domainToName } from "../../../data/integration";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { computeUserInitials } from "../../../data/user";
import { HomeAssistant } from "../../../types";
@ -25,10 +27,10 @@ import {
PictureCardConfig,
PictureEntityCardConfig,
ThermostatCardConfig,
TileCardConfig,
} from "../cards/types";
import { EntityConfig } from "../entity-rows/types";
import { ButtonsHeaderFooterConfig } from "../header-footer/types";
import { areaCompare } from "../../../data/area_registry";
const HIDE_DOMAIN = new Set([
"automation",
@ -100,6 +102,24 @@ const splitByAreaDevice = (
};
};
export const computeSection = (
entityIds: string[],
sectionOptions?: Partial<LovelaceSectionConfig>
): LovelaceSectionConfig => ({
type: "grid",
cards: entityIds.map(
(entity) =>
({
type: "tile",
entity,
show_entity_picture: ["person", "camera", "image"].includes(
computeDomain(entity)
),
}) as TileCardConfig
),
...sectionOptions,
});
export const computeCards = (
states: HassEntities,
entityIds: string[],

View File

@ -0,0 +1,303 @@
import "@material/mwc-button";
import { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import {
mdiContentCopy,
mdiContentCut,
mdiContentDuplicate,
mdiDelete,
mdiDotsVertical,
mdiPencil,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button-menu";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import {
LovelaceCardPath,
findLovelaceCards,
getLovelaceContainerPath,
parseLovelaceCardPath,
} from "../editor/lovelace-path";
import { Lovelace } from "../types";
@customElement("hui-card-edit-mode")
export class HuiCardEditMode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ type: Array }) public path!: LovelaceCardPath;
@property({ type: Boolean }) public hiddenOverlay = false;
@state()
public _menuOpened: boolean = false;
@state()
public _hover: boolean = false;
@state()
public _focused: boolean = false;
@storage({
key: "lovelaceClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: LovelaceCardConfig;
private get _cards() {
const containerPath = getLovelaceContainerPath(this.path!);
return findLovelaceCards(this.lovelace!.config, containerPath)!;
}
private _touchStarted = false;
protected firstUpdated(): void {
this.addEventListener("focus", () => {
this._focused = true;
});
this.addEventListener("blur", () => {
this._focused = false;
});
this.addEventListener("touchstart", () => {
this._touchStarted = true;
});
this.addEventListener("touchend", () => {
setTimeout(() => {
this._touchStarted = false;
}, 10);
});
this.addEventListener("mouseenter", () => {
if (this._touchStarted) return;
this._hover = true;
});
this.addEventListener("mouseout", () => {
this._hover = false;
});
this.addEventListener("click", () => {
this._hover = true;
document.addEventListener("click", this._documentClicked);
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
document.removeEventListener("click", this._documentClicked);
}
_documentClicked = (ev) => {
this._hover = ev.composedPath().includes(this);
document.removeEventListener("click", this._documentClicked);
};
protected render(): TemplateResult {
const showOverlay =
(this._hover || this._menuOpened || this._focused) && !this.hiddenOverlay;
return html`
<div class="card-wrapper" inert><slot></slot></div>
<div class="card-overlay ${classMap({ visible: showOverlay })}">
<div
class="edit"
@click=${this._editCard}
@keydown=${this._editCard}
tabindex="0"
>
<div class="edit-overlay"></div>
<ha-svg-icon class="edit" .path=${mdiPencil}> </ha-svg-icon>
</div>
<ha-button-menu
class="more"
corner="BOTTOM_END"
menuCorner="END"
.path=${[this.path!]}
@action=${this._handleAction}
@opened=${this._handleOpened}
@closed=${this._handleClosed}
>
<ha-icon-button slot="trigger" .path=${mdiDotsVertical}>
</ha-icon-button>
<ha-list-item graphic="icon">
<ha-svg-icon
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.duplicate"
)}
</ha-list-item>
<ha-list-item graphic="icon">
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize("ui.panel.lovelace.editor.edit_card.copy")}
</ha-list-item>
<ha-list-item graphic="icon">
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
${this.hass.localize("ui.panel.lovelace.editor.edit_card.cut")}
</ha-list-item>
<li divider role="separator"></li>
<ha-list-item graphic="icon" class="warning">
${this.hass.localize("ui.panel.lovelace.editor.edit_card.delete")}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
</div>
`;
}
private _handleOpened() {
this._menuOpened = true;
}
private _handleClosed() {
this._menuOpened = false;
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._duplicateCard();
break;
case 1:
this._copyCard();
break;
case 2:
this._cutCard();
break;
case 3:
this._deleteCard(true);
break;
}
}
private _duplicateCard(): void {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const containerPath = getLovelaceContainerPath(this.path!);
const cardConfig = this._cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: containerPath,
cardConfig,
});
}
private _editCard(ev): void {
if (ev.defaultPrevented) {
return;
}
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
return;
}
ev.preventDefault();
ev.stopPropagation();
fireEvent(this, "ll-edit-card", { path: this.path! });
}
private _cutCard(): void {
this._copyCard();
this._deleteCard(false);
}
private _copyCard(): void {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const cardConfig = this._cards[cardIndex];
this._clipboard = deepClone(cardConfig);
}
private _deleteCard(confirm: boolean): void {
fireEvent(this, "ll-delete-card", { path: this.path!, confirm });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.card-overlay {
position: absolute;
opacity: 0;
pointer-events: none;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 180ms ease-in-out;
}
.card-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.card-wrapper {
position: relative;
height: 100%;
z-index: 0;
}
.edit {
outline: none !important;
cursor: pointer;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--ha-card-border-radius, 12px);
z-index: 0;
}
.edit-overlay {
position: absolute;
inset: 0;
opacity: 0.8;
background-color: var(--primary-background-color);
border: 1px solid var(--divider-color);
border-radius: var(--ha-card-border-radius, 12px);
z-index: 0;
}
.edit ha-svg-icon {
display: flex;
position: relative;
color: var(--primary-text-color);
border-radius: 50%;
padding: 12px;
background: var(--secondary-background-color);
--mdc-icon-size: 24px;
}
.more {
position: absolute;
right: -6px;
top: -6px;
}
.more ha-icon-button {
cursor: pointer;
border-radius: 50%;
background: var(--secondary-background-color);
--mdc-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-card-edit-mode": HuiCardEditMode;
}
}

View File

@ -28,7 +28,6 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { saveConfig } from "../../../data/lovelace/config/types";
import { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import {
showAlertDialog,
showPromptDialog,
@ -41,10 +40,15 @@ import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"
import {
addCard,
deleteCard,
moveCard,
moveCardToPosition,
swapCard,
moveCardToContainer,
moveCardToIndex,
} from "../editor/config-util";
import {
LovelaceCardPath,
findLovelaceCards,
getLovelaceContainerPath,
parseLovelaceCardPath,
} from "../editor/lovelace-path";
import { showSelectViewDialog } from "../editor/select-view/show-select-view-dialog";
import { Lovelace, LovelaceCard } from "../types";
@ -54,7 +58,7 @@ export class HuiCardOptions extends LitElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Array }) public path?: [number, number];
@property({ type: Array }) public path?: LovelaceCardPath;
@queryAssignedNodes() private _assignedNodes?: NodeListOf<LovelaceCard>;
@ -76,17 +80,21 @@ export class HuiCardOptions extends LitElement {
if (!changedProps.has("path") || !this.path) {
return;
}
const { viewIndex } = parseLovelaceCardPath(this.path);
this.classList.toggle(
"panel",
this.lovelace!.config.views[this.path![0]].panel
this.lovelace!.config.views[viewIndex].panel
);
}
private get _currentView() {
return this.lovelace!.config.views[this.path![0]] as LovelaceViewConfig;
private get _cards() {
const containerPath = getLovelaceContainerPath(this.path!);
return findLovelaceCards(this.lovelace!.config, containerPath)!;
}
protected render(): TemplateResult {
const { cardIndex } = parseLovelaceCardPath(this.path!);
return html`
<div class="card"><slot></slot></div>
<ha-card>
@ -107,7 +115,7 @@ export class HuiCardOptions extends LitElement {
.path=${mdiMinus}
class="move-arrow"
@click=${this._decreaseCardPosiion}
?disabled=${this.path![1] === 0}
?disabled=${cardIndex === 0}
></ha-icon-button>
<ha-icon-button
@click=${this._changeCardPosition}
@ -115,7 +123,7 @@ export class HuiCardOptions extends LitElement {
"ui.panel.lovelace.editor.edit_card.change_position"
)}
>
<div class="position-badge">${this.path![1] + 1}</div>
<div class="position-badge">${cardIndex + 1}</div>
</ha-icon-button>
<ha-icon-button
.label=${this.hass!.localize(
@ -124,8 +132,7 @@ export class HuiCardOptions extends LitElement {
.path=${mdiPlus}
class="move-arrow"
@click=${this._increaseCardPosition}
.disabled=${this._currentView.cards!.length ===
this.path![1] + 1}
.disabled=${this._cards!.length === cardIndex + 1}
></ha-icon-button>
`
: nothing}
@ -271,13 +278,14 @@ export class HuiCardOptions extends LitElement {
}
private _duplicateCard(): void {
const path = this.path!;
const cardConfig = this._currentView.cards![path[1]];
const { cardIndex } = parseLovelaceCardPath(this.path!);
const containerPath = getLovelaceContainerPath(this.path!);
const cardConfig = this._cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: [path[0], null],
newCardConfig: cardConfig,
path: containerPath,
cardConfig,
});
}
@ -291,30 +299,29 @@ export class HuiCardOptions extends LitElement {
}
private _copyCard(): void {
const cardConfig = this._currentView.cards![this.path![1]];
const { cardIndex } = parseLovelaceCardPath(this.path!);
const cardConfig = this._cards[cardIndex];
this._clipboard = deepClone(cardConfig);
}
private _decreaseCardPosiion(): void {
const lovelace = this.lovelace!;
const path = this.path!;
lovelace.saveConfig(
swapCard(lovelace.config, path, [path[0], path[1] - 1])
);
const { cardIndex } = parseLovelaceCardPath(path);
lovelace.saveConfig(moveCardToIndex(lovelace.config, path, cardIndex - 1));
}
private _increaseCardPosition(): void {
const lovelace = this.lovelace!;
const path = this.path!;
lovelace.saveConfig(
swapCard(lovelace.config, path, [path[0], path[1] + 1])
);
const { cardIndex } = parseLovelaceCardPath(path);
lovelace.saveConfig(moveCardToIndex(lovelace.config, path, cardIndex + 1));
}
private async _changeCardPosition(): Promise<void> {
const lovelace = this.lovelace!;
const path = this.path!;
const { cardIndex } = parseLovelaceCardPath(path);
const positionString = await showPromptDialog(this, {
title: this.hass!.localize(
"ui.panel.lovelace.editor.change_position.title"
@ -324,7 +331,7 @@ export class HuiCardOptions extends LitElement {
),
inputType: "number",
inputMin: "1",
placeholder: String(path[1] + 1),
placeholder: String(cardIndex + 1),
});
if (!positionString) return;
@ -333,7 +340,8 @@ export class HuiCardOptions extends LitElement {
if (isNaN(position)) return;
lovelace.saveConfig(moveCardToPosition(lovelace.config, path, position));
const newIndex = position - 1;
lovelace.saveConfig(moveCardToIndex(lovelace.config, path, newIndex));
}
private _moveCard(): void {
@ -345,20 +353,17 @@ export class HuiCardOptions extends LitElement {
viewSelectedCallback: async (urlPath, selectedDashConfig, viewIndex) => {
if (urlPath === this.lovelace!.urlPath) {
this.lovelace!.saveConfig(
moveCard(this.lovelace!.config, this.path!, [viewIndex])
moveCardToContainer(this.lovelace!.config, this.path!, [viewIndex])
);
showSaveSuccessToast(this, this.hass!);
return;
}
try {
const { cardIndex } = parseLovelaceCardPath(this.path!);
await saveConfig(
this.hass!,
urlPath,
addCard(
selectedDashConfig,
[viewIndex],
this._currentView.cards![this.path![1]]
)
addCard(selectedDashConfig, [viewIndex], this._cards[cardIndex])
);
this.lovelace!.saveConfig(
deleteCard(this.lovelace!.config, this.path!)

View File

@ -24,6 +24,7 @@ const ALWAYS_LOADED_TYPES = new Set([
"entity-button",
"glance",
"grid",
"section",
"light",
"sensor",
"thermostat",

View File

@ -1,27 +1,31 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { LovelaceViewElement } from "../../../data/lovelace";
import {
LovelaceSectionElement,
LovelaceViewElement,
} from "../../../data/lovelace";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import {
isCustomType,
stripCustomPrefix,
} from "../../../data/lovelace_custom_cards";
import { LovelaceCardFeatureConfig } from "../card-features/types";
import type { HuiErrorCard } from "../cards/hui-error-card";
import type { ErrorCardConfig } from "../cards/types";
import { LovelaceElement, LovelaceElementConfig } from "../elements/types";
import { LovelaceRow, LovelaceRowConfig } from "../entity-rows/types";
import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { LovelaceCardFeatureConfig } from "../card-features/types";
import {
LovelaceBadge,
LovelaceCard,
LovelaceCardConstructor,
LovelaceCardFeature,
LovelaceCardFeatureConstructor,
LovelaceHeaderFooter,
LovelaceHeaderFooterConstructor,
LovelaceRowConstructor,
LovelaceCardFeature,
LovelaceCardFeatureConstructor,
} from "../types";
const TIMEOUT = 2000;
@ -62,6 +66,11 @@ interface CreateElementConfigTypes {
element: LovelaceCardFeature;
constructor: LovelaceCardFeatureConstructor;
};
section: {
config: LovelaceSectionConfig;
element: LovelaceSectionElement;
constructor: unknown;
};
}
export const createErrorCardElement = (config: ErrorCardConfig) => {

View File

@ -0,0 +1,19 @@
import { LovelaceSectionElement } from "../../../data/lovelace";
import { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import { HuiErrorCard } from "../cards/hui-error-card";
import "../sections/hui-grid-section";
import { createLovelaceElement } from "./create-element-base";
const ALWAYS_LOADED_LAYOUTS = new Set(["grid"]);
const LAZY_LOAD_LAYOUTS = {};
export const createSectionElement = (
config: LovelaceSectionConfig
): LovelaceSectionElement | HuiErrorCard =>
createLovelaceElement(
"section",
config,
ALWAYS_LOADED_LAYOUTS,
LAZY_LOAD_LAYOUTS
);

View File

@ -9,6 +9,7 @@ const ALWAYS_LOADED_LAYOUTS = new Set(["masonry"]);
const LAZY_LOAD_LAYOUTS = {
panel: () => import("../views/hui-panel-view"),
sidebar: () => import("../views/hui-sidebar-view"),
sections: () => import("../views/hui-sections-view"),
};
export const createViewElement = (

View File

@ -1,5 +1,6 @@
import { LovelacePanelConfig } from "../../../data/lovelace";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import {
LovelaceConfig,
fetchConfig,
@ -15,6 +16,7 @@ export const addEntitiesToLovelaceView = async (
element: HTMLElement,
hass: HomeAssistant,
cardConfig: LovelaceCardConfig[],
sectionConfig?: LovelaceSectionConfig,
entities?: string[]
) => {
hass.loadFragmentTranslation("lovelace");
@ -71,6 +73,7 @@ export const addEntitiesToLovelaceView = async (
// all storage dashboards are generated, but we have YAML dashboards just show the YAML config
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
entities,
yaml: true,
});
@ -93,6 +96,7 @@ export const addEntitiesToLovelaceView = async (
if (!storageDashs.length && lovelaceConfig.views.length === 1) {
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
lovelaceConfig: lovelaceConfig!,
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
try {
@ -116,6 +120,7 @@ export const addEntitiesToLovelaceView = async (
viewSelectedCallback: (newUrlPath, selectedDashConfig, viewIndex) => {
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
lovelaceConfig: selectedDashConfig,
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
try {

View File

@ -15,6 +15,7 @@ import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-circular-progress";
import "../../../../components/search-input";
import { isUnavailableState } from "../../../../data/entity";
@ -46,6 +47,8 @@ interface CardElement {
export class HuiCardPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public suggestedCards?: string[];
@storage({
key: "lovelaceClipboard",
state: true,
@ -92,6 +95,29 @@ export class HuiCardPicker extends LitElement {
}
);
private _suggestedCards = memoizeOne(
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) => cardElement.card.isSuggested
)
);
private _customCards = memoizeOne(
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) =>
cardElement.card.isCustom && !cardElement.card.isSuggested
)
);
private _otherCards = memoizeOne(
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) =>
!cardElement.card.isSuggested && !cardElement.card.isCustom
)
);
protected render() {
if (
!this.hass ||
@ -102,6 +128,10 @@ export class HuiCardPicker extends LitElement {
return nothing;
}
const suggestedCards = this._suggestedCards(this._cards);
const othersCards = this._otherCards(this._cards);
const customCardsItems = this._customCards(this._cards);
return html`
<search-input
.hass=${this.hass}
@ -119,39 +149,49 @@ export class HuiCardPicker extends LitElement {
})}
>
<div class="cards-container">
${this._clipboard && !this._filter
? html`
${until(
this._renderCardElement(
{
type: this._clipboard.type,
showElement: true,
isCustom: false,
name: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste"
),
description: `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste_description",
{
type: this._clipboard.type,
}
)}`,
},
this._clipboard
),
html`
<div class="card spinner">
<ha-circular-progress
indeterminate
></ha-circular-progress>
</div>
`
${this._filter
? this._filterCards(this._cards, this._filter).map(
(cardElement: CardElement) => cardElement.element
)
: html`
${suggestedCards.length > 0
? html`
<div class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.suggested_cards`
)}
</div>
`
: nothing}
${this._renderClipboardCard()}
${suggestedCards.map(
(cardElement: CardElement) => cardElement.element
)}
`
: nothing}
${this._filterCards(this._cards, this._filter).map(
(cardElement: CardElement) => cardElement.element
)}
${suggestedCards.length > 0
? html`
<div class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.other_cards`
)}
</div>
`
: nothing}
${othersCards.map(
(cardElement: CardElement) => cardElement.element
)}
${customCardsItems.length > 0
? html`
<div class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.custom_cards`
)}
</div>
`
: nothing}
${customCardsItems.map(
(cardElement: CardElement) => cardElement.element
)}
`}
</div>
<div class="cards-container">
<div
@ -218,8 +258,24 @@ export class HuiCardPicker extends LitElement {
description: this.hass!.localize(
`ui.panel.lovelace.editor.card.${card.type}.description`
),
isSuggested: this.suggestedCards?.includes(card.type) || false,
...card,
}));
cards = cards.sort((a, b) => {
if (a.isSuggested && !b.isSuggested) {
return -1;
}
if (!a.isSuggested && b.isSuggested) {
return 1;
}
return stringCompare(
a.name || a.type,
b.name || b.type,
this.hass?.language
);
});
if (customCards.length > 0) {
cards = cards.concat(
customCards.map((ccard: CustomCardEntry) => ({
@ -244,6 +300,37 @@ export class HuiCardPicker extends LitElement {
}));
}
private _renderClipboardCard() {
if (!this._clipboard) {
return nothing;
}
return html` ${until(
this._renderCardElement(
{
type: this._clipboard.type,
showElement: true,
isCustom: false,
name: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste"
),
description: `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste_description",
{
type: this._clipboard.type,
}
)}`,
},
this._clipboard
),
html`
<div class="card spinner">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>
`
)}`;
}
private _handleSearchChange(ev: CustomEvent) {
const value = ev.detail.value;
@ -381,6 +468,14 @@ export class HuiCardPicker extends LitElement {
margin: var(--card-picker-search-margin);
}
.cards-container-header {
font-size: 16px;
font-weight: 500;
padding: 12px 8px 4px 8px;
margin: 0;
grid-column: 1 / -1;
}
.cards-container {
display: grid;
grid-gap: 8px 8px;
@ -455,6 +550,23 @@ export class HuiCardPicker extends LitElement {
.manual {
max-width: none;
}
.icon {
position: absolute;
top: 8px;
right: 8px
inset-inline-start: 8px;
inset-inline-end: 8px;
border-radius: 50%;
--mdc-icon-size: 16px;
line-height: 16px;
box-sizing: border-box;
color: var(--text-primary-color);
padding: 4px;
}
.icon.custom {
background: var(--warning-color);
}
`,
];
}

View File

@ -12,16 +12,27 @@ import { computeStateName } from "../../../../common/entity/compute_state_name";
import { DataTableRowData } from "../../../../components/data-table/ha-data-table";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import {
isStrategySection,
LovelaceSectionConfig,
} from "../../../../data/lovelace/config/section";
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 {
computeCards,
computeSection,
} from "../../common/generate-lovelace-config";
import "./hui-card-picker";
import "./hui-entity-picker-table";
import { CreateCardDialogParams } from "./show-create-card-dialog";
import { showEditCardDialog } from "./show-edit-card-dialog";
import { showSuggestCardDialog } from "./show-suggest-card-dialog";
import { computeCards } from "../../common/generate-lovelace-config";
import {
findLovelaceContainer,
parseLovelaceContainerPath,
} from "../lovelace-path";
declare global {
interface HASSDomEvents {
@ -42,7 +53,9 @@ export class HuiCreateDialogCard
@state() private _params?: CreateCardDialogParams;
@state() private _viewConfig!: LovelaceViewConfig;
@state() private _containerConfig!:
| LovelaceViewConfig
| LovelaceSectionConfig;
@state() private _selectedEntities: string[] = [];
@ -50,8 +63,17 @@ export class HuiCreateDialogCard
public async showDialog(params: CreateCardDialogParams): Promise<void> {
this._params = params;
const [view] = params.path;
this._viewConfig = params.lovelaceConfig.views[view];
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
params.path
);
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
}
this._containerConfig = containerConfig;
}
public closeDialog(): boolean {
@ -67,10 +89,10 @@ export class HuiCreateDialogCard
return nothing;
}
const title = this._viewConfig.title
const title = this._containerConfig.title
? this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.pick_card_view_title",
{ name: `"${this._viewConfig.title}"` }
"ui.panel.lovelace.editor.edit_card.pick_card_title",
{ name: `"${this._containerConfig.title}"` }
)
: this.hass!.localize("ui.panel.lovelace.editor.edit_card.pick_card");
@ -112,6 +134,7 @@ export class HuiCreateDialogCard
this._currTabIndex === 0
? html`
<hui-card-picker
.suggestedCards=${this._params.suggestedCards}
.lovelace=${this._params.lovelaceConfig}
.hass=${this.hass}
@config-changed=${this._handleCardPicked}
@ -214,8 +237,8 @@ export class HuiCreateDialogCard
showEditCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: [this._params!.path[0], null],
newCardConfig: config,
path: this._params!.path,
cardConfig: config,
});
this.closeDialog();
@ -248,12 +271,36 @@ export class HuiCreateDialogCard
this._selectedEntities,
{}
);
let sectionOptions: Partial<LovelaceSectionConfig> = {};
const { sectionIndex } = parseLovelaceContainerPath(this._params!.path);
const isSection = sectionIndex !== undefined;
// If we are in a section, we want to keep the section options for the preview
if (isSection) {
const containerConfig = findLovelaceContainer(
this._params!.lovelaceConfig!,
this._params!.path!
) as LovelaceSectionConfig;
if (!isStrategySection(containerConfig)) {
const { cards, title, ...rest } = containerConfig;
sectionOptions = rest;
}
}
const sectionConfig = computeSection(
this._selectedEntities,
sectionOptions
);
showSuggestCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: this._params!.path as [number],
entities: this._selectedEntities,
cardConfig,
sectionConfig,
});
this.closeDialog();

View File

@ -1,12 +1,12 @@
import { mdiClose, mdiHelpCircle } from "@mdi/js";
import deepFreeze from "deep-freeze";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
@ -16,6 +16,14 @@ import "../../../../components/ha-circular-progress";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import {
getCustomCardEntry,
isCustomType,
stripCustomPrefix,
} from "../../../../data/lovelace_custom_cards";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
@ -24,18 +32,12 @@ import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import { addCard, replaceCard } from "../config-util";
import { getCardDocumentationURL } from "../get-card-documentation-url";
import type { ConfigChangedEvent } from "../hui-element-editor";
import { findLovelaceContainer } from "../lovelace-path";
import type { GUIModeChangedEvent } from "../types";
import "./hui-card-element-editor";
import type { HuiCardElementEditor } from "./hui-card-element-editor";
import "./hui-card-preview";
import type { EditCardDialogParams } from "./show-edit-card-dialog";
import { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import {
getCustomCardEntry,
isCustomType,
stripCustomPrefix,
} from "../../../../data/lovelace_custom_cards";
declare global {
// for fire event
@ -61,7 +63,9 @@ export class HuiDialogEditCard
@state() private _cardConfig?: LovelaceCardConfig;
@state() private _viewConfig!: LovelaceViewConfig;
@state() private _containerConfig!:
| LovelaceViewConfig
| LovelaceSectionConfig;
@state() private _saving = false;
@ -84,18 +88,29 @@ export class HuiDialogEditCard
this._params = params;
this._GUImode = true;
this._guiModeAvailable = true;
const [view, card] = params.path;
this._viewConfig = params.lovelaceConfig.views[view];
this._cardConfig =
params.newCardConfig ??
(card !== null ? this._viewConfig.cards![card] : undefined);
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
params.path
);
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
}
this._containerConfig = containerConfig;
if ("cardConfig" in params) {
this._cardConfig = params.cardConfig;
this._dirty = true;
} else {
this._cardConfig = this._containerConfig.cards?.[params.cardIndex];
}
this.large = false;
if (this._cardConfig && !Object.isFrozen(this._cardConfig)) {
this._cardConfig = deepFreeze(this._cardConfig);
}
if (params.newCardConfig) {
this._dirty = true;
}
}
public closeDialog(): boolean {
@ -171,10 +186,10 @@ export class HuiDialogEditCard
{ type: cardName }
);
} else if (!this._cardConfig) {
heading = this._viewConfig.title
heading = this._containerConfig.title
? this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.pick_card_view_title",
{ name: this._viewConfig.title }
{ name: this._containerConfig.title }
)
: this.hass!.localize("ui.panel.lovelace.editor.edit_card.pick_card");
} else {
@ -369,13 +384,13 @@ export class HuiDialogEditCard
return;
}
this._saving = true;
const [view, card] = this._params!.path;
const path = this._params!.path;
await this._params!.saveConfig(
card === null
? addCard(this._params!.lovelaceConfig, [view], this._cardConfig!)
"cardConfig" in this._params!
? addCard(this._params!.lovelaceConfig, path, this._cardConfig!)
: replaceCard(
this._params!.lovelaceConfig,
[view, card],
[...path, this._params!.cardIndex],
this._cardConfig!
)
);

View File

@ -5,13 +5,20 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import { haStyleDialog } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import { addCards } from "../config-util";
import { addCards, addSection } from "../config-util";
import {
LovelaceContainerPath,
parseLovelaceContainerPath,
} from "../lovelace-path";
import "./hui-card-preview";
import { showCreateCardDialog } from "./show-create-card-dialog";
import { SuggestCardDialogParams } from "./show-suggest-card-dialog";
import { LovelaceConfig } from "../../../../data/lovelace/config/types";
@customElement("hui-dialog-suggest-card")
export class HuiDialogSuggestCard extends LitElement {
@ -21,6 +28,8 @@ export class HuiDialogSuggestCard extends LitElement {
@state() private _cardConfig?: LovelaceCardConfig[];
@state() private _sectionConfig?: LovelaceSectionConfig;
@state() private _saving = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
@ -28,9 +37,13 @@ export class HuiDialogSuggestCard extends LitElement {
public showDialog(params: SuggestCardDialogParams): void {
this._params = params;
this._cardConfig = params.cardConfig;
this._sectionConfig = params.sectionConfig;
if (!Object.isFrozen(this._cardConfig)) {
this._cardConfig = deepFreeze(this._cardConfig);
}
if (!Object.isFrozen(this._sectionConfig)) {
this._sectionConfig = deepFreeze(this._sectionConfig);
}
if (this._yamlEditor) {
this._yamlEditor.setValue(this._cardConfig);
}
@ -42,6 +55,45 @@ export class HuiDialogSuggestCard extends LitElement {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private get _viewSupportsSection(): boolean {
if (!this._params?.lovelaceConfig || !this._params?.path) {
return false;
}
const { viewIndex } = parseLovelaceContainerPath(this._params.path);
const viewConfig = this._params!.lovelaceConfig.views[viewIndex];
return !isStrategyView(viewConfig) && viewConfig.type === "sections";
}
private _renderPreview() {
if (this._sectionConfig && this._viewSupportsSection) {
return html`
<div class="element-preview">
<hui-section
.hass=${this.hass}
.config=${this._sectionConfig}
></hui-section>
</div>
`;
}
if (this._cardConfig) {
return html`
<div class="element-preview">
${this._cardConfig.map(
(cardConfig) => html`
<hui-card-preview
.hass=${this.hass}
.config=${cardConfig}
></hui-card-preview>
`
)}
</div>
`;
}
return nothing;
}
protected render() {
if (!this._params) {
return nothing;
@ -56,20 +108,7 @@ export class HuiDialogSuggestCard extends LitElement {
)}
>
<div>
${this._cardConfig
? html`
<div class="element-preview">
${this._cardConfig.map(
(cardConfig) => html`
<hui-card-preview
.hass=${this.hass}
.config=${cardConfig}
></hui-card-preview>
`
)}
</div>
`
: ""}
${this._renderPreview()}
${this._params.yaml && this._cardConfig
? html`
<div class="editor">
@ -79,7 +118,7 @@ export class HuiDialogSuggestCard extends LitElement {
></ha-yaml-editor>
</div>
`
: ""}
: nothing}
</div>
<mwc-button
slot="secondaryAction"
@ -146,7 +185,8 @@ export class HuiDialogSuggestCard extends LitElement {
.element-preview {
position: relative;
}
hui-card-preview {
hui-card-preview,
hui-section {
padding-top: 8px;
margin: 4px auto;
max-width: 390px;
@ -178,6 +218,32 @@ export class HuiDialogSuggestCard extends LitElement {
this.closeDialog();
}
private _computeNewConfig(
config: LovelaceConfig,
path: LovelaceContainerPath
): 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 newSection = this._sectionConfig ?? {
type: "grid",
cards: this._cardConfig,
};
return addSection(config, viewIndex, newSection);
}
// Else add cards to section
const newCards = this._sectionConfig
? this._sectionConfig.cards || []
: this._cardConfig!;
return addCards(config, [viewIndex, sectionIndex], newCards);
}
private async _save(): Promise<void> {
if (
!this._params?.lovelaceConfig ||
@ -188,13 +254,12 @@ export class HuiDialogSuggestCard extends LitElement {
return;
}
this._saving = true;
await this._params!.saveConfig(
addCards(
this._params!.lovelaceConfig,
this._params!.path as [number],
this._cardConfig
)
const newConfig = this._computeNewConfig(
this._params.lovelaceConfig,
this._params.path
);
await this._params!.saveConfig(newConfig);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();

View File

@ -0,0 +1,104 @@
import { PropertyValues, ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { LovelaceSectionElement } from "../../../../data/lovelace";
import { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { HomeAssistant } from "../../../../types";
import { createSectionElement } from "../../create-element/create-section-element";
import { createErrorSectionConfig } from "../../sections/hui-error-section";
import { LovelaceConfig } from "../../../../data/lovelace/config/types";
@customElement("hui-section-preview")
export class HuiSectionPreview extends ReactiveElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public lovelace?: LovelaceConfig;
@property({ attribute: false }) public config?: LovelaceSectionConfig;
private _element?: LovelaceSectionElement;
private get _error() {
return this._element?.tagName === "HUI-ERROR-SECTION";
}
constructor() {
super();
this.addEventListener("ll-rebuild", () => {
this._cleanup();
if (this.config) {
this._createSection(this.config);
}
});
}
protected createRenderRoot() {
return this;
}
protected update(changedProperties: PropertyValues) {
super.update(changedProperties);
if (changedProperties.has("config")) {
const oldConfig = changedProperties.get("config") as
| undefined
| LovelaceSectionConfig;
if (!this.config) {
this._cleanup();
return;
}
if (!this.config.type) {
this._createSection(createErrorSectionConfig("No section type found"));
return;
}
if (!this._element) {
this._createSection(this.config);
return;
}
// in case the element was an error element we always want to recreate it
if (!this._error && oldConfig && this.config.type === oldConfig.type) {
try {
this._element.setConfig(this.config);
} catch (err: any) {
this._createSection(createErrorSectionConfig(err.message));
}
} else {
this._createSection(this.config);
}
}
if (changedProperties.has("hass")) {
if (this._element) {
this._element.hass = this.hass;
}
}
}
private _createSection(configValue: LovelaceSectionConfig): void {
this._cleanup();
this._element = createSectionElement(configValue) as LovelaceSectionElement;
if (this.hass) {
this._element!.hass = this.hass;
}
this.appendChild(this._element!);
}
private _cleanup() {
if (!this._element) {
return;
}
this.removeChild(this._element);
this._element = undefined;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-section-preview": HuiSectionPreview;
}
}

View File

@ -1,10 +1,12 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { LovelaceContainerPath } from "../lovelace-path";
export interface CreateCardDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: [number];
path: LovelaceContainerPath;
suggestedCards?: string[];
entities?: string[]; // We can pass entity id's that will be added to the config when a card is picked
}

View File

@ -1,14 +1,20 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { LovelaceContainerPath } from "../lovelace-path";
export interface EditCardDialogParams {
export type EditCardDialogParams = {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: [number, number | null];
// If specified, the card will be replaced with the new card.
newCardConfig?: LovelaceCardConfig;
}
path: LovelaceContainerPath;
} & (
| {
cardIndex: number;
}
| {
cardConfig: LovelaceCardConfig;
}
);
export const importEditCardDialog = () => import("./hui-dialog-edit-card");

View File

@ -1,14 +1,17 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { LovelaceContainerPath } from "../lovelace-path";
export interface SuggestCardDialogParams {
lovelaceConfig?: LovelaceConfig;
yaml?: boolean;
saveConfig?: (config: LovelaceConfig) => void;
path?: [number];
path?: LovelaceContainerPath;
entities?: string[]; // We pass this to create dialog when user chooses "Pick own"
cardConfig: LovelaceCardConfig[]; // We can pass a suggested config
cardConfig: LovelaceCardConfig[]; // We can pass a suggested config,s
sectionConfig?: LovelaceSectionConfig;
}
const importSuggestCardDialog = () => import("./hui-dialog-suggest-card");

View File

@ -1,296 +1,160 @@
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import { LovelaceConfig } from "../../../data/lovelace/config/types";
import {
LovelaceViewConfig,
isStrategyView,
} from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import {
LovelaceCardPath,
LovelaceContainerPath,
findLovelaceCards,
findLovelaceContainer,
getLovelaceContainerPath,
parseLovelaceCardPath,
parseLovelaceContainerPath,
updateLovelaceCards,
updateLovelaceContainer,
} from "./lovelace-path";
export const addCard = (
config: LovelaceConfig,
path: [number],
path: LovelaceContainerPath,
cardConfig: LovelaceCardConfig
): LovelaceConfig => {
const [viewIndex] = path;
const views: LovelaceViewConfig[] = [];
config.views.forEach((viewConf, index) => {
if (index !== viewIndex) {
views.push(config.views[index]);
return;
}
if (isStrategyView(viewConf)) {
throw new Error("You cannot add a card in a strategy view.");
}
const cards = viewConf.cards
? [...viewConf.cards, cardConfig]
: [cardConfig];
views.push({
...viewConf,
cards,
});
});
return {
...config,
views,
};
const cards = findLovelaceCards(config, path);
const newCards = cards ? [...cards, cardConfig] : [cardConfig];
const newConfig = updateLovelaceCards(config, path, newCards);
return newConfig;
};
export const addCards = (
config: LovelaceConfig,
path: [number],
path: LovelaceContainerPath,
cardConfigs: LovelaceCardConfig[]
): LovelaceConfig => {
const [viewIndex] = path;
const views: LovelaceViewConfig[] = [];
config.views.forEach((viewConf, index) => {
if (index !== viewIndex) {
views.push(config.views[index]);
return;
}
if (isStrategyView(viewConf)) {
throw new Error("You cannot add cards in a strategy view.");
}
const cards = viewConf.cards
? [...viewConf.cards, ...cardConfigs]
: [...cardConfigs];
views.push({
...viewConf,
cards,
});
});
return {
...config,
views,
};
const cards = findLovelaceCards(config, path);
const newCards = cards ? [...cards, ...cardConfigs] : [...cardConfigs];
const newConfig = updateLovelaceCards(config, path, newCards);
return newConfig;
};
export const replaceCard = (
config: LovelaceConfig,
path: [number, number],
path: LovelaceCardPath,
cardConfig: LovelaceCardConfig
): LovelaceConfig => {
const [viewIndex, cardIndex] = path;
const views: LovelaceViewConfig[] = [];
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
config.views.forEach((viewConf, index) => {
if (index !== viewIndex) {
views.push(config.views[index]);
return;
}
const cards = findLovelaceCards(config, containerPath);
if (isStrategyView(viewConf)) {
throw new Error("You cannot replace a card in a strategy view.");
}
const newCards = (cards ?? []).map((origConf, ind) =>
ind === cardIndex ? cardConfig : origConf
);
views.push({
...viewConf,
cards: (viewConf.cards || []).map((origConf, ind) =>
ind === cardIndex ? cardConfig : origConf
),
});
});
return {
...config,
views,
};
const newConfig = updateLovelaceCards(config, containerPath, newCards);
return newConfig;
};
export const deleteCard = (
config: LovelaceConfig,
path: [number, number]
path: LovelaceCardPath
): LovelaceConfig => {
const [viewIndex, cardIndex] = path;
const views: LovelaceViewConfig[] = [];
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
config.views.forEach((viewConf, index) => {
if (index !== viewIndex) {
views.push(config.views[index]);
return;
}
const cards = findLovelaceCards(config, containerPath);
if (isStrategyView(viewConf)) {
throw new Error("You cannot delete a card in a strategy view.");
}
const newCards = (cards ?? []).filter((_origConf, ind) => ind !== cardIndex);
views.push({
...viewConf,
cards: (viewConf.cards || []).filter(
(_origConf, ind) => ind !== cardIndex
),
});
});
return {
...config,
views,
};
const newConfig = updateLovelaceCards(config, containerPath, newCards);
return newConfig;
};
export const insertCard = (
config: LovelaceConfig,
path: [number, number],
path: LovelaceCardPath,
cardConfig: LovelaceCardConfig
) => {
const [viewIndex, cardIndex] = path;
const views: LovelaceViewConfig[] = [];
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
config.views.forEach((viewConf, index) => {
if (index !== viewIndex) {
views.push(config.views[index]);
return;
}
const cards = findLovelaceCards(config, containerPath);
if (isStrategyView(viewConf)) {
throw new Error("You cannot insert a card in a strategy view.");
}
const newCards = cards
? [...cards.slice(0, cardIndex), cardConfig, ...cards.slice(cardIndex)]
: [cardConfig];
const cards = viewConf.cards
? [
...viewConf.cards.slice(0, cardIndex),
cardConfig,
...viewConf.cards.slice(cardIndex),
]
: [cardConfig];
views.push({
...viewConf,
cards,
});
});
return {
...config,
views,
};
const newConfig = updateLovelaceCards(config, containerPath, newCards);
return newConfig;
};
export const swapCard = (
export const moveCardToIndex = (
config: LovelaceConfig,
path1: [number, number],
path2: [number, number]
path: LovelaceCardPath,
index: number
): LovelaceConfig => {
const origView1 = config.views[path1[0]];
const origView2 = config.views[path2[0]];
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
if (isStrategyView(origView1) || isStrategyView(origView2)) {
throw new Error("You cannot move swap cards in a strategy view.");
}
const cards = findLovelaceCards(config, containerPath);
const card1 = origView1.cards![path1[1]];
const card2 = origView2.cards![path2[1]];
const newCards = cards ? [...cards] : [];
const newView1 = {
...origView1,
cards: origView1.cards!.map((origCard, index) =>
index === path1[1] ? card2 : origCard
),
};
const updatedOrigView2 = path1[0] === path2[0] ? newView1 : origView2;
const newView2 = {
...updatedOrigView2,
cards: updatedOrigView2.cards!.map((origCard, index) =>
index === path2[1] ? card1 : origCard
),
};
return {
...config,
views: config.views.map((origView, index) =>
index === path2[0] ? newView2 : index === path1[0] ? newView1 : origView
),
};
};
export const moveCardToPosition = (
config: LovelaceConfig,
path: [number, number],
position: number
): LovelaceConfig => {
const view = config.views[path[0]];
if (isStrategyView(view)) {
throw new Error("You cannot move a card in a strategy view.");
}
const oldIndex = path[1];
const newIndex = Math.max(Math.min(position - 1, view.cards!.length - 1), 0);
const newCards = [...view.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 newView = {
...view,
cards: newCards,
};
const newConfig = updateLovelaceCards(config, containerPath, newCards);
return newConfig;
};
return {
...config,
views: config.views.map((origView, index) =>
index === path[0] ? newView : origView
),
};
export const moveCardToContainer = (
config: LovelaceConfig,
fromPath: LovelaceCardPath,
toPath: LovelaceContainerPath
): LovelaceConfig => {
const {
cardIndex: fromCardIndex,
viewIndex: fromViewIndex,
sectionIndex: fromSectionIndex,
} = parseLovelaceCardPath(fromPath);
const { viewIndex: toViewIndex, sectionIndex: toSectionIndex } =
parseLovelaceContainerPath(toPath);
if (fromViewIndex === toViewIndex && fromSectionIndex === toSectionIndex) {
throw new Error("You cannot move a card to the view or section it is in.");
}
const fromContainerPath = getLovelaceContainerPath(fromPath);
const cards = findLovelaceCards(config, fromContainerPath);
const card = cards![fromCardIndex];
let newConfig = addCard(config, toPath, card);
newConfig = deleteCard(newConfig, fromPath);
return newConfig;
};
export const moveCard = (
config: LovelaceConfig,
fromPath: [number, number],
toPath: [number]
fromPath: LovelaceCardPath,
toPath: LovelaceCardPath
): LovelaceConfig => {
if (fromPath[0] === toPath[0]) {
throw new Error("You cannot move a card to the view it is in.");
}
const fromView = config.views[fromPath[0]];
const toView = config.views[toPath[0]];
const { cardIndex: fromCardIndex } = parseLovelaceCardPath(fromPath);
const fromContainerPath = getLovelaceContainerPath(fromPath);
const cards = findLovelaceCards(config, fromContainerPath);
const card = cards![fromCardIndex];
if (isStrategyView(fromView)) {
throw new Error("You cannot move a card from a strategy view.");
}
let newConfig = deleteCard(config, fromPath);
newConfig = insertCard(newConfig, toPath, card);
if (isStrategyView(toView)) {
throw new Error("You cannot move a card to a strategy view.");
}
const card = fromView.cards![fromPath[1]];
const newView1 = {
...fromView,
cards: (fromView.cards || []).filter(
(_origConf, ind) => ind !== fromPath[1]
),
};
const cards = toView.cards ? [...toView.cards, card] : [card];
const newView2 = {
...toView,
cards,
};
return {
...config,
views: config.views.map((origView, index) =>
index === toPath[0]
? newView2
: index === fromPath[0]
? newView1
: origView
),
};
return newConfig;
};
export const addView = (
@ -356,3 +220,84 @@ export const deleteView = (
...config,
views: config.views.filter((_origView, index) => index !== viewIndex),
});
export const addSection = (
config: LovelaceConfig,
viewIndex: number,
sectionConfig: LovelaceSectionRawConfig
): LovelaceConfig => {
const view = findLovelaceContainer(config, [viewIndex]) as LovelaceViewConfig;
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]) as LovelaceViewConfig;
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;
};
export const insertSection = (
config: LovelaceConfig,
viewIndex: number,
sectionIndex: number,
sectionConfig: LovelaceSectionRawConfig
): LovelaceConfig => {
const view = findLovelaceContainer(config, [viewIndex]) as LovelaceViewConfig;
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
) as LovelaceSectionRawConfig;
let newConfig = deleteSection(config, fromPath[0], fromPath[1]);
newConfig = insertSection(newConfig, toPath[0], toPath[1], section);
return newConfig;
};

View File

@ -1,22 +1,29 @@
import { isStrategyView } from "../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import { HomeAssistant } from "../../../types";
import { showDeleteSuccessToast } from "../../../util/toast-deleted-success";
import { Lovelace } from "../types";
import { showDeleteCardDialog } from "./card-editor/show-delete-card-dialog";
import { deleteCard, insertCard } from "./config-util";
import {
LovelaceCardPath,
findLovelaceContainer,
getLovelaceContainerPath,
parseLovelaceCardPath,
} from "./lovelace-path";
export async function confDeleteCard(
element: HTMLElement,
hass: HomeAssistant,
lovelace: Lovelace,
path: [number, number]
path: LovelaceCardPath
): Promise<void> {
const view = lovelace.config.views[path[0]];
if (isStrategyView(view)) {
throw new Error("Deleting cards in a strategy view is not supported.");
const containerPath = getLovelaceContainerPath(path);
const { cardIndex } = parseLovelaceCardPath(path);
const containerConfig = findLovelaceContainer(lovelace.config, containerPath);
if ("strategy" in containerConfig) {
throw new Error("Deleting cards in a strategy is not supported.");
}
const cardConfig = view.cards![path[1]];
const cardConfig = containerConfig.cards![cardIndex];
showDeleteCardDialog(element, {
cardConfig,
deleteCard: async () => {

View File

@ -0,0 +1,197 @@
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import {
LovelaceSectionRawConfig,
isStrategySection,
} from "../../../data/lovelace/config/section";
import { LovelaceConfig } from "../../../data/lovelace/config/types";
import {
LovelaceViewRawConfig,
isStrategyView,
} from "../../../data/lovelace/config/view";
export type LovelaceCardPath = [number, number] | [number, number, number];
export type LovelaceContainerPath = [number] | [number, number];
export const parseLovelaceCardPath = (
path: LovelaceCardPath
): { viewIndex: number; sectionIndex?: number; cardIndex: number } => {
if (path.length === 2) {
return {
viewIndex: path[0],
cardIndex: path[1],
};
}
return {
viewIndex: path[0],
sectionIndex: path[1],
cardIndex: path[2],
};
};
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;
export const findLovelaceContainer = (
config: LovelaceConfig,
path: LovelaceContainerPath
): LovelaceViewRawConfig | LovelaceSectionRawConfig => {
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
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;
};
export const findLovelaceCards = (
config: LovelaceConfig,
path: LovelaceContainerPath
): LovelaceCardConfig[] | undefined => {
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
const view = config.views[viewIndex];
if (!view) {
throw new Error("View does not exist");
}
if (isStrategyView(view)) {
throw new Error("Can not find cards in a strategy view");
}
if (sectionIndex === undefined) {
return view.cards;
}
const section = view.sections?.[sectionIndex];
if (!section) {
throw new Error("Section does not exist");
}
if (isStrategySection(section)) {
throw new Error("Can not find cards in a strategy section");
}
return section.cards;
};
export const updateLovelaceContainer = (
config: LovelaceConfig,
path: LovelaceContainerPath,
containerConfig: LovelaceViewRawConfig | LovelaceSectionRawConfig
): 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;
}
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;
});
return {
...view,
sections: newSections,
};
});
if (!updated) {
throw new Error("Can not update cards in a non-existing view/section");
}
return {
...config,
views: newViews,
};
};
export const updateLovelaceCards = (
config: LovelaceConfig,
path: LovelaceContainerPath,
cards: LovelaceCardConfig[]
): 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 cards in a strategy view");
}
if (sectionIndex === undefined) {
updated = true;
return {
...view,
cards,
};
}
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 cards in a strategy section");
}
updated = true;
return {
...section,
cards,
};
});
return {
...view,
sections: newSections,
};
});
if (!updated) {
throw new Error("Can not update cards in a non-existing view/section");
}
return {
...config,
views: newViews,
};
};

View File

@ -62,6 +62,7 @@ export interface Card {
description?: string;
showElement?: boolean;
isCustom?: boolean;
isSuggested?: boolean;
}
export interface HeaderFooter {

View File

@ -21,7 +21,10 @@ import "../card-editor/hui-entity-picker-table";
import { showSuggestCardDialog } from "../card-editor/show-suggest-card-dialog";
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
import { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { computeCards } from "../../common/generate-lovelace-config";
import {
computeCards,
computeSection,
} from "../../common/generate-lovelace-config";
@customElement("hui-unused-entities")
export class HuiUnusedEntities extends LitElement {
@ -132,6 +135,8 @@ export class HuiUnusedEntities extends LitElement {
this._selectedEntities,
{}
);
const sectionConfig = computeSection(this._selectedEntities, {});
if (this.lovelace.config.views.length === 1) {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
@ -139,6 +144,7 @@ export class HuiUnusedEntities extends LitElement {
path: [0],
entities: this._selectedEntities,
cardConfig,
sectionConfig,
});
return;
}
@ -152,6 +158,7 @@ export class HuiUnusedEntities extends LitElement {
path: [viewIndex],
entities: this._selectedEntities,
cardConfig,
sectionConfig,
});
},
});

View File

@ -6,13 +6,14 @@ import { slugify } from "../../../../common/string/slugify";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import {
DEFAULT_VIEW_LAYOUT,
SECTION_VIEW_LAYOUT,
PANEL_VIEW_LAYOUT,
SIDEBAR_VIEW_LAYOUT,
} from "../../views/const";
import { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
declare global {
interface HASSDomEvents {
@ -53,6 +54,7 @@ export class HuiViewEditor extends LitElement {
DEFAULT_VIEW_LAYOUT,
SIDEBAR_VIEW_LAYOUT,
PANEL_VIEW_LAYOUT,
SECTION_VIEW_LAYOUT,
] as const
).map((type) => ({
value: type,

View File

@ -0,0 +1,2 @@
export const GRID_SECTION_LAYOUT = "grid";
export const DEFAULT_SECTION_LAYOUT = GRID_SECTION_LAYOUT;

View File

@ -0,0 +1,60 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-label-badge";
import "../../../components/ha-svg-icon";
import { LovelaceSectionElement } from "../../../data/lovelace";
import { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import { HomeAssistant } from "../../../types";
export interface ErrorSectionConfig extends LovelaceSectionConfig {
error: string;
}
export const createErrorSectionElement = (config: ErrorSectionConfig) => {
const el = document.createElement(
"hui-error-section"
) as LovelaceSectionElement;
el.setConfig(config);
return el;
};
export const createErrorSectionConfig = (
error: string
): ErrorSectionConfig => ({
type: "error",
error,
});
@customElement("hui-error-section")
export class HuiErrorSection
extends LitElement
implements LovelaceSectionElement
{
public hass?: HomeAssistant;
@property({ type: Boolean }) public isStrategy = false;
@state() private _config?: ErrorSectionConfig;
public setConfig(config: ErrorSectionConfig): void {
this._config = config;
}
protected render() {
if (!this._config) {
return nothing;
}
// Todo improve
return html`
<h1>Error</h1>
<p>${this._config.error}</p>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-error-section": HuiErrorSection;
}
}

View File

@ -0,0 +1,246 @@
import { mdiPlus } from "@mdi/js";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaSortableOptions } from "../../../components/ha-sortable";
import { LovelaceSectionElement } from "../../../data/lovelace";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { HuiErrorCard } from "../cards/hui-error-card";
import "../components/hui-card-edit-mode";
import { moveCard } from "../editor/config-util";
import type { Lovelace, LovelaceCard } from "../types";
const CARD_SORTABLE_OPTIONS: HaSortableOptions = {
delay: 200,
delayOnTouchOnly: true,
direction: "vertical",
invertedSwapThreshold: 0.7,
} as HaSortableOptions;
export class GridSection extends LitElement implements LovelaceSectionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Number }) public index?: number;
@property({ type: Number }) public viewIndex?: number;
@property({ type: Boolean }) public isStrategy = false;
@property({ attribute: false }) public cards: Array<
LovelaceCard | HuiErrorCard
> = [];
@state() _config?: LovelaceSectionConfig;
@state() _dragging = false;
public setConfig(config: LovelaceSectionConfig): void {
this._config = config;
}
private _cardConfigKeys = new WeakMap<LovelaceCardConfig, string>();
private _getKey(cardConfig: LovelaceCardConfig) {
if (!this._cardConfigKeys.has(cardConfig)) {
this._cardConfigKeys.set(cardConfig, Math.random().toString());
}
return this._cardConfigKeys.get(cardConfig)!;
}
render() {
if (!this.cards || !this._config) return nothing;
const cardsConfig = this._config?.cards ?? [];
const editMode = Boolean(this.lovelace?.editMode && !this.isStrategy);
return html`
${this._config.title || this.lovelace?.editMode
? html`
<h2
class="title ${classMap({
placeholder: !this._config.title,
})}"
>
${this._config.title ||
this.hass.localize(
"ui.panel.lovelace.editor.section.unnamed_section"
)}
</h2>
`
: nothing}
<ha-sortable
.disabled=${!editMode}
@item-moved=${this._cardMoved}
@drag-start=${this._dragStart}
@drag-end=${this._dragEnd}
group="card"
draggable-selector=".card"
.path=${[this.viewIndex, this.index]}
.rollback=${false}
.options=${CARD_SORTABLE_OPTIONS}
invert-swap
>
<div class="container ${classMap({ "edit-mode": editMode })}">
${repeat(
cardsConfig,
(cardConfig) => this._getKey(cardConfig),
(_cardConfig, idx) => {
const card = this.cards![idx];
(card as any).editMode = editMode;
const size = card && (card as any).getGridSize?.();
return html`
<div
class="card"
style=${styleMap({
"--column-size": size?.[0],
"--row-size": size?.[1],
})}
>
${editMode
? html`
<hui-card-edit-mode
.hass=${this.hass}
.lovelace=${this.lovelace}
.path=${[this.viewIndex, this.index, idx]}
.hiddenOverlay=${this._dragging}
>
${card}
</hui-card-edit-mode>
`
: card}
</div>
`;
}
)}
${editMode
? html`
<button
class="add"
@click=${this._addCard}
aria-label=${this.hass.localize(
"ui.panel.lovelace.editor.section.add_card"
)}
.title=${this.hass.localize(
"ui.panel.lovelace.editor.section.add_card"
)}
>
<ha-svg-icon .path=${mdiPlus}></ha-svg-icon>
</button>
`
: nothing}
</div>
</ha-sortable>
`;
}
private _cardMoved(ev) {
ev.stopPropagation();
const { oldIndex, newIndex, oldPath, newPath } = ev.detail;
const newConfig = moveCard(
this.lovelace!.config,
[...oldPath, oldIndex] as [number, number, number],
[...newPath, newIndex] as [number, number, number]
);
this.lovelace!.saveConfig(newConfig);
}
private _dragStart() {
this._dragging = true;
}
private _dragEnd() {
this._dragging = false;
}
private _addCard() {
fireEvent(this, "ll-create-card", { suggested: ["tile"] });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: flex;
flex-direction: column;
gap: 8px;
}
.container {
--column-count: 4;
display: grid;
grid-template-columns: repeat(var(--column-count), minmax(0, 1fr));
grid-auto-rows: minmax(66px, auto);
gap: 8px;
padding: 0;
margin: 0 auto;
}
.container.edit-mode {
padding: 8px;
border-radius: var(--ha-card-border-radius, 12px);
border: 2px dashed var(--divider-color);
min-height: 66px;
}
.title {
color: var(--primary-text-color);
font-size: 20px;
font-weight: normal;
margin: 0px;
letter-spacing: 0.1px;
line-height: 32px;
min-height: 32px;
display: block;
padding: 24px 10px 10px;
}
.title.placeholder {
color: var(--secondary-text-color);
font-style: italic;
}
.card {
border-radius: var(--ha-card-border-radius, 12px);
position: relative;
grid-row: span var(--row-size, 1);
grid-column: span var(--column-size, 4);
}
.add {
outline: none;
grid-row: span var(--row-size, 1);
grid-column: span var(--column-size, 2);
background: none;
cursor: pointer;
border-radius: var(--ha-card-border-radius, 12px);
border: 2px dashed var(--primary-color);
height: 66px;
order: 1;
}
.add:focus {
border-style: solid;
}
.sortable-ghost {
border-radius: var(--ha-card-border-radius, 12px);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-grid-section": GridSection;
}
}
customElements.define("hui-grid-section", GridSection);

View File

@ -0,0 +1,247 @@
import { PropertyValues, ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-svg-icon";
import type { LovelaceSectionElement } from "../../../data/lovelace";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import {
LovelaceSectionConfig,
LovelaceSectionRawConfig,
isStrategySection,
} from "../../../data/lovelace/config/section";
import type { HomeAssistant } from "../../../types";
import type { HuiErrorCard } from "../cards/hui-error-card";
import { createCardElement } from "../create-element/create-card-element";
import {
createErrorCardConfig,
createErrorCardElement,
} from "../create-element/create-element-base";
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 { deleteCard } from "../editor/config-util";
import { confDeleteCard } from "../editor/delete-card";
import { parseLovelaceCardPath } from "../editor/lovelace-path";
import { generateLovelaceSectionStrategy } from "../strategies/get-strategy";
import type { Lovelace, LovelaceCard } from "../types";
import { DEFAULT_SECTION_LAYOUT } from "./const";
@customElement("hui-section")
export class HuiSection extends ReactiveElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ attribute: false }) public config!: LovelaceSectionRawConfig;
@property({ type: Number }) public index!: number;
@property({ type: Number }) public viewIndex!: number;
@state() private _cards: Array<LovelaceCard | HuiErrorCard> = [];
private _layoutElementType?: string;
private _layoutElement?: LovelaceSectionElement;
// Public to make demo happy
public createCardElement(cardConfig: LovelaceCardConfig) {
const element = createCardElement(cardConfig) as LovelaceCard;
try {
element.hass = this.hass;
} catch (e: any) {
return createErrorCardElement(
createErrorCardConfig(e.message, cardConfig)
);
}
element.addEventListener(
"ll-rebuild",
(ev: Event) => {
// In edit mode let it go to hui-root and rebuild whole section.
if (!this.lovelace!.editMode) {
ev.stopPropagation();
this._rebuildCard(element, cardConfig);
}
},
{ once: true }
);
return element;
}
protected createRenderRoot() {
return this;
}
public willUpdate(changedProperties: PropertyValues<typeof this>): void {
super.willUpdate(changedProperties);
/*
We need to handle the following use cases:
- initialization: create layout element, populate
- config changed to section with same layout element
- config changed to section with different layout element
- forwarded properties hass/narrow/lovelace/cards change
- cards change if one is rebuild when it was loaded later
- lovelace changes if edit mode is enabled or config has changed
*/
const oldConfig = changedProperties.get("config");
// If config has changed, create element if necessary and set all values.
if (
changedProperties.has("config") &&
(!oldConfig || this.config !== oldConfig)
) {
this._initializeConfig();
}
}
protected update(changedProperties) {
super.update(changedProperties);
// If no layout element, we're still creating one
if (this._layoutElement) {
// Config has not changed. Just props
if (changedProperties.has("hass")) {
this._cards.forEach((element) => {
try {
element.hass = this.hass;
} catch (e: any) {
this._rebuildCard(element, createErrorCardConfig(e.message, null));
}
});
this._layoutElement.hass = this.hass;
}
if (changedProperties.has("lovelace")) {
this._layoutElement.lovelace = this.lovelace;
}
if (changedProperties.has("_cards")) {
this._layoutElement.cards = this._cards;
}
}
}
private async _initializeConfig() {
let sectionConfig = { ...this.config };
let isStrategy = false;
if (isStrategySection(sectionConfig)) {
isStrategy = true;
sectionConfig = await generateLovelaceSectionStrategy(
sectionConfig.strategy,
this.hass!
);
}
sectionConfig = {
...sectionConfig,
type: sectionConfig.type || DEFAULT_SECTION_LAYOUT,
};
// Create a new layout element if necessary.
let addLayoutElement = false;
if (
!this._layoutElement ||
this._layoutElementType !== sectionConfig.type
) {
addLayoutElement = true;
this._createLayoutElement(sectionConfig);
}
this._createCards(sectionConfig);
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!.cards = this._cards;
if (addLayoutElement) {
while (this.lastChild) {
this.removeChild(this.lastChild);
}
this.appendChild(this._layoutElement!);
}
}
private _createLayoutElement(config: LovelaceSectionConfig): void {
this._layoutElement = createSectionElement(
config
) as LovelaceSectionElement;
this._layoutElementType = config.type;
this._layoutElement.addEventListener("ll-create-card", (ev) => {
ev.stopPropagation();
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();
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.viewIndex, this.index],
cardIndex,
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
ev.stopPropagation();
if (ev.detail.confirm) {
confDeleteCard(this, this.hass!, this.lovelace!, ev.detail.path);
} else {
const newLovelace = deleteCard(this.lovelace!.config, ev.detail.path);
this.lovelace.saveConfig(newLovelace);
}
});
}
private _createCards(config: LovelaceSectionConfig): void {
if (!config || !config.cards || !Array.isArray(config.cards)) {
this._cards = [];
return;
}
this._cards = config.cards.map((cardConfig) => {
const element = this.createCardElement(cardConfig);
try {
element.hass = this.hass;
} catch (e: any) {
return createErrorCardElement(
createErrorCardConfig(e.message, cardConfig)
);
}
return element;
});
}
private _rebuildCard(
cardElToReplace: LovelaceCard,
config: LovelaceCardConfig
): void {
let newCardEl = this.createCardElement(config);
try {
newCardEl.hass = this.hass;
} catch (e: any) {
newCardEl = createErrorCardElement(
createErrorCardConfig(e.message, config)
);
}
if (cardElToReplace.parentElement) {
cardElToReplace.parentElement!.replaceChild(newCardEl, cardElToReplace);
}
this._cards = this._cards!.map((curCardEl) =>
curCardEl === cardElToReplace ? newCardEl : curCardEl
);
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-section": HuiSection;
}
}

View File

@ -12,6 +12,7 @@ import { AsyncReturnType, HomeAssistant } from "../../../types";
import { cleanLegacyStrategyConfig, isLegacyStrategy } from "./legacy-strategy";
import {
LovelaceDashboardStrategy,
LovelaceSectionStrategy,
LovelaceStrategy,
LovelaceViewStrategy,
} from "./types";
@ -27,13 +28,15 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
"original-states": () => import("./original-states-view-strategy"),
energy: () => import("../../energy/strategies/energy-view-strategy"),
},
section: {},
};
export type LovelaceStrategyConfigType = "dashboard" | "view";
export type LovelaceStrategyConfigType = "dashboard" | "view" | "section";
type Strategies = {
dashboard: LovelaceDashboardStrategy;
view: LovelaceViewStrategy;
section: LovelaceSectionStrategy;
};
type StrategyConfig<T extends LovelaceStrategyConfigType> = AsyncReturnType<
@ -163,6 +166,24 @@ export const generateLovelaceViewStrategy = async (
hass
);
export const generateLovelaceSectionStrategy = async (
strategyConfig: LovelaceStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> =>
generateStrategy(
"section",
(err) => ({
cards: [
{
type: "markdown",
content: `Error loading the section strategy:\n> ${err}`,
},
],
}),
strategyConfig,
hass
);
/**
* Find all references to strategies and replaces them with the generated output
*/
@ -175,11 +196,24 @@ export const expandLovelaceConfigStrategies = async (
: { ...config };
newConfig.views = await Promise.all(
newConfig.views.map((view) =>
isStrategyView(view)
? generateLovelaceViewStrategy(view.strategy, hass)
: view
)
newConfig.views.map(async (view) => {
const newView = isStrategyView(view)
? await generateLovelaceViewStrategy(view.strategy, hass)
: { ...view };
if (newView.sections) {
newView.sections = await Promise.all(
newView.sections.map(async (section) => {
const newSection = isStrategyView(section)
? await generateLovelaceSectionStrategy(section.strategy, hass)
: { ...section };
return newSection;
})
);
}
return newView;
})
);
return newConfig;

View File

@ -1,5 +1,6 @@
import { LovelaceConfig } from "../../../data/lovelace/config/types";
import { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import { LovelaceConfig } from "../../../data/lovelace/config/types";
import { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { HomeAssistant } from "../../../types";
import { LovelaceGenericElementEditor } from "../types";
@ -15,6 +16,9 @@ export interface LovelaceDashboardStrategy
export interface LovelaceViewStrategy
extends LovelaceStrategy<LovelaceViewConfig> {}
export interface LovelaceSectionStrategy
extends LovelaceStrategy<LovelaceSectionConfig> {}
export interface LovelaceStrategyEditor extends LovelaceGenericElementEditor {
setConfig(config: LovelaceStrategyConfig): void;
}

View File

@ -44,6 +44,7 @@ export interface LovelaceCard extends HTMLElement {
isPanel?: boolean;
editMode?: boolean;
getCardSize(): number | Promise<number>;
getGridSize?(): [number, number];
setConfig(config: LovelaceCardConfig): void;
}

View File

@ -1,4 +1,9 @@
export const DEFAULT_VIEW_LAYOUT = "masonry";
export const PANEL_VIEW_LAYOUT = "panel";
export const SIDEBAR_VIEW_LAYOUT = "sidebar";
export const VIEWS_NO_BADGE_SUPPORT = [PANEL_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT];
export const SECTION_VIEW_LAYOUT = "sections";
export const VIEWS_NO_BADGE_SUPPORT = [
PANEL_VIEW_LAYOUT,
SIDEBAR_VIEW_LAYOUT,
SECTION_VIEW_LAYOUT,
];

View File

@ -0,0 +1,322 @@
import { mdiArrowAll, mdiDelete, mdiPencil, mdiViewGridPlus } from "@mdi/js";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import "../../../components/ha-icon-button";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { LovelaceViewElement } from "../../../data/lovelace";
import { LovelaceSectionConfig as LovelaceRawSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import {
showConfirmationDialog,
showPromptDialog,
} from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { addSection, deleteSection, moveSection } from "../editor/config-util";
import {
findLovelaceContainer,
updateLovelaceContainer,
} from "../editor/lovelace-path";
import { HuiSection } from "../sections/hui-section";
import type { Lovelace } from "../types";
@customElement("hui-sections-view")
export class SectionsView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Number }) public index?: number;
@property({ type: Boolean }) public isStrategy = false;
@property({ attribute: false }) public sections: HuiSection[] = [];
@state() private _config?: LovelaceViewConfig;
public setConfig(config: LovelaceViewConfig): void {
this._config = config;
}
private _sectionConfigKeys = new WeakMap<LovelaceRawSectionConfig, string>();
private _getKey(sectionConfig: LovelaceRawSectionConfig) {
if (!this._sectionConfigKeys.has(sectionConfig)) {
this._sectionConfigKeys.set(sectionConfig, Math.random().toString());
}
return this._sectionConfigKeys.get(sectionConfig)!;
}
protected render() {
if (!this.lovelace) return nothing;
const sectionsConfig = this._config?.sections ?? [];
const editMode = this.lovelace.editMode;
return html`
<ha-sortable
.disabled=${!editMode}
@item-moved=${this._sectionMoved}
group="section"
handle-selector=".handle"
draggable-selector=".section"
.rollback=${false}
>
<div class="container">
${repeat(
sectionsConfig,
(sectionConfig) => this._getKey(sectionConfig),
(_sectionConfig, idx) => {
const section = this.sections[idx];
(section as any).itemPath = [idx];
return html`
<div class="section">
${editMode
? html`
<div class="section-overlay">
<div class="section-actions">
<ha-svg-icon
aria-hidden="true"
class="handle"
.path=${mdiArrowAll}
></ha-svg-icon>
<ha-icon-button
.label=${this.hass.localize("ui.common.edit")}
@click=${this._editSection}
.index=${idx}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize("ui.common.delete")}
@click=${this._deleteSection}
.index=${idx}
.path=${mdiDelete}
></ha-icon-button>
</div>
</div>
`
: nothing}
<div class="section-wrapper">${section}</div>
</div>
`;
}
)}
${editMode
? html`
<button
class="add"
@click=${this._addSection}
aria-label=${this.hass.localize(
"ui.panel.lovelace.editor.section.add_section"
)}
.title=${this.hass.localize(
"ui.panel.lovelace.editor.section.add_section"
)}
>
<ha-svg-icon .path=${mdiViewGridPlus}></ha-svg-icon>
</button>
`
: nothing}
</div>
</ha-sortable>
`;
}
private _addSection(): void {
const newConfig = addSection(this.lovelace!.config, this.index!, {
type: "grid",
cards: [],
});
this.lovelace!.saveConfig(newConfig);
}
private async _editSection(ev) {
const index = ev.currentTarget.index;
const path = [this.index!, index] as [number, number];
const section = findLovelaceContainer(
this.lovelace!.config,
path
) as LovelaceRawSectionConfig;
const newTitle = !section.title;
const title = await showPromptDialog(this, {
title: this.hass.localize(
`ui.panel.lovelace.editor.edit_section_title.${newTitle ? "title_new" : "title"}`
),
inputLabel: this.hass.localize(
"ui.panel.lovelace.editor.edit_section_title.input_label"
),
inputType: "string",
defaultValue: section.title,
confirmText: newTitle
? this.hass.localize("ui.common.add")
: this.hass.localize("ui.common.save"),
});
if (title === null) {
return;
}
const newConfig = updateLovelaceContainer(this.lovelace!.config, path, {
...section,
title: title || undefined,
});
this.lovelace!.saveConfig(newConfig);
}
private async _deleteSection(ev) {
const index = ev.currentTarget.index;
const path = [this.index!, index] as [number, number];
const section = findLovelaceContainer(
this.lovelace!.config,
path
) as LovelaceRawSectionConfig;
const title = section.title;
const cardCount = section.cards?.length;
if (title || cardCount) {
const sectionName = title?.trim()
? this.hass.localize(
"ui.panel.lovelace.editor.delete_section.named_section",
{ name: title }
)
: this.hass.localize(
"ui.panel.lovelace.editor.delete_section.unnamed_section"
);
const content = cardCount
? this.hass.localize(
"ui.panel.lovelace.editor.delete_section.text_section_and_cards",
{
section: sectionName,
}
)
: this.hass.localize(
"ui.panel.lovelace.editor.delete_section.text_section_only",
{
section: sectionName,
}
);
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.lovelace.editor.delete_section.title"
),
text: content,
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (!confirm) return;
}
const newConfig = deleteSection(this.lovelace!.config, this.index!, index);
this.lovelace!.saveConfig(newConfig);
}
private _sectionMoved(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newConfig = moveSection(
this.lovelace!.config,
[this.index!, oldIndex],
[this.index!, newIndex]
);
this.lovelace!.saveConfig(newConfig);
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
}
.section {
position: relative;
border-radius: var(--ha-card-border-radius, 12px);
}
.container {
--column-count: 3;
display: grid;
grid-template-columns: repeat(var(--column-count), minmax(0, 1fr));
gap: 8px 20px;
max-width: 1400px;
padding: 20px;
margin: 0 auto;
}
@media (max-width: 1200px) {
.container {
--column-count: 2;
}
}
@media (max-width: 600px) {
.container {
--column-count: 1;
padding: 8px;
}
}
.section-actions {
position: absolute;
top: 0;
right: 0;
opacity: 1;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease-in-out;
background-color: rgba(var(--rgb-card-background-color), 0.3);
border-radius: 18px;
background: var(--secondary-background-color);
--mdc-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--primary-text-color);
}
.handle {
cursor: grab;
padding: 8px;
}
.add {
margin-top: calc(66px + 8px);
outline: none;
background: none;
cursor: pointer;
border-radius: var(--ha-card-border-radius, 12px);
border: 2px dashed var(--primary-color);
order: 1;
height: 66px;
padding: 8px;
box-sizing: content-box;
}
.add:focus {
border: 2px solid var(--primary-color);
}
.sortable-ghost {
border-radius: var(--ha-card-border-radius, 12px);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-sections-view": SectionsView;
}
}

View File

@ -1,9 +1,17 @@
import { PropertyValues, ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/ha-state-label-badge";
import "../../../components/ha-svg-icon";
import type { LovelaceViewElement } from "../../../data/lovelace";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import {
LovelaceViewConfig,
isStrategyView,
} from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import {
createErrorBadgeConfig,
@ -20,25 +28,25 @@ import {
import { createViewElement } from "../create-element/create-view-element";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { confDeleteCard } from "../editor/delete-card";
import { deleteCard } from "../editor/config-util";
import { confDeleteCard } from "../editor/delete-card";
import {
LovelaceCardPath,
parseLovelaceCardPath,
} from "../editor/lovelace-path";
import { createErrorSectionConfig } from "../sections/hui-error-section";
import "../sections/hui-section";
import type { HuiSection } from "../sections/hui-section";
import { generateLovelaceViewStrategy } from "../strategies/get-strategy";
import type { Lovelace, LovelaceBadge, LovelaceCard } from "../types";
import { PANEL_VIEW_LAYOUT, DEFAULT_VIEW_LAYOUT } from "./const";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import {
LovelaceViewConfig,
isStrategyView,
} from "../../../data/lovelace/config/view";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { DEFAULT_VIEW_LAYOUT, PANEL_VIEW_LAYOUT } from "./const";
declare global {
// for fire event
interface HASSDomEvents {
"ll-create-card": undefined;
"ll-edit-card": { path: [number, number] };
"ll-delete-card": { path: [number, number]; confirm: boolean };
"ll-create-card": { suggested?: string[] } | undefined;
"ll-edit-card": { path: LovelaceCardPath };
"ll-delete-card": { path: LovelaceCardPath; confirm: boolean };
}
interface HTMLElementEventMap {
"ll-create-card": HASSDomEvent<HASSDomEvents["ll-create-card"]>;
@ -61,6 +69,8 @@ export class HUIView extends ReactiveElement {
@state() private _badges: LovelaceBadge[] = [];
@state() private _sections: HuiSection[] = [];
private _layoutElementType?: string;
private _layoutElement?: LovelaceViewElement;
@ -108,6 +118,27 @@ export class HUIView extends ReactiveElement {
return element;
}
// Public to make demo happy
public createSectionElement(sectionConfig: LovelaceSectionConfig) {
const element = document.createElement("hui-section");
element.hass = this.hass;
element.lovelace = this.lovelace;
element.config = sectionConfig;
element.viewIndex = this.index;
element.addEventListener(
"ll-rebuild",
(ev: Event) => {
// In edit mode let it go to hui-root and rebuild whole view.
if (!this.lovelace!.editMode) {
ev.stopPropagation();
this._rebuildSection(element, sectionConfig);
}
},
{ once: true }
);
return element;
}
protected createRenderRoot() {
return this;
}
@ -139,7 +170,7 @@ export class HUIView extends ReactiveElement {
}
}
protected update(changedProperties) {
protected update(changedProperties: PropertyValues) {
super.update(changedProperties);
// If no layout element, we're still creating one
@ -162,6 +193,14 @@ export class HUIView extends ReactiveElement {
}
});
this._sections.forEach((element) => {
try {
element.hass = this.hass;
} catch (e: any) {
this._rebuildSection(element, createErrorSectionConfig(e.message));
}
});
this._layoutElement.hass = this.hass;
const oldHass = changedProperties.get("hass") as
@ -181,6 +220,14 @@ export class HUIView extends ReactiveElement {
}
if (changedProperties.has("lovelace")) {
this._layoutElement.lovelace = this.lovelace;
this._sections.forEach((element) => {
try {
element.hass = this.hass;
element.lovelace = this.lovelace;
} catch (e: any) {
this._rebuildSection(element, createErrorSectionConfig(e.message));
}
});
}
if (changedProperties.has("_cards")) {
this._layoutElement.cards = this._cards;
@ -220,6 +267,7 @@ export class HUIView extends ReactiveElement {
this._createBadges(viewConfig);
this._createCards(viewConfig);
this._createSections(viewConfig);
this._layoutElement!.isStrategy = isStrategy;
this._layoutElement!.hass = this.hass;
this._layoutElement!.narrow = this.narrow;
@ -227,6 +275,7 @@ export class HUIView extends ReactiveElement {
this._layoutElement!.index = this.index;
this._layoutElement!.cards = this._cards;
this._layoutElement!.badges = this._badges;
this._layoutElement!.sections = this._sections;
applyThemesOnElement(this, this.hass.themes, viewConfig.theme);
this._viewConfigTheme = viewConfig.theme;
@ -242,18 +291,21 @@ export class HUIView extends ReactiveElement {
private _createLayoutElement(config: LovelaceViewConfig): void {
this._layoutElement = createViewElement(config) as LovelaceViewElement;
this._layoutElementType = config.type;
this._layoutElement.addEventListener("ll-create-card", () => {
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);
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: ev.detail.path,
path: [this.index],
cardIndex,
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
@ -303,6 +355,19 @@ export class HUIView extends ReactiveElement {
});
}
private _createSections(config: LovelaceViewConfig): void {
if (!config || !config.sections || !Array.isArray(config.sections)) {
this._sections = [];
return;
}
this._sections = config.sections.map((sectionConfig, index) => {
const element = this.createSectionElement(sectionConfig);
element.index = index;
return element;
});
}
private _rebuildCard(
cardElToReplace: LovelaceCard,
config: LovelaceCardConfig
@ -343,6 +408,23 @@ export class HUIView extends ReactiveElement {
curBadgeEl === badgeElToReplace ? newBadgeEl : curBadgeEl
);
}
private _rebuildSection(
sectionElToReplace: HuiSection,
config: LovelaceSectionConfig
): void {
const newSectionEl = this.createSectionElement(config);
newSectionEl.index = sectionElToReplace.index;
if (sectionElToReplace.parentElement) {
sectionElToReplace.parentElement!.replaceChild(
newSectionEl,
sectionElToReplace
);
}
this._sections = this._sections!.map((curSectionEl) =>
curSectionEl === sectionElToReplace ? newSectionEl : curSectionEl
);
}
}
declare global {

View File

@ -5096,7 +5096,8 @@
"types": {
"masonry": "Masonry (default)",
"sidebar": "Sidebar",
"panel": "Panel (1 card)"
"panel": "Panel (1 card)",
"sections": "Sections (experimental)"
},
"subview": "Subview",
"subview_helper": "Subviews don't appear in tabs and have a back button.",
@ -5112,7 +5113,7 @@
"header": "Card configuration",
"typed_header": "{type} Card configuration",
"pick_card": "Which card would you like to add?",
"pick_card_view_title": "Which card would you like to add to your {name} view?",
"pick_card_title": "Which card would you like to add to {name}",
"toggle_editor": "Toggle editor",
"unsaved_changes": "You have unsaved changes",
"confirm_cancel": "Are you sure you want to cancel?",
@ -5150,6 +5151,23 @@
"no_config": "No config found.",
"no_views": "No views in this dashboard."
},
"section": {
"unnamed_section": "Unnamed section",
"add_card": "[%key:ui::panel::lovelace::editor::edit_card::add%]",
"add_section": "Add section"
},
"delete_section": {
"title": "Delete section",
"named_section": "\"{name}\" section",
"unnamed_section": "This section",
"text_section_only": "{section} will be deleted.",
"text_section_and_cards": "{section} and all its cards will be deleted."
},
"edit_section_title": {
"title": "Edit name",
"title_new": "Add name",
"input_label": "Name"
},
"suggest_card": {
"header": "We created a suggestion for you",
"create_own": "Pick different card",
@ -5455,7 +5473,10 @@
"state": "State",
"secondary_info_attribute": "Secondary info attribute",
"search": "Search",
"state_color": "Color icons based on state?"
"state_color": "Color icons based on state?",
"suggested_cards": "Suggested cards",
"other_cards": "Other cards",
"custom_cards": "Custom cards"
},
"map": {
"name": "Map",

View File

@ -1,63 +1,12 @@
import { assert } from "chai";
import { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
import {
swapCard,
moveCard,
moveCardToContainer,
swapView,
} from "../../../../src/panels/lovelace/editor/config-util";
import { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
describe("swapCard", () => {
it("swaps 2 cards in same view", () => {
const config: LovelaceConfig = {
views: [
{},
{
cards: [{ type: "card1" }, { type: "card2" }],
},
],
};
const result = swapCard(config, [1, 0], [1, 1]);
const expected = {
views: [
{},
{
cards: [{ type: "card2" }, { type: "card1" }],
},
],
};
assert.deepEqual(expected, result);
});
it("swaps 2 cards in different views", () => {
const config: LovelaceConfig = {
views: [
{
cards: [{ type: "v1-c1" }, { type: "v1-c2" }],
},
{
cards: [{ type: "v2-c1" }, { type: "v2-c2" }],
},
],
};
const result = swapCard(config, [0, 0], [1, 1]);
const expected: LovelaceConfig = {
views: [
{
cards: [{ type: "v2-c2" }, { type: "v1-c2" }],
},
{
cards: [{ type: "v2-c1" }, { type: "v1-c1" }],
},
],
};
assert.deepEqual(expected, result);
});
});
describe("moveCard", () => {
describe("moveCardToContainer", () => {
it("move a card to an empty view", () => {
const config: LovelaceConfig = {
views: [
@ -68,7 +17,7 @@ describe("moveCard", () => {
],
};
const result = moveCard(config, [1, 0], [0]);
const result = moveCardToContainer(config, [1, 0], [0]);
const expected: LovelaceConfig = {
views: [
{
@ -94,7 +43,7 @@ describe("moveCard", () => {
],
};
const result = moveCard(config, [1, 0], [0]);
const result = moveCardToContainer(config, [1, 0], [0]);
const expected: LovelaceConfig = {
views: [
{
@ -121,12 +70,12 @@ describe("moveCard", () => {
};
const result = () => {
moveCard(config, [1, 0], [1]);
moveCardToContainer(config, [1, 0], [1]);
};
assert.throws(
result,
Error,
"You cannot move a card to the view it is in."
"You cannot move a card to the view or section it is in."
);
});
});