From d95bf64edf85f226a47e00b2436d3b543cf4af82 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 22 Feb 2024 20:51:48 +0100 Subject: [PATCH] Add experimental sections view (#19846) --- src/common/util/array-move.ts | 8 +- src/components/ha-sortable.ts | 44 +- src/data/lovelace.ts | 13 + src/data/lovelace/config/section.ts | 26 ++ src/data/lovelace/config/view.ts | 2 + .../device-detail/ha-device-entities-card.ts | 8 +- src/panels/lovelace/cards/hui-button-card.ts | 10 + src/panels/lovelace/cards/hui-sensor-card.ts | 4 + src/panels/lovelace/cards/hui-tile-card.ts | 31 +- src/panels/lovelace/cards/types.ts | 2 +- .../common/generate-lovelace-config.ts | 22 +- .../lovelace/components/hui-card-edit-mode.ts | 303 +++++++++++++ .../lovelace/components/hui-card-options.ts | 69 +-- .../create-element/create-card-element.ts | 1 + .../create-element/create-element-base.ts | 17 +- .../create-element/create-section-element.ts | 19 + .../create-element/create-view-element.ts | 1 + .../lovelace/editor/add-entities-to-view.ts | 5 + .../editor/card-editor/hui-card-picker.ts | 176 ++++++-- .../card-editor/hui-dialog-create-card.ts | 65 ++- .../card-editor/hui-dialog-edit-card.ts | 65 +-- .../card-editor/hui-dialog-suggest-card.ts | 111 ++++- .../editor/card-editor/hui-section-preview.ts | 104 +++++ .../card-editor/show-create-card-dialog.ts | 4 +- .../card-editor/show-edit-card-dialog.ts | 16 +- .../card-editor/show-suggest-card-dialog.ts | 7 +- src/panels/lovelace/editor/config-util.ts | 403 ++++++++---------- src/panels/lovelace/editor/delete-card.ts | 19 +- src/panels/lovelace/editor/lovelace-path.ts | 197 +++++++++ src/panels/lovelace/editor/types.ts | 1 + .../unused-entities/hui-unused-entities.ts | 9 +- .../editor/view-editor/hui-view-editor.ts | 4 +- src/panels/lovelace/sections/const.ts | 2 + .../lovelace/sections/hui-error-section.ts | 60 +++ .../lovelace/sections/hui-grid-section.ts | 246 +++++++++++ src/panels/lovelace/sections/hui-section.ts | 247 +++++++++++ .../lovelace/strategies/get-strategy.ts | 46 +- src/panels/lovelace/strategies/types.ts | 6 +- src/panels/lovelace/types.ts | 1 + src/panels/lovelace/views/const.ts | 7 +- .../lovelace/views/hui-sections-view.ts | 322 ++++++++++++++ src/panels/lovelace/views/hui-view.ts | 112 ++++- src/translations/en.json | 27 +- .../lovelace/editor/config-util.spec.ts | 65 +-- 44 files changed, 2423 insertions(+), 484 deletions(-) create mode 100644 src/data/lovelace/config/section.ts create mode 100644 src/panels/lovelace/components/hui-card-edit-mode.ts create mode 100644 src/panels/lovelace/create-element/create-section-element.ts create mode 100644 src/panels/lovelace/editor/card-editor/hui-section-preview.ts create mode 100644 src/panels/lovelace/editor/lovelace-path.ts create mode 100644 src/panels/lovelace/sections/const.ts create mode 100644 src/panels/lovelace/sections/hui-error-section.ts create mode 100644 src/panels/lovelace/sections/hui-grid-section.ts create mode 100644 src/panels/lovelace/sections/hui-section.ts create mode 100644 src/panels/lovelace/views/hui-sections-view.ts diff --git a/src/common/util/array-move.ts b/src/common/util/array-move.ts index 36a152a019..0985f764c0 100644 --- a/src/common/util/array-move.ts +++ b/src/common/util/array-move.ts @@ -20,14 +20,14 @@ function findNestedItem( }, obj); } -export function nestedArrayMove( - obj: T | T[], +export function nestedArrayMove( + 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; diff --git a/src/components/ha-sortable.ts b/src/components/ha-sortable.ts index 5258c2fc5f..1c83eef950 100644 --- a/src/components/ha-sortable.ts +++ b/src/components/ha-sortable.ts @@ -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) { 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); }; diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index 1d6ba571ac..48de53e7d6 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -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; 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; + isStrategy: boolean; + setConfig(config: LovelaceSectionConfig): void; +} + type LovelaceUpdatedEvent = HassEventBase & { event_type: "lovelace_updated"; data: { diff --git a/src/data/lovelace/config/section.ts b/src/data/lovelace/config/section.ts new file mode 100644 index 0000000000..1c21688585 --- /dev/null +++ b/src/data/lovelace/config/section.ts @@ -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; +} diff --git a/src/data/lovelace/config/view.ts b/src/data/lovelace/config/view.ts index 0c522b60a1..10b454c15d 100644 --- a/src/data/lovelace/config/view.ts +++ b/src/data/lovelace/config/view.ts @@ -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; cards?: LovelaceCardConfig[]; + sections?: LovelaceSectionRawConfig[]; } export interface LovelaceStrategyViewConfig extends LovelaceBaseViewConfig { diff --git a/src/panels/config/devices/device-detail/ha-device-entities-card.ts b/src/panels/config/devices/device-detail/ha-device-entities-card.ts index d594df6952..71a8b0acdf 100644 --- a/src/panels/config/devices/device-detail/ha-device-entities-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-entities-card.ts @@ -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 ); } diff --git a/src/panels/lovelace/cards/hui-button-card.ts b/src/panels/lovelace/cards/hui-button-card.ts index d738a1be6c..129390fe5b 100644 --- a/src/panels/lovelace/cards/hui-button-card.ts +++ b/src/panels/lovelace/cards/hui-button-card.ts @@ -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"); diff --git a/src/panels/lovelace/cards/hui-sensor-card.ts b/src/panels/lovelace/cards/hui-sensor-card.ts index ee7937e544..d90858862d 100644 --- a/src/panels/lovelace/cards/hui-sensor-card.ts +++ b/src/panels/lovelace/cards/hui-sensor-card.ts @@ -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, diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index f85f182f88..1d56cc66bd 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -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} > - + ${this._config.features + ? html` + + ` + : nothing} `; } @@ -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); diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index f87dde26cd..8a57d1b0d5 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -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; diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 2b5437a93d..c609f66dbd 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -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 => ({ + 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[], diff --git a/src/panels/lovelace/components/hui-card-edit-mode.ts b/src/panels/lovelace/components/hui-card-edit-mode.ts new file mode 100644 index 0000000000..bae22490ba --- /dev/null +++ b/src/panels/lovelace/components/hui-card-edit-mode.ts @@ -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` +
+
+
+
+ +
+ + + + + + ${this.hass.localize( + "ui.panel.lovelace.editor.edit_card.duplicate" + )} + + + + ${this.hass.localize("ui.panel.lovelace.editor.edit_card.copy")} + + + + ${this.hass.localize("ui.panel.lovelace.editor.edit_card.cut")} + +
  • + + ${this.hass.localize("ui.panel.lovelace.editor.edit_card.delete")} + + +
    +
    + `; + } + + private _handleOpened() { + this._menuOpened = true; + } + + private _handleClosed() { + this._menuOpened = false; + } + + private _handleAction(ev: CustomEvent) { + 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; + } +} diff --git a/src/panels/lovelace/components/hui-card-options.ts b/src/panels/lovelace/components/hui-card-options.ts index 8d960e15f8..ec444bb954 100644 --- a/src/panels/lovelace/components/hui-card-options.ts +++ b/src/panels/lovelace/components/hui-card-options.ts @@ -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; @@ -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`
    @@ -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} > -
    ${this.path![1] + 1}
    +
    ${cardIndex + 1}
    ` : 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 { 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!) diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 3619a13c8a..4a9a02fd67 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -24,6 +24,7 @@ const ALWAYS_LOADED_TYPES = new Set([ "entity-button", "glance", "grid", + "section", "light", "sensor", "thermostat", diff --git a/src/panels/lovelace/create-element/create-element-base.ts b/src/panels/lovelace/create-element/create-element-base.ts index d6d4c8d53f..48bf11bef9 100644 --- a/src/panels/lovelace/create-element/create-element-base.ts +++ b/src/panels/lovelace/create-element/create-element-base.ts @@ -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) => { diff --git a/src/panels/lovelace/create-element/create-section-element.ts b/src/panels/lovelace/create-element/create-section-element.ts new file mode 100644 index 0000000000..7b63d2742e --- /dev/null +++ b/src/panels/lovelace/create-element/create-section-element.ts @@ -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 + ); diff --git a/src/panels/lovelace/create-element/create-view-element.ts b/src/panels/lovelace/create-element/create-view-element.ts index 158cf2da31..65bd3e30b2 100644 --- a/src/panels/lovelace/create-element/create-view-element.ts +++ b/src/panels/lovelace/create-element/create-view-element.ts @@ -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 = ( diff --git a/src/panels/lovelace/editor/add-entities-to-view.ts b/src/panels/lovelace/editor/add-entities-to-view.ts index 03b9a44ac2..8706287c03 100644 --- a/src/panels/lovelace/editor/add-entities-to-view.ts +++ b/src/panels/lovelace/editor/add-entities-to-view.ts @@ -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 => { try { @@ -116,6 +120,7 @@ export const addEntitiesToLovelaceView = async ( viewSelectedCallback: (newUrlPath, selectedDashConfig, viewIndex) => { showSuggestCardDialog(element, { cardConfig, + sectionConfig, lovelaceConfig: selectedDashConfig, saveConfig: async (newConfig: LovelaceConfig): Promise => { try { diff --git a/src/panels/lovelace/editor/card-editor/hui-card-picker.ts b/src/panels/lovelace/editor/card-editor/hui-card-picker.ts index 382914e87f..2577822756 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-picker.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-picker.ts @@ -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`
    - ${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` -
    - -
    - ` + ${this._filter + ? this._filterCards(this._cards, this._filter).map( + (cardElement: CardElement) => cardElement.element + ) + : html` + ${suggestedCards.length > 0 + ? html` +
    + ${this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.suggested_cards` + )} +
    + ` + : 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` +
    + ${this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.other_cards` + )} +
    + ` + : nothing} + ${othersCards.map( + (cardElement: CardElement) => cardElement.element + )} + ${customCardsItems.length > 0 + ? html` +
    + ${this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.custom_cards` + )} +
    + ` + : nothing} + ${customCardsItems.map( + (cardElement: CardElement) => cardElement.element + )} + `}
    { + 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` +
    + +
    + ` + )}`; + } + 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); + } `, ]; } diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts index 3c7798411f..89a20ab0f5 100644 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts @@ -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 { 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` = {}; + + 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(); diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts index 44e4703567..c27b7024a2 100644 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts @@ -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! ) ); diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-suggest-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-suggest-card.ts index 8cf248ac01..80e517d5ce 100644 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-suggest-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-suggest-card.ts @@ -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` +
    + +
    + `; + } + if (this._cardConfig) { + return html` +
    + ${this._cardConfig.map( + (cardConfig) => html` + + ` + )} +
    + `; + } + return nothing; + } + protected render() { if (!this._params) { return nothing; @@ -56,20 +108,7 @@ export class HuiDialogSuggestCard extends LitElement { )} >
    - ${this._cardConfig - ? html` -
    - ${this._cardConfig.map( - (cardConfig) => html` - - ` - )} -
    - ` - : ""} + ${this._renderPreview()} ${this._params.yaml && this._cardConfig ? html`
    @@ -79,7 +118,7 @@ export class HuiDialogSuggestCard extends LitElement { >
    ` - : ""} + : nothing}
    { 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(); diff --git a/src/panels/lovelace/editor/card-editor/hui-section-preview.ts b/src/panels/lovelace/editor/card-editor/hui-section-preview.ts new file mode 100644 index 0000000000..c569fcf4c1 --- /dev/null +++ b/src/panels/lovelace/editor/card-editor/hui-section-preview.ts @@ -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; + } +} diff --git a/src/panels/lovelace/editor/card-editor/show-create-card-dialog.ts b/src/panels/lovelace/editor/card-editor/show-create-card-dialog.ts index 230047d399..96c58879b2 100644 --- a/src/panels/lovelace/editor/card-editor/show-create-card-dialog.ts +++ b/src/panels/lovelace/editor/card-editor/show-create-card-dialog.ts @@ -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 } diff --git a/src/panels/lovelace/editor/card-editor/show-edit-card-dialog.ts b/src/panels/lovelace/editor/card-editor/show-edit-card-dialog.ts index 46070fd7ff..40391f6a13 100644 --- a/src/panels/lovelace/editor/card-editor/show-edit-card-dialog.ts +++ b/src/panels/lovelace/editor/card-editor/show-edit-card-dialog.ts @@ -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"); diff --git a/src/panels/lovelace/editor/card-editor/show-suggest-card-dialog.ts b/src/panels/lovelace/editor/card-editor/show-suggest-card-dialog.ts index ea43443d06..c95fed399e 100644 --- a/src/panels/lovelace/editor/card-editor/show-suggest-card-dialog.ts +++ b/src/panels/lovelace/editor/card-editor/show-suggest-card-dialog.ts @@ -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"); diff --git a/src/panels/lovelace/editor/config-util.ts b/src/panels/lovelace/editor/config-util.ts index f494544fb6..09d2e611fa 100644 --- a/src/panels/lovelace/editor/config-util.ts +++ b/src/panels/lovelace/editor/config-util.ts @@ -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; +}; diff --git a/src/panels/lovelace/editor/delete-card.ts b/src/panels/lovelace/editor/delete-card.ts index f4b2c08b09..f3264bc023 100644 --- a/src/panels/lovelace/editor/delete-card.ts +++ b/src/panels/lovelace/editor/delete-card.ts @@ -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 { - 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 () => { diff --git a/src/panels/lovelace/editor/lovelace-path.ts b/src/panels/lovelace/editor/lovelace-path.ts new file mode 100644 index 0000000000..2eaf9dadc6 --- /dev/null +++ b/src/panels/lovelace/editor/lovelace-path.ts @@ -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, + }; +}; diff --git a/src/panels/lovelace/editor/types.ts b/src/panels/lovelace/editor/types.ts index 1edab843d1..44c42beac8 100644 --- a/src/panels/lovelace/editor/types.ts +++ b/src/panels/lovelace/editor/types.ts @@ -62,6 +62,7 @@ export interface Card { description?: string; showElement?: boolean; isCustom?: boolean; + isSuggested?: boolean; } export interface HeaderFooter { diff --git a/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts b/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts index 98cd3f3163..dadd34449e 100644 --- a/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts +++ b/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts @@ -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, }); }, }); diff --git a/src/panels/lovelace/editor/view-editor/hui-view-editor.ts b/src/panels/lovelace/editor/view-editor/hui-view-editor.ts index b693848a8d..3c4326d394 100644 --- a/src/panels/lovelace/editor/view-editor/hui-view-editor.ts +++ b/src/panels/lovelace/editor/view-editor/hui-view-editor.ts @@ -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, diff --git a/src/panels/lovelace/sections/const.ts b/src/panels/lovelace/sections/const.ts new file mode 100644 index 0000000000..8551f0fe92 --- /dev/null +++ b/src/panels/lovelace/sections/const.ts @@ -0,0 +1,2 @@ +export const GRID_SECTION_LAYOUT = "grid"; +export const DEFAULT_SECTION_LAYOUT = GRID_SECTION_LAYOUT; diff --git a/src/panels/lovelace/sections/hui-error-section.ts b/src/panels/lovelace/sections/hui-error-section.ts new file mode 100644 index 0000000000..801f6f0379 --- /dev/null +++ b/src/panels/lovelace/sections/hui-error-section.ts @@ -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` +

    Error

    +

    ${this._config.error}

    + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-error-section": HuiErrorSection; + } +} diff --git a/src/panels/lovelace/sections/hui-grid-section.ts b/src/panels/lovelace/sections/hui-grid-section.ts new file mode 100644 index 0000000000..1a6ed30ff7 --- /dev/null +++ b/src/panels/lovelace/sections/hui-grid-section.ts @@ -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(); + + 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` +

    + ${this._config.title || + this.hass.localize( + "ui.panel.lovelace.editor.section.unnamed_section" + )} +

    + ` + : nothing} + +
    + ${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` +
    + ${editMode + ? html` + + ${card} + + ` + : card} +
    + `; + } + )} + ${editMode + ? html` + + ` + : nothing} +
    +
    + `; + } + + 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); diff --git a/src/panels/lovelace/sections/hui-section.ts b/src/panels/lovelace/sections/hui-section.ts new file mode 100644 index 0000000000..b50b1df18a --- /dev/null +++ b/src/panels/lovelace/sections/hui-section.ts @@ -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 = []; + + 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): 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; + } +} diff --git a/src/panels/lovelace/strategies/get-strategy.ts b/src/panels/lovelace/strategies/get-strategy.ts index ee62115275..927089dedd 100644 --- a/src/panels/lovelace/strategies/get-strategy.ts +++ b/src/panels/lovelace/strategies/get-strategy.ts @@ -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> = { "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 = AsyncReturnType< @@ -163,6 +166,24 @@ export const generateLovelaceViewStrategy = async ( hass ); +export const generateLovelaceSectionStrategy = async ( + strategyConfig: LovelaceStrategyConfig, + hass: HomeAssistant +): Promise => + 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; diff --git a/src/panels/lovelace/strategies/types.ts b/src/panels/lovelace/strategies/types.ts index da2ab8ee3c..ae1be1bbca 100644 --- a/src/panels/lovelace/strategies/types.ts +++ b/src/panels/lovelace/strategies/types.ts @@ -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 {} +export interface LovelaceSectionStrategy + extends LovelaceStrategy {} + export interface LovelaceStrategyEditor extends LovelaceGenericElementEditor { setConfig(config: LovelaceStrategyConfig): void; } diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index b1d42984b8..31a87961f2 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -44,6 +44,7 @@ export interface LovelaceCard extends HTMLElement { isPanel?: boolean; editMode?: boolean; getCardSize(): number | Promise; + getGridSize?(): [number, number]; setConfig(config: LovelaceCardConfig): void; } diff --git a/src/panels/lovelace/views/const.ts b/src/panels/lovelace/views/const.ts index 5cc4709bbb..5633f05bbc 100644 --- a/src/panels/lovelace/views/const.ts +++ b/src/panels/lovelace/views/const.ts @@ -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, +]; diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts new file mode 100644 index 0000000000..6e80a6266f --- /dev/null +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -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(); + + 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` + +
    + ${repeat( + sectionsConfig, + (sectionConfig) => this._getKey(sectionConfig), + (_sectionConfig, idx) => { + const section = this.sections[idx]; + (section as any).itemPath = [idx]; + return html` +
    + ${editMode + ? html` +
    +
    + + + +
    +
    + ` + : nothing} +
    ${section}
    +
    + `; + } + )} + ${editMode + ? html` + + ` + : nothing} +
    +
    + `; + } + + 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; + } +} diff --git a/src/panels/lovelace/views/hui-view.ts b/src/panels/lovelace/views/hui-view.ts index 4338bc9eff..6d35d6a715 100644 --- a/src/panels/lovelace/views/hui-view.ts +++ b/src/panels/lovelace/views/hui-view.ts @@ -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; @@ -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 { diff --git a/src/translations/en.json b/src/translations/en.json index 1195ceb346..972c6753f5 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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", diff --git a/test/panels/lovelace/editor/config-util.spec.ts b/test/panels/lovelace/editor/config-util.spec.ts index a437b28cfa..b625636736 100644 --- a/test/panels/lovelace/editor/config-util.spec.ts +++ b/test/panels/lovelace/editor/config-util.spec.ts @@ -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." ); }); });