diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index bfd794ef8d..1b4f47aa38 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -3,9 +3,10 @@ import { getCollection, HassEventBase, } from "home-assistant-js-websocket"; +import { HuiBadge } from "../panels/lovelace/badges/hui-badge"; import type { HuiCard } from "../panels/lovelace/cards/hui-card"; import type { HuiSection } from "../panels/lovelace/sections/hui-section"; -import { Lovelace, LovelaceBadge } from "../panels/lovelace/types"; +import { Lovelace } from "../panels/lovelace/types"; import { HomeAssistant } from "../types"; import { LovelaceSectionConfig } from "./lovelace/config/section"; import { fetchConfig, LegacyLovelaceConfig } from "./lovelace/config/types"; @@ -21,7 +22,7 @@ export interface LovelaceViewElement extends HTMLElement { narrow?: boolean; index?: number; cards?: HuiCard[]; - badges?: LovelaceBadge[]; + badges?: HuiBadge[]; sections?: HuiSection[]; isStrategy: boolean; setConfig(config: LovelaceViewConfig): void; diff --git a/src/data/lovelace/config/badge.ts b/src/data/lovelace/config/badge.ts index 661464a935..7226adc982 100644 --- a/src/data/lovelace/config/badge.ts +++ b/src/data/lovelace/config/badge.ts @@ -1,4 +1,12 @@ +import { Condition } from "../../../panels/lovelace/common/validate-condition"; + export interface LovelaceBadgeConfig { type?: string; [key: string]: any; + visibility?: Condition[]; } + +export const defaultBadgeConfig = (entity_id: string): LovelaceBadgeConfig => ({ + type: "entity", + entity: entity_id, +}); diff --git a/src/data/lovelace/config/view.ts b/src/data/lovelace/config/view.ts index db0385173d..708d307ab5 100644 --- a/src/data/lovelace/config/view.ts +++ b/src/data/lovelace/config/view.ts @@ -27,7 +27,7 @@ export interface LovelaceBaseViewConfig { export interface LovelaceViewConfig extends LovelaceBaseViewConfig { type?: string; - badges?: Array; + badges?: (string | LovelaceBadgeConfig)[]; // Badge can be just an entity_id cards?: LovelaceCardConfig[]; sections?: LovelaceSectionRawConfig[]; } diff --git a/src/data/lovelace_custom_cards.ts b/src/data/lovelace_custom_cards.ts index 0e4a7d79ae..c79cfb4584 100644 --- a/src/data/lovelace_custom_cards.ts +++ b/src/data/lovelace_custom_cards.ts @@ -8,6 +8,14 @@ export interface CustomCardEntry { documentationURL?: string; } +export interface CustomBadgeEntry { + type: string; + name?: string; + description?: string; + preview?: boolean; + documentationURL?: string; +} + export interface CustomCardFeatureEntry { type: string; name?: string; @@ -18,6 +26,7 @@ export interface CustomCardFeatureEntry { export interface CustomCardsWindow { customCards?: CustomCardEntry[]; customCardFeatures?: CustomCardFeatureEntry[]; + customBadges?: CustomBadgeEntry[]; /** * @deprecated Use customCardFeatures */ @@ -34,6 +43,9 @@ if (!("customCards" in customCardsWindow)) { if (!("customCardFeatures" in customCardsWindow)) { customCardsWindow.customCardFeatures = []; } +if (!("customBadges" in customCardsWindow)) { + customCardsWindow.customBadges = []; +} if (!("customTileFeatures" in customCardsWindow)) { customCardsWindow.customTileFeatures = []; } @@ -43,10 +55,14 @@ export const getCustomCardFeatures = () => [ ...customCardsWindow.customCardFeatures!, ...customCardsWindow.customTileFeatures!, ]; +export const customBadges = customCardsWindow.customBadges!; export const getCustomCardEntry = (type: string) => customCards.find((card) => card.type === type); +export const getCustomBadgeEntry = (type: string) => + customBadges.find((badge) => badge.type === type); + export const isCustomType = (type: string) => type.startsWith(CUSTOM_TYPE_PREFIX); diff --git a/src/panels/lovelace/badges/hui-badge.ts b/src/panels/lovelace/badges/hui-badge.ts new file mode 100644 index 0000000000..9b362fd173 --- /dev/null +++ b/src/panels/lovelace/badges/hui-badge.ts @@ -0,0 +1,200 @@ +import { PropertyValues, ReactiveElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { MediaQueriesListener } from "../../../common/dom/media_query"; +import "../../../components/ha-svg-icon"; +import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; +import type { HomeAssistant } from "../../../types"; +import { + attachConditionMediaQueriesListeners, + checkConditionsMet, +} from "../common/validate-condition"; +import { createBadgeElement } from "../create-element/create-badge-element"; +import { createErrorBadgeConfig } from "../create-element/create-element-base"; +import type { LovelaceBadge } from "../types"; + +declare global { + interface HASSDomEvents { + "badge-updated": undefined; + } +} + +@customElement("hui-badge") +export class HuiBadge extends ReactiveElement { + @property({ type: Boolean }) public preview = false; + + @property({ attribute: false }) public config?: LovelaceBadgeConfig; + + @property({ attribute: false }) public hass?: HomeAssistant; + + private _elementConfig?: LovelaceBadgeConfig; + + public load() { + if (!this.config) { + throw new Error("Cannot build badge without config"); + } + this._loadElement(this.config); + } + + private _element?: LovelaceBadge; + + private _listeners: MediaQueriesListener[] = []; + + protected createRenderRoot() { + return this; + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._clearMediaQueries(); + } + + public connectedCallback() { + super.connectedCallback(); + this._listenMediaQueries(); + this._updateVisibility(); + } + + private _updateElement(config: LovelaceBadgeConfig) { + if (!this._element) { + return; + } + this._element.setConfig(config); + this._elementConfig = config; + fireEvent(this, "badge-updated"); + } + + private _loadElement(config: LovelaceBadgeConfig) { + this._element = createBadgeElement(config); + this._elementConfig = config; + if (this.hass) { + this._element.hass = this.hass; + } + this._element.addEventListener( + "ll-upgrade", + (ev: Event) => { + ev.stopPropagation(); + if (this.hass) { + this._element!.hass = this.hass; + } + fireEvent(this, "badge-updated"); + }, + { once: true } + ); + this._element.addEventListener( + "ll-rebuild", + (ev: Event) => { + ev.stopPropagation(); + this._loadElement(config); + fireEvent(this, "badge-updated"); + }, + { once: true } + ); + while (this.lastChild) { + this.removeChild(this.lastChild); + } + this._updateVisibility(); + } + + protected willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + + if (!this._element) { + this.load(); + } + } + + protected update(changedProps: PropertyValues) { + super.update(changedProps); + + if (this._element) { + if (changedProps.has("config")) { + const elementConfig = this._elementConfig; + if (this.config !== elementConfig && this.config) { + const typeChanged = this.config?.type !== elementConfig?.type; + if (typeChanged) { + this._loadElement(this.config); + } else { + this._updateElement(this.config); + } + } + } + if (changedProps.has("hass")) { + try { + if (this.hass) { + this._element.hass = this.hass; + } + } catch (e: any) { + this._loadElement(createErrorBadgeConfig(e.message, null)); + } + } + } + + if (changedProps.has("hass") || changedProps.has("preview")) { + this._updateVisibility(); + } + } + + private _clearMediaQueries() { + this._listeners.forEach((unsub) => unsub()); + this._listeners = []; + } + + private _listenMediaQueries() { + this._clearMediaQueries(); + if (!this.config?.visibility) { + return; + } + const conditions = this.config.visibility; + const hasOnlyMediaQuery = + conditions.length === 1 && + conditions[0].condition === "screen" && + !!conditions[0].media_query; + + this._listeners = attachConditionMediaQueriesListeners( + this.config.visibility, + (matches) => { + this._updateVisibility(hasOnlyMediaQuery && matches); + } + ); + } + + private _updateVisibility(forceVisible?: boolean) { + if (!this._element || !this.hass) { + return; + } + + if (this._element.hidden) { + this._setElementVisibility(false); + return; + } + + const visible = + forceVisible || + this.preview || + !this.config?.visibility || + checkConditionsMet(this.config.visibility, this.hass); + this._setElementVisibility(visible); + } + + private _setElementVisibility(visible: boolean) { + if (!this._element) return; + + if (this.hidden !== !visible) { + this.style.setProperty("display", visible ? "" : "none"); + this.toggleAttribute("hidden", !visible); + } + + if (!visible && this._element.parentElement) { + this.removeChild(this._element); + } else if (visible && !this._element.parentElement) { + this.appendChild(this._element); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-badge": HuiBadge; + } +} diff --git a/src/panels/lovelace/badges/hui-entity-badge.ts b/src/panels/lovelace/badges/hui-entity-badge.ts new file mode 100644 index 0000000000..05a6839da0 --- /dev/null +++ b/src/panels/lovelace/badges/hui-entity-badge.ts @@ -0,0 +1,306 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; +import { styleMap } from "lit/directives/style-map"; +import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; +import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { stateActive } from "../../../common/entity/state_active"; +import { stateColorCss } from "../../../common/entity/state_color"; +import "../../../components/ha-ripple"; +import "../../../components/ha-state-icon"; +import { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; +import { HomeAssistant } from "../../../types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { findEntities } from "../common/find-entities"; +import { handleAction } from "../common/handle-action"; +import { hasAction } from "../common/has-action"; +import { LovelaceBadge, LovelaceBadgeEditor } from "../types"; +import { EntityBadgeConfig } from "./types"; +import { computeStateDomain } from "../../../common/entity/compute_state_domain"; +import { cameraUrlWithWidthHeight } from "../../../data/camera"; + +export const DISPLAY_TYPES = ["minimal", "standard", "complete"] as const; + +export type DisplayType = (typeof DISPLAY_TYPES)[number]; + +export const DEFAULT_DISPLAY_TYPE: DisplayType = "standard"; + +@customElement("hui-entity-badge") +export class HuiEntityBadge extends LitElement implements LovelaceBadge { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-entity-badge-editor"); + return document.createElement("hui-entity-badge-editor"); + } + + public static getStubConfig( + hass: HomeAssistant, + entities: string[], + entitiesFallback: string[] + ): EntityBadgeConfig { + const includeDomains = ["sensor", "light", "switch"]; + const maxEntities = 1; + const foundEntities = findEntities( + hass, + maxEntities, + entities, + entitiesFallback, + includeDomains + ); + + return { + type: "entity", + entity: foundEntities[0] || "", + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() protected _config?: EntityBadgeConfig; + + public setConfig(config: EntityBadgeConfig): void { + this._config = config; + } + + get hasAction() { + return ( + !this._config?.tap_action || + hasAction(this._config?.tap_action) || + hasAction(this._config?.hold_action) || + hasAction(this._config?.double_tap_action) + ); + } + + private _computeStateColor = memoizeOne( + (stateObj: HassEntity, color?: string) => { + // Use custom color if active + if (color) { + return stateActive(stateObj) ? computeCssColor(color) : undefined; + } + + // Use light color if the light support rgb + if ( + computeDomain(stateObj.entity_id) === "light" && + stateObj.attributes.rgb_color + ) { + const hsvColor = rgb2hsv(stateObj.attributes.rgb_color); + + // Modify the real rgb color for better contrast + if (hsvColor[1] < 0.4) { + // Special case for very light color (e.g: white) + if (hsvColor[1] < 0.1) { + hsvColor[2] = 225; + } else { + hsvColor[1] = 0.4; + } + } + return rgb2hex(hsv2rgb(hsvColor)); + } + + // Fallback to state color + return stateColorCss(stateObj); + } + ); + + private _getImageUrl(stateObj: HassEntity): string | undefined { + const entityPicture = + stateObj.attributes.entity_picture_local || + stateObj.attributes.entity_picture; + + if (!entityPicture) return undefined; + + let imageUrl = this.hass!.hassUrl(entityPicture); + if (computeStateDomain(stateObj) === "camera") { + imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32); + } + + return imageUrl; + } + + protected render() { + if (!this._config || !this.hass) { + return nothing; + } + + const entityId = this._config.entity; + const stateObj = entityId ? this.hass.states[entityId] : undefined; + + if (!stateObj) { + return nothing; + } + + const active = stateActive(stateObj); + const color = this._computeStateColor(stateObj, this._config.color); + + const style = { + "--badge-color": color, + }; + + const stateDisplay = html` + + + `; + + const name = this._config.name || stateObj.attributes.friendly_name; + + const displayType = this._config.display_type || DEFAULT_DISPLAY_TYPE; + + const imageUrl = this._config.show_entity_picture + ? this._getImageUrl(stateObj) + : undefined; + + return html` +
+ + ${imageUrl + ? html`` + : html` + + `} + ${displayType !== "minimal" + ? html` + + ${displayType === "complete" + ? html`${name}` + : nothing} + ${stateDisplay} + + ` + : nothing} +
+ `; + } + + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action!); + } + + static get styles(): CSSResultGroup { + return css` + :host { + --badge-color: var(--state-inactive-color); + -webkit-tap-highlight-color: transparent; + } + .badge { + position: relative; + --ha-ripple-color: var(--badge-color); + --ha-ripple-hover-opacity: 0.04; + --ha-ripple-pressed-opacity: 0.12; + transition: + box-shadow 180ms ease-in-out, + border-color 180ms ease-in-out; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; + height: 36px; + min-width: 36px; + padding: 0px 8px; + box-sizing: border-box; + width: auto; + border-radius: 18px; + background-color: var(--card-background-color, white); + border-width: var(--ha-card-border-width, 1px); + border-style: solid; + border-color: var( + --ha-card-border-color, + var(--divider-color, #e0e0e0) + ); + --mdc-icon-size: 18px; + text-align: center; + font-family: Roboto; + } + .badge:focus-visible { + --shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent); + --shadow-focus: 0 0 0 1px var(--badge-color); + border-color: var(--badge-color); + box-shadow: var(--shadow-default), var(--shadow-focus); + } + button, + [role="button"] { + cursor: pointer; + } + button:focus, + [role="button"]:focus { + outline: none; + } + .badge.active { + --badge-color: var(--primary-color); + } + .content { + display: flex; + flex-direction: column; + align-items: flex-start; + padding-right: 4px; + padding-inline-end: 4px; + padding-inline-start: initial; + } + .name { + font-size: 10px; + font-style: normal; + font-weight: 500; + line-height: 10px; + letter-spacing: 0.1px; + color: var(--secondary-text-color); + } + .state { + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.1px; + color: var(--primary-text-color); + } + ha-state-icon { + color: var(--badge-color); + line-height: 0; + } + img { + width: 30px; + height: 30px; + border-radius: 50%; + object-fit: cover; + overflow: hidden; + } + .badge.minimal { + padding: 0; + } + .badge:not(.minimal) img { + margin-left: -6px; + margin-inline-start: -6px; + margin-inline-end: initial; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-entity-badge": HuiEntityBadge; + } +} diff --git a/src/panels/lovelace/badges/hui-entity-filter-badge.ts b/src/panels/lovelace/badges/hui-entity-filter-badge.ts index e6a889febe..5cba77434c 100644 --- a/src/panels/lovelace/badges/hui-entity-filter-badge.ts +++ b/src/panels/lovelace/badges/hui-entity-filter-badge.ts @@ -8,9 +8,10 @@ import { checkConditionsMet, extractConditionEntityIds, } from "../common/validate-condition"; -import { createBadgeElement } from "../create-element/create-badge-element"; import { EntityFilterEntityConfig } from "../entity-rows/types"; import { LovelaceBadge } from "../types"; +import "./hui-badge"; +import type { HuiBadge } from "./hui-badge"; import { EntityFilterBadgeConfig } from "./types"; @customElement("hui-entity-filter-badge") @@ -18,11 +19,13 @@ export class HuiEntityFilterBadge extends ReactiveElement implements LovelaceBadge { + @property({ attribute: false }) public preview = false; + @property({ attribute: false }) public hass!: HomeAssistant; @state() private _config?: EntityFilterBadgeConfig; - private _elements?: LovelaceBadge[]; + private _elements?: HuiBadge[]; private _configEntities?: EntityFilterEntityConfig[]; @@ -121,8 +124,11 @@ export class HuiEntityFilterBadge if (!isSame) { this._elements = []; for (const badgeConfig of entitiesList) { - const element = createBadgeElement(badgeConfig); + const element = document.createElement("hui-badge"); element.hass = this.hass; + element.preview = this.preview; + element.config = badgeConfig; + element.load(); this._elements.push(element); } this._oldEntities = entitiesList; @@ -140,7 +146,10 @@ export class HuiEntityFilterBadge this.appendChild(element); } - this.style.display = "inline"; + this.style.display = "flex"; + this.style.flexWrap = "wrap"; + this.style.justifyContent = "center"; + this.style.gap = "8px"; } private haveEntitiesChanged(oldHass?: HomeAssistant): boolean { diff --git a/src/panels/lovelace/badges/hui-error-badge.ts b/src/panels/lovelace/badges/hui-error-badge.ts index aa02366f26..19c6e95826 100644 --- a/src/panels/lovelace/badges/hui-error-badge.ts +++ b/src/panels/lovelace/badges/hui-error-badge.ts @@ -1,10 +1,13 @@ -import { mdiAlert } from "@mdi/js"; +import { mdiAlertCircle } from "@mdi/js"; +import { dump } from "js-yaml"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, state } from "lit/decorators"; import "../../../components/ha-label-badge"; import "../../../components/ha-svg-icon"; import { HomeAssistant } from "../../../types"; +import { showAlertDialog } from "../custom-card-helpers"; import { LovelaceBadge } from "../types"; +import { HuiEntityBadge } from "./hui-entity-badge"; import { ErrorBadgeConfig } from "./types"; export const createErrorBadgeElement = (config) => { @@ -28,24 +31,65 @@ export class HuiErrorBadge extends LitElement implements LovelaceBadge { this._config = config; } + private _viewDetail() { + let dumped: string | undefined; + + if (this._config!.origConfig) { + try { + dumped = dump(this._config!.origConfig); + } catch (err: any) { + dumped = `[Error dumping ${this._config!.origConfig}]`; + } + } + + showAlertDialog(this, { + title: this._config?.error, + warning: true, + text: dumped ? html`
${dumped}
` : "", + }); + } + protected render() { if (!this._config) { return nothing; } return html` - - - + `; } static get styles(): CSSResultGroup { - return css` - :host { - --ha-label-badge-color: var(--label-badge-red, #fce588); - } - `; + return [ + HuiEntityBadge.styles, + css` + .badge.error { + --badge-color: var(--error-color); + border-color: var(--badge-color); + } + ha-svg-icon { + color: var(--badge-color); + } + .state { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + pre { + font-family: var(--code-font-family, monospace); + white-space: break-spaces; + user-select: text; + } + `, + ]; } } diff --git a/src/panels/lovelace/badges/hui-view-badges.ts b/src/panels/lovelace/badges/hui-view-badges.ts new file mode 100644 index 0000000000..64bfa106b8 --- /dev/null +++ b/src/panels/lovelace/badges/hui-view-badges.ts @@ -0,0 +1,177 @@ +import { mdiPlus } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-sortable"; +import type { HaSortableOptions } from "../../../components/ha-sortable"; +import "../../../components/ha-svg-icon"; +import { HomeAssistant } from "../../../types"; +import "../components/hui-badge-edit-mode"; +import { moveBadge } from "../editor/config-util"; +import { Lovelace } from "../types"; +import { HuiBadge } from "./hui-badge"; + +const BADGE_SORTABLE_OPTIONS: HaSortableOptions = { + delay: 100, + delayOnTouchOnly: true, + direction: "horizontal", + invertedSwapThreshold: 0.7, +} as HaSortableOptions; + +@customElement("hui-view-badges") +export class HuiViewBadges extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public lovelace!: Lovelace; + + @property({ attribute: false }) public badges: HuiBadge[] = []; + + @property({ attribute: false }) public viewIndex!: number; + + @state() _dragging = false; + + private _badgeConfigKeys = new WeakMap(); + + private _getBadgeKey(badge: HuiBadge) { + if (!this._badgeConfigKeys.has(badge)) { + this._badgeConfigKeys.set(badge, Math.random().toString()); + } + return this._badgeConfigKeys.get(badge)!; + } + + private _badgeMoved(ev) { + ev.stopPropagation(); + const { oldIndex, newIndex, oldPath, newPath } = ev.detail; + const newConfig = moveBadge( + 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 _addBadge() { + fireEvent(this, "ll-create-badge"); + } + + render() { + if (!this.lovelace) return nothing; + + const editMode = this.lovelace.editMode; + + const badges = this.badges; + + return html` + ${badges?.length > 0 || editMode + ? html` + +
+ ${repeat( + badges, + (badge) => this._getBadgeKey(badge), + (badge, idx) => html` + ${editMode + ? html` + + ${badge} + + ` + : badge} + ` + )} + ${editMode + ? html` + + ` + : nothing} +
+
+ ` + : nothing} + `; + } + + static get styles(): CSSResultGroup { + return css` + .badges { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin: 0; + } + + hui-badge-edit-mode { + display: block; + position: relative; + } + + .add { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + height: 36px; + padding: 6px 20px 6px 20px; + box-sizing: border-box; + width: auto; + border-radius: 18px; + background-color: transparent; + border-width: 2px; + border-style: dashed; + border-color: var(--primary-color); + --mdc-icon-size: 18px; + cursor: pointer; + color: var(--primary-text-color); + } + .add:focus { + border-style: solid; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-view-badges": HuiViewBadges; + } +} diff --git a/src/panels/lovelace/badges/types.ts b/src/panels/lovelace/badges/types.ts index 345c1c3d8e..682308d39a 100644 --- a/src/panels/lovelace/badges/types.ts +++ b/src/panels/lovelace/badges/types.ts @@ -13,6 +13,7 @@ export interface EntityFilterBadgeConfig extends LovelaceBadgeConfig { export interface ErrorBadgeConfig extends LovelaceBadgeConfig { error: string; + origConfig: LovelaceBadgeConfig; } export interface StateLabelBadgeConfig extends LovelaceBadgeConfig { @@ -25,3 +26,17 @@ export interface StateLabelBadgeConfig extends LovelaceBadgeConfig { hold_action?: ActionConfig; double_tap_action?: ActionConfig; } + +export interface EntityBadgeConfig extends LovelaceBadgeConfig { + type: "entity"; + entity?: string; + name?: string; + icon?: string; + color?: string; + show_entity_picture?: boolean; + display_type?: "minimal" | "standard" | "complete"; + state_content?: string | string[]; + tap_action?: ActionConfig; + hold_action?: ActionConfig; + double_tap_action?: ActionConfig; +} diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index 1f07d1f6fc..19ece5430b 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -244,7 +244,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard { const color = this._computeStateColor(stateObj, this._config.color); const domain = computeDomain(stateObj.entity_id); - const localizedState = this._config.hide_state + const stateDisplay = this._config.hide_state ? nothing : html` ${this._config.features diff --git a/src/panels/lovelace/components/hui-badge-edit-mode.ts b/src/panels/lovelace/components/hui-badge-edit-mode.ts new file mode 100644 index 0000000000..5ccee7a24a --- /dev/null +++ b/src/panels/lovelace/components/hui-badge-edit-mode.ts @@ -0,0 +1,275 @@ +import { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; +import { + mdiContentDuplicate, + mdiDelete, + mdiDotsVertical, + mdiPencil, +} from "@mdi/js"; +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 { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog"; +import { + LovelaceCardPath, + findLovelaceItems, + getLovelaceContainerPath, + parseLovelaceCardPath, +} from "../editor/lovelace-path"; +import { Lovelace } from "../types"; + +@customElement("hui-badge-edit-mode") +export class HuiBadgeEditMode 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 _badges() { + const containerPath = getLovelaceContainerPath(this.path!); + return findLovelaceItems("badges", 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.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._deleteCard(); + break; + } + } + + private _duplicateCard(): void { + const { cardIndex } = parseLovelaceCardPath(this.path!); + const containerPath = getLovelaceContainerPath(this.path!); + const badgeConfig = this._badges![cardIndex]; + showEditBadgeDialog(this, { + lovelaceConfig: this.lovelace!.config, + saveConfig: this.lovelace!.saveConfig, + path: containerPath, + badgeConfig, + }); + } + + private _editBadge(ev): void { + if (ev.defaultPrevented) { + return; + } + if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + fireEvent(this, "ll-edit-badge", { path: this.path! }); + } + + private _deleteCard(): void { + fireEvent(this, "ll-delete-badge", { path: this.path! }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .badge-overlay { + position: absolute; + opacity: 0; + pointer-events: none; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 180ms ease-in-out; + } + + .badge-overlay.visible { + opacity: 1; + pointer-events: auto; + } + + .badge-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-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: 4px; + background: var(--secondary-background-color); + --mdc-icon-size: 16px; + } + .more { + position: absolute; + right: -8px; + top: -8px; + inset-inline-end: -10px; + inset-inline-start: initial; + } + .more ha-icon-button { + cursor: pointer; + border-radius: 50%; + background: var(--secondary-background-color); + --mdc-icon-button-size: 24px; + --mdc-icon-size: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-badge-edit-mode": HuiBadgeEditMode; + } +} diff --git a/src/panels/lovelace/components/hui-card-edit-mode.ts b/src/panels/lovelace/components/hui-card-edit-mode.ts index a3f611cefe..f66e6c5a5f 100644 --- a/src/panels/lovelace/components/hui-card-edit-mode.ts +++ b/src/panels/lovelace/components/hui-card-edit-mode.ts @@ -24,7 +24,7 @@ import { HomeAssistant } from "../../../types"; import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"; import { LovelaceCardPath, - findLovelaceCards, + findLovelaceItems, getLovelaceContainerPath, parseLovelaceCardPath, } from "../editor/lovelace-path"; @@ -59,7 +59,7 @@ export class HuiCardEditMode extends LitElement { private get _cards() { const containerPath = getLovelaceContainerPath(this.path!); - return findLovelaceCards(this.lovelace!.config, containerPath)!; + return findLovelaceItems("cards", this.lovelace!.config, containerPath)!; } private _touchStarted = false; diff --git a/src/panels/lovelace/components/hui-card-options.ts b/src/panels/lovelace/components/hui-card-options.ts index db87e8d291..163f325895 100644 --- a/src/panels/lovelace/components/hui-card-options.ts +++ b/src/panels/lovelace/components/hui-card-options.ts @@ -46,7 +46,7 @@ import { } from "../editor/config-util"; import { LovelaceCardPath, - findLovelaceCards, + findLovelaceItems, getLovelaceContainerPath, parseLovelaceCardPath, } from "../editor/lovelace-path"; @@ -91,7 +91,7 @@ export class HuiCardOptions extends LitElement { private get _cards() { const containerPath = getLovelaceContainerPath(this.path!); - return findLovelaceCards(this.lovelace!.config, containerPath)!; + return findLovelaceItems("cards", this.lovelace!.config, containerPath)!; } protected render(): TemplateResult { diff --git a/src/panels/lovelace/create-element/create-badge-element.ts b/src/panels/lovelace/create-element/create-badge-element.ts index 2335a48c99..a6f8efeb63 100644 --- a/src/panels/lovelace/create-element/create-badge-element.ts +++ b/src/panels/lovelace/create-element/create-badge-element.ts @@ -1,8 +1,12 @@ import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; +import "../badges/hui-entity-badge"; import "../badges/hui-state-label-badge"; -import { createLovelaceElement } from "./create-element-base"; +import { + createLovelaceElement, + getLovelaceElementClass, +} from "./create-element-base"; -const ALWAYS_LOADED_TYPES = new Set(["error", "state-label"]); +const ALWAYS_LOADED_TYPES = new Set(["error", "state-label", "entity"]); const LAZY_LOAD_TYPES = { "entity-filter": () => import("../badges/hui-entity-filter-badge"), }; @@ -14,5 +18,8 @@ export const createBadgeElement = (config: LovelaceBadgeConfig) => ALWAYS_LOADED_TYPES, LAZY_LOAD_TYPES, undefined, - "state-label" + "entity" ); + +export const getBadgeElementClass = (type: string) => + getLovelaceElementClass(type, "badge", ALWAYS_LOADED_TYPES, LAZY_LOAD_TYPES); diff --git a/src/panels/lovelace/create-element/create-element-base.ts b/src/panels/lovelace/create-element/create-element-base.ts index 5ba763733c..65f3ecbcc5 100644 --- a/src/panels/lovelace/create-element/create-element-base.ts +++ b/src/panels/lovelace/create-element/create-element-base.ts @@ -12,13 +12,13 @@ import { 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 { LovelaceBadge, + LovelaceBadgeConstructor, LovelaceCard, LovelaceCardConstructor, LovelaceCardFeature, @@ -39,7 +39,7 @@ interface CreateElementConfigTypes { badge: { config: LovelaceBadgeConfig; element: LovelaceBadge; - constructor: unknown; + constructor: LovelaceBadgeConstructor; }; element: { config: LovelaceElementConfig; @@ -87,16 +87,36 @@ export const createErrorCardElement = (config: ErrorCardConfig) => { return el; }; +export const createErrorBadgeElement = (config: ErrorCardConfig) => { + const el = document.createElement("hui-error-badge"); + if (customElements.get("hui-error-badge")) { + el.setConfig(config); + } else { + import("../badges/hui-error-badge"); + customElements.whenDefined("hui-error-badge").then(() => { + customElements.upgrade(el); + el.setConfig(config); + }); + } + return el; +}; + export const createErrorCardConfig = (error, origConfig) => ({ type: "error", error, origConfig, }); +export const createErrorBadgeConfig = (error, origConfig) => ({ + type: "error", + error, + origConfig, +}); + const _createElement = ( tag: string, config: CreateElementConfigTypes[T]["config"] -): CreateElementConfigTypes[T]["element"] | HuiErrorCard => { +): CreateElementConfigTypes[T]["element"] => { const element = document.createElement( tag ) as CreateElementConfigTypes[T]["element"]; @@ -106,11 +126,18 @@ const _createElement = ( }; const _createErrorElement = ( + tagSuffix: T, error: string, config: CreateElementConfigTypes[T]["config"] -): HuiErrorCard => createErrorCardElement(createErrorCardConfig(error, config)); +): CreateElementConfigTypes[T]["element"] => { + if (tagSuffix === "badge") { + return createErrorBadgeElement(createErrorBadgeConfig(error, config)); + } + return createErrorCardElement(createErrorCardConfig(error, config)); +}; const _customCreate = ( + tagSuffix: T, tag: string, config: CreateElementConfigTypes[T]["config"] ) => { @@ -119,6 +146,7 @@ const _customCreate = ( } const element = _createErrorElement( + tagSuffix, `Custom element doesn't exist: ${tag}.`, config ); @@ -175,7 +203,7 @@ export const createLovelaceElement = ( domainTypes?: { _domain_not_found: string; [domain: string]: string }, // Default type if no type given. If given, entity types will not work. defaultType?: string -): CreateElementConfigTypes[T]["element"] | HuiErrorCard => { +): CreateElementConfigTypes[T]["element"] => { try { return tryCreateLovelaceElement( tagSuffix, @@ -188,7 +216,7 @@ export const createLovelaceElement = ( } catch (err: any) { // eslint-disable-next-line console.error(tagSuffix, config.type, err); - return _createErrorElement(err.message, config); + return _createErrorElement(tagSuffix, err.message, config); } }; @@ -203,7 +231,7 @@ export const tryCreateLovelaceElement = < domainTypes?: { _domain_not_found: string; [domain: string]: string }, // Default type if no type given. If given, entity types will not work. defaultType?: string -): CreateElementConfigTypes[T]["element"] | HuiErrorCard => { +): CreateElementConfigTypes[T]["element"] => { if (!config || typeof config !== "object") { throw new Error("Config is not an object"); } @@ -220,7 +248,7 @@ export const tryCreateLovelaceElement = < const customTag = config.type ? _getCustomTag(config.type) : undefined; if (customTag) { - return _customCreate(customTag, config); + return _customCreate(tagSuffix, customTag, config); } let type: string | undefined; diff --git a/src/panels/lovelace/editor/badge-editor/hui-badge-element-editor.ts b/src/panels/lovelace/editor/badge-editor/hui-badge-element-editor.ts new file mode 100644 index 0000000000..dad6679e8c --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-badge-element-editor.ts @@ -0,0 +1,109 @@ +import { css, CSSResultGroup, html, nothing, TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators"; +import { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge"; +import { getBadgeElementClass } from "../../create-element/create-badge-element"; +import type { LovelaceCardEditor, LovelaceConfigForm } from "../../types"; +import { HuiElementEditor } from "../hui-element-editor"; +import "./hui-badge-visibility-editor"; + +type Tab = "config" | "visibility"; + +@customElement("hui-badge-element-editor") +export class HuiBadgeElementEditor extends HuiElementEditor { + @state() private _curTab: Tab = "config"; + + protected async getConfigElement(): Promise { + const elClass = await getBadgeElementClass(this.configElementType!); + + // Check if a GUI editor exists + if (elClass && elClass.getConfigElement) { + return elClass.getConfigElement(); + } + + return undefined; + } + + protected async getConfigForm(): Promise { + const elClass = await getBadgeElementClass(this.configElementType!); + + // Check if a schema exists + if (elClass && elClass.getConfigForm) { + return elClass.getConfigForm(); + } + + return undefined; + } + + private _handleTabSelected(ev: CustomEvent): void { + if (!ev.detail.value) { + return; + } + this._curTab = ev.detail.value.id; + } + + private _configChanged(ev: CustomEvent): void { + ev.stopPropagation(); + this.value = ev.detail.value; + } + + protected renderConfigElement(): TemplateResult { + const displayedTabs: Tab[] = ["config", "visibility"]; + + let content: TemplateResult<1> | typeof nothing = nothing; + + switch (this._curTab) { + case "config": + content = html`${super.renderConfigElement()}`; + break; + case "visibility": + content = html` + + `; + break; + } + return html` + + ${displayedTabs.map( + (tab, index) => html` + + ${this.hass.localize( + `ui.panel.lovelace.editor.edit_badge.tab_${tab}` + )} + + ` + )} + + ${content} + `; + } + + static get styles(): CSSResultGroup { + return [ + HuiElementEditor.styles, + css` + paper-tabs { + --paper-tabs-selection-bar-color: var(--primary-color); + color: var(--primary-text-color); + text-transform: uppercase; + margin-bottom: 16px; + border-bottom: 1px solid var(--divider-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-badge-element-editor": HuiBadgeElementEditor; + } +} diff --git a/src/panels/lovelace/editor/badge-editor/hui-badge-visibility-editor.ts b/src/panels/lovelace/editor/badge-editor/hui-badge-visibility-editor.ts new file mode 100644 index 0000000000..f7f1612d9f --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-badge-visibility-editor.ts @@ -0,0 +1,59 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-alert"; +import { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; +import { HomeAssistant } from "../../../../types"; +import { Condition } from "../../common/validate-condition"; +import "../conditions/ha-card-conditions-editor"; + +@customElement("hui-badge-visibility-editor") +export class HuiBadgeVisibilityEditor extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public config!: LovelaceCardConfig; + + render() { + const conditions = this.config.visibility ?? []; + return html` +

    + ${this.hass.localize( + `ui.panel.lovelace.editor.edit_badge.visibility.explanation` + )} +

    + + + `; + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + const conditions = ev.detail.value as Condition[]; + const newConfig: LovelaceCardConfig = { + ...this.config, + visibility: conditions, + }; + if (newConfig.visibility?.length === 0) { + delete newConfig.visibility; + } + fireEvent(this, "value-changed", { value: newConfig }); + } + + static styles = css` + .intro { + margin: 0; + color: var(--secondary-text-color); + margin-bottom: 8px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-badge-visibility-editor": HuiBadgeVisibilityEditor; + } +} diff --git a/src/panels/lovelace/editor/badge-editor/hui-dialog-edit-badge.ts b/src/panels/lovelace/editor/badge-editor/hui-dialog-edit-badge.ts new file mode 100644 index 0000000000..19ae477d5e --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/hui-dialog-edit-badge.ts @@ -0,0 +1,521 @@ +import { mdiClose, mdiHelpCircle } from "@mdi/js"; +import deepFreeze from "deep-freeze"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, +} from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import type { HASSDomEvent } from "../../../../common/dom/fire_event"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeRTLDirection } from "../../../../common/util/compute_rtl"; +import "../../../../components/ha-circular-progress"; +import "../../../../components/ha-dialog"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-icon-button"; +import { + defaultBadgeConfig, + LovelaceBadgeConfig, +} from "../../../../data/lovelace/config/badge"; +import { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; +import { + getCustomBadgeEntry, + 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"; +import type { HomeAssistant } from "../../../../types"; +import { showSaveSuccessToast } from "../../../../util/toast-saved-success"; +import "../../badges/hui-badge"; +import "../../sections/hui-section"; +import { addBadge, replaceBadge } from "../config-util"; +import { getBadgeDocumentationURL } from "../get-dashboard-documentation-url"; +import type { ConfigChangedEvent } from "../hui-element-editor"; +import { findLovelaceContainer } from "../lovelace-path"; +import type { GUIModeChangedEvent } from "../types"; +import "./hui-badge-element-editor"; +import type { HuiBadgeElementEditor } from "./hui-badge-element-editor"; +import type { EditBadgeDialogParams } from "./show-edit-badge-dialog"; + +declare global { + // for fire event + interface HASSDomEvents { + "reload-lovelace": undefined; + } + // for add event listener + interface HTMLElementEventMap { + "reload-lovelace": HASSDomEvent; + } +} + +@customElement("hui-dialog-edit-badge") +export class HuiDialogEditBadge + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public large = false; + + @state() private _params?: EditBadgeDialogParams; + + @state() private _badgeConfig?: LovelaceBadgeConfig; + + @state() private _containerConfig!: LovelaceViewConfig; + + @state() private _saving = false; + + @state() private _error?: string; + + @state() private _guiModeAvailable? = true; + + @query("hui-badge-element-editor") + private _badgeEditorEl?: HuiBadgeElementEditor; + + @state() private _GUImode = true; + + @state() private _documentationURL?: string; + + @state() private _dirty = false; + + @state() private _isEscapeEnabled = true; + + public async showDialog(params: EditBadgeDialogParams): Promise { + this._params = params; + this._GUImode = true; + this._guiModeAvailable = true; + + const containerConfig = findLovelaceContainer( + params.lovelaceConfig, + params.path + ); + + if ("strategy" in containerConfig) { + throw new Error("Can't edit strategy"); + } + + this._containerConfig = containerConfig; + + if ("badgeConfig" in params) { + this._badgeConfig = params.badgeConfig; + this._dirty = true; + } else { + const badge = this._containerConfig.badges?.[params.badgeIndex]; + this._badgeConfig = + typeof badge === "string" ? defaultBadgeConfig(badge) : badge; + } + + this.large = false; + if (this._badgeConfig && !Object.isFrozen(this._badgeConfig)) { + this._badgeConfig = deepFreeze(this._badgeConfig); + } + } + + public closeDialog(): boolean { + this._isEscapeEnabled = true; + window.removeEventListener("dialog-closed", this._enableEscapeKeyClose); + window.removeEventListener("hass-more-info", this._disableEscapeKeyClose); + if (this._dirty) { + this._confirmCancel(); + return false; + } + this._params = undefined; + this._badgeConfig = undefined; + this._error = undefined; + this._documentationURL = undefined; + this._dirty = false; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + return true; + } + + protected updated(changedProps: PropertyValues): void { + if ( + !this._badgeConfig || + this._documentationURL !== undefined || + !changedProps.has("_badgeConfig") + ) { + return; + } + + const oldConfig = changedProps.get("_badgeConfig") as LovelaceBadgeConfig; + + if (oldConfig?.type !== this._badgeConfig!.type) { + this._documentationURL = this._badgeConfig!.type + ? getBadgeDocumentationURL(this.hass, this._badgeConfig!.type) + : undefined; + } + } + + private _enableEscapeKeyClose = (ev: any) => { + if (ev.detail.dialog === "ha-more-info-dialog") { + this._isEscapeEnabled = true; + } + }; + + private _disableEscapeKeyClose = () => { + this._isEscapeEnabled = false; + }; + + protected render() { + if (!this._params) { + return nothing; + } + + let heading: string; + if (this._badgeConfig && this._badgeConfig.type) { + let badgeName: string | undefined; + if (isCustomType(this._badgeConfig.type)) { + // prettier-ignore + badgeName = getCustomBadgeEntry( + stripCustomPrefix(this._badgeConfig.type) + )?.name; + // Trim names that end in " Card" so as not to redundantly duplicate it + if (badgeName?.toLowerCase().endsWith(" badge")) { + badgeName = badgeName.substring(0, badgeName.length - 6); + } + } else { + badgeName = this.hass!.localize( + `ui.panel.lovelace.editor.badge.${this._badgeConfig.type}.name` + ); + } + heading = this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.typed_header", + { type: badgeName } + ); + } else if (!this._badgeConfig) { + heading = this._containerConfig.title + ? this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.pick_badge_view_title", + { name: this._containerConfig.title } + ) + : this.hass!.localize("ui.panel.lovelace.editor.edit_badge.pick_badge"); + } else { + heading = this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.header" + ); + } + + return html` + + + + ${heading} + ${this._documentationURL !== undefined + ? html` + + + + ` + : nothing} + +
    +
    + +
    +
    + + ${this._error + ? html` + + ` + : ``} +
    +
    + ${this._badgeConfig !== undefined + ? html` + + ${this.hass!.localize( + !this._badgeEditorEl || this._GUImode + ? "ui.panel.lovelace.editor.edit_badge.show_code_editor" + : "ui.panel.lovelace.editor.edit_badge.show_visual_editor" + )} + + ` + : ""} +
    + + ${this.hass!.localize("ui.common.cancel")} + + ${this._badgeConfig !== undefined && this._dirty + ? html` + + ${this._saving + ? html` + + ` + : this.hass!.localize("ui.common.save")} + + ` + : ``} +
    +
    + `; + } + + private _enlarge() { + this.large = !this.large; + } + + private _ignoreKeydown(ev: KeyboardEvent) { + ev.stopPropagation(); + } + + private _handleConfigChanged(ev: HASSDomEvent) { + this._badgeConfig = deepFreeze(ev.detail.config); + this._error = ev.detail.error; + this._guiModeAvailable = ev.detail.guiModeAvailable; + this._dirty = true; + } + + private _handleGUIModeChanged(ev: HASSDomEvent): void { + ev.stopPropagation(); + this._GUImode = ev.detail.guiMode; + this._guiModeAvailable = ev.detail.guiModeAvailable; + } + + private _toggleMode(): void { + this._badgeEditorEl?.toggleMode(); + } + + private _opened() { + window.addEventListener("dialog-closed", this._enableEscapeKeyClose); + window.addEventListener("hass-more-info", this._disableEscapeKeyClose); + this._badgeEditorEl?.focusYamlEditor(); + } + + private get _canSave(): boolean { + if (this._saving) { + return false; + } + if (this._badgeConfig === undefined) { + return false; + } + if (this._badgeEditorEl && this._badgeEditorEl.hasError) { + return false; + } + return true; + } + + private async _confirmCancel() { + // Make sure the open state of this dialog is handled before the open state of confirm dialog + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + const confirm = await showConfirmationDialog(this, { + title: this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.unsaved_changes" + ), + text: this.hass!.localize( + "ui.panel.lovelace.editor.edit_badge.confirm_cancel" + ), + dismissText: this.hass!.localize("ui.common.stay"), + confirmText: this.hass!.localize("ui.common.leave"), + }); + if (confirm) { + this._cancel(); + } + } + + private _cancel(ev?: Event) { + if (ev) { + ev.stopPropagation(); + } + this._dirty = false; + this.closeDialog(); + } + + private async _save(): Promise { + if (!this._canSave) { + return; + } + if (!this._dirty) { + this.closeDialog(); + return; + } + this._saving = true; + const path = this._params!.path; + await this._params!.saveConfig( + "badgeConfig" in this._params! + ? addBadge(this._params!.lovelaceConfig, path, this._badgeConfig!) + : replaceBadge( + this._params!.lovelaceConfig, + [...path, this._params!.badgeIndex], + this._badgeConfig! + ) + ); + this._saving = false; + this._dirty = false; + showSaveSuccessToast(this, this.hass); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + :host { + --code-mirror-max-height: calc(100vh - 176px); + } + + ha-dialog { + --mdc-dialog-max-width: 100px; + --dialog-z-index: 6; + --dialog-surface-position: fixed; + --dialog-surface-top: 40px; + --mdc-dialog-max-width: 90vw; + --dialog-content-padding: 24px 12px; + } + + .content { + width: calc(90vw - 48px); + max-width: 1000px; + } + + @media all and (max-width: 450px), all and (max-height: 500px) { + /* overrule the ha-style-dialog max-height on small screens */ + ha-dialog { + height: 100%; + --mdc-dialog-max-height: 100%; + --dialog-surface-top: 0px; + --mdc-dialog-max-width: 100vw; + } + .content { + width: 100%; + max-width: 100%; + } + } + + @media all and (min-width: 451px) and (min-height: 501px) { + :host([large]) .content { + max-width: none; + } + } + + .center { + margin-left: auto; + margin-right: auto; + } + + .content { + display: flex; + flex-direction: column; + } + + .content .element-editor { + margin: 0 10px; + } + + @media (min-width: 1000px) { + .content { + flex-direction: row; + } + .content > * { + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + } + } + .hidden { + display: none; + } + .element-editor { + margin-bottom: 8px; + } + .blur { + filter: blur(2px) grayscale(100%); + } + .element-preview { + position: relative; + height: max-content; + background: var(--primary-background-color); + padding: 10px; + border-radius: 4px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + .element-preview ha-circular-progress { + top: 50%; + left: 50%; + position: absolute; + z-index: 10; + } + .gui-mode-button { + margin-right: auto; + margin-inline-end: auto; + margin-inline-start: initial; + } + .header { + display: flex; + align-items: center; + justify-content: space-between; + } + ha-dialog-header a { + color: inherit; + text-decoration: none; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-dialog-edit-badge": HuiDialogEditBadge; + } +} diff --git a/src/panels/lovelace/editor/badge-editor/show-edit-badge-dialog.ts b/src/panels/lovelace/editor/badge-editor/show-edit-badge-dialog.ts new file mode 100644 index 0000000000..e7640e9887 --- /dev/null +++ b/src/panels/lovelace/editor/badge-editor/show-edit-badge-dialog.ts @@ -0,0 +1,30 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge"; +import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; +import { LovelaceContainerPath } from "../lovelace-path"; + +export type EditBadgeDialogParams = { + lovelaceConfig: LovelaceConfig; + saveConfig: (config: LovelaceConfig) => void; + path: LovelaceContainerPath; +} & ( + | { + badgeIndex: number; + } + | { + badgeConfig: LovelaceBadgeConfig; + } +); + +export const importEditBadgeDialog = () => import("./hui-dialog-edit-badge"); + +export const showEditBadgeDialog = ( + element: HTMLElement, + editBadgeDialogParams: EditBadgeDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "hui-dialog-edit-badge", + dialogImport: importEditBadgeDialog, + dialogParams: editBadgeDialogParams, + }); +}; 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 eb9294f9e5..9b3c3aa9d1 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 @@ -30,15 +30,15 @@ import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import { haStyleDialog } from "../../../../resources/styles"; import type { HomeAssistant } from "../../../../types"; import { showSaveSuccessToast } from "../../../../util/toast-saved-success"; +import "../../cards/hui-card"; import "../../sections/hui-section"; import { addCard, replaceCard } from "../config-util"; -import { getCardDocumentationURL } from "../get-card-documentation-url"; +import { getCardDocumentationURL } from "../get-dashboard-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 "../../cards/hui-card"; import type { EditCardDialogParams } from "./show-edit-card-dialog"; declare global { diff --git a/src/panels/lovelace/editor/config-elements/hui-entity-badge-editor.ts b/src/panels/lovelace/editor/config-elements/hui-entity-badge-editor.ts new file mode 100644 index 0000000000..73f5b65c07 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-entity-badge-editor.ts @@ -0,0 +1,236 @@ +import { mdiGestureTap, mdiPalette } from "@mdi/js"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { + array, + assert, + assign, + boolean, + enums, + object, + optional, + string, + union, +} from "superstruct"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-form/ha-form"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../types"; +import { + DEFAULT_DISPLAY_TYPE, + DISPLAY_TYPES, +} from "../../badges/hui-entity-badge"; +import { EntityBadgeConfig } from "../../badges/types"; +import type { LovelaceBadgeEditor } from "../../types"; +import "../hui-sub-element-editor"; +import { actionConfigStruct } from "../structs/action-struct"; +import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct"; +import { configElementStyle } from "./config-elements-style"; +import "./hui-card-features-editor"; + +const badgeConfigStruct = assign( + baseLovelaceBadgeConfig, + object({ + entity: optional(string()), + display_type: optional(enums(DISPLAY_TYPES)), + name: optional(string()), + icon: optional(string()), + state_content: optional(union([string(), array(string())])), + color: optional(string()), + show_entity_picture: optional(boolean()), + tap_action: optional(actionConfigStruct), + }) +); + +@customElement("hui-entity-badge-editor") +export class HuiEntityBadgeEditor + extends LitElement + implements LovelaceBadgeEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: EntityBadgeConfig; + + public setConfig(config: EntityBadgeConfig): void { + assert(config, badgeConfigStruct); + this._config = config; + } + + private _schema = memoizeOne( + (localize: LocalizeFunc) => + [ + { name: "entity", selector: { entity: {} } }, + { + name: "", + type: "expandable", + iconPath: mdiPalette, + title: localize(`ui.panel.lovelace.editor.badge.entity.appearance`), + schema: [ + { + name: "display_type", + selector: { + select: { + mode: "dropdown", + options: DISPLAY_TYPES.map((type) => ({ + value: type, + label: localize( + `ui.panel.lovelace.editor.badge.entity.display_type_options.${type}` + ), + })), + }, + }, + }, + { + name: "", + type: "grid", + schema: [ + { + name: "name", + selector: { + text: {}, + }, + }, + { + name: "icon", + selector: { + icon: {}, + }, + context: { icon_entity: "entity" }, + }, + { + name: "color", + selector: { + ui_color: { default_color: true }, + }, + }, + { + name: "show_entity_picture", + selector: { + boolean: {}, + }, + }, + ], + }, + + { + name: "state_content", + selector: { + ui_state_content: {}, + }, + context: { + filter_entity: "entity", + }, + }, + ], + }, + { + name: "", + type: "expandable", + title: localize(`ui.panel.lovelace.editor.badge.entity.actions`), + iconPath: mdiGestureTap, + schema: [ + { + name: "tap_action", + selector: { + ui_action: { + default_action: "more-info", + }, + }, + }, + ], + }, + ] as const satisfies readonly HaFormSchema[] + ); + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + const schema = this._schema(this.hass!.localize); + + const data = { ...this._config }; + + if (!data.display_type) { + data.display_type = DEFAULT_DISPLAY_TYPE; + } + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const newConfig = ev.detail.value as EntityBadgeConfig; + + const config: EntityBadgeConfig = { + ...newConfig, + }; + + if (!config.state_content) { + delete config.state_content; + } + + if (config.display_type === "standard") { + delete config.display_type; + } + + fireEvent(this, "config-changed", { config }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "color": + case "state_content": + case "display_type": + case "show_entity_picture": + return this.hass!.localize( + `ui.panel.lovelace.editor.badge.entity.${schema.name}` + ); + default: + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + } + }; + + static get styles() { + return [ + configElementStyle, + css` + .container { + display: flex; + flex-direction: column; + } + ha-form { + display: block; + margin-bottom: 24px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-entity-badge-editor": HuiEntityBadgeEditor; + } +} diff --git a/src/panels/lovelace/editor/config-util.ts b/src/panels/lovelace/editor/config-util.ts index 0207a90e97..7eb5d5f105 100644 --- a/src/panels/lovelace/editor/config-util.ts +++ b/src/panels/lovelace/editor/config-util.ts @@ -1,3 +1,4 @@ +import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section"; import { LovelaceConfig } from "../../../data/lovelace/config/types"; @@ -9,13 +10,13 @@ import type { HomeAssistant } from "../../../types"; import { LovelaceCardPath, LovelaceContainerPath, - findLovelaceCards, findLovelaceContainer, + findLovelaceItems, getLovelaceContainerPath, parseLovelaceCardPath, parseLovelaceContainerPath, - updateLovelaceCards, updateLovelaceContainer, + updateLovelaceItems, } from "./lovelace-path"; export const addCard = ( @@ -23,9 +24,9 @@ export const addCard = ( path: LovelaceContainerPath, cardConfig: LovelaceCardConfig ): LovelaceConfig => { - const cards = findLovelaceCards(config, path); + const cards = findLovelaceItems("cards", config, path); const newCards = cards ? [...cards, cardConfig] : [cardConfig]; - const newConfig = updateLovelaceCards(config, path, newCards); + const newConfig = updateLovelaceItems("cards", config, path, newCards); return newConfig; }; @@ -34,9 +35,9 @@ export const addCards = ( path: LovelaceContainerPath, cardConfigs: LovelaceCardConfig[] ): LovelaceConfig => { - const cards = findLovelaceCards(config, path); + const cards = findLovelaceItems("cards", config, path); const newCards = cards ? [...cards, ...cardConfigs] : [...cardConfigs]; - const newConfig = updateLovelaceCards(config, path, newCards); + const newConfig = updateLovelaceItems("cards", config, path, newCards); return newConfig; }; @@ -48,13 +49,18 @@ export const replaceCard = ( const { cardIndex } = parseLovelaceCardPath(path); const containerPath = getLovelaceContainerPath(path); - const cards = findLovelaceCards(config, containerPath); + const cards = findLovelaceItems("cards", config, containerPath); const newCards = (cards ?? []).map((origConf, ind) => ind === cardIndex ? cardConfig : origConf ); - const newConfig = updateLovelaceCards(config, containerPath, newCards); + const newConfig = updateLovelaceItems( + "cards", + config, + containerPath, + newCards + ); return newConfig; }; @@ -65,11 +71,16 @@ export const deleteCard = ( const { cardIndex } = parseLovelaceCardPath(path); const containerPath = getLovelaceContainerPath(path); - const cards = findLovelaceCards(config, containerPath); + const cards = findLovelaceItems("cards", config, containerPath); const newCards = (cards ?? []).filter((_origConf, ind) => ind !== cardIndex); - const newConfig = updateLovelaceCards(config, containerPath, newCards); + const newConfig = updateLovelaceItems( + "cards", + config, + containerPath, + newCards + ); return newConfig; }; @@ -81,13 +92,18 @@ export const insertCard = ( const { cardIndex } = parseLovelaceCardPath(path); const containerPath = getLovelaceContainerPath(path); - const cards = findLovelaceCards(config, containerPath); + const cards = findLovelaceItems("cards", config, containerPath); const newCards = cards ? [...cards.slice(0, cardIndex), cardConfig, ...cards.slice(cardIndex)] : [cardConfig]; - const newConfig = updateLovelaceCards(config, containerPath, newCards); + const newConfig = updateLovelaceItems( + "cards", + config, + containerPath, + newCards + ); return newConfig; }; @@ -99,7 +115,7 @@ export const moveCardToIndex = ( const { cardIndex } = parseLovelaceCardPath(path); const containerPath = getLovelaceContainerPath(path); - const cards = findLovelaceCards(config, containerPath); + const cards = findLovelaceItems("cards", config, containerPath); const newCards = cards ? [...cards] : []; @@ -110,7 +126,12 @@ export const moveCardToIndex = ( newCards.splice(oldIndex, 1); newCards.splice(newIndex, 0, card); - const newConfig = updateLovelaceCards(config, containerPath, newCards); + const newConfig = updateLovelaceItems( + "cards", + config, + containerPath, + newCards + ); return newConfig; }; @@ -132,7 +153,7 @@ export const moveCardToContainer = ( } const fromContainerPath = getLovelaceContainerPath(fromPath); - const cards = findLovelaceCards(config, fromContainerPath); + const cards = findLovelaceItems("cards", config, fromContainerPath); const card = cards![fromCardIndex]; let newConfig = addCard(config, toPath, card); @@ -148,7 +169,7 @@ export const moveCard = ( ): LovelaceConfig => { const { cardIndex: fromCardIndex } = parseLovelaceCardPath(fromPath); const fromContainerPath = getLovelaceContainerPath(fromPath); - const cards = findLovelaceCards(config, fromContainerPath); + const cards = findLovelaceItems("cards", config, fromContainerPath); const card = cards![fromCardIndex]; let newConfig = deleteCard(config, fromPath); @@ -298,3 +319,98 @@ export const moveSection = ( return newConfig; }; + +export const addBadge = ( + config: LovelaceConfig, + path: LovelaceContainerPath, + badgeConfig: LovelaceBadgeConfig +): LovelaceConfig => { + const badges = findLovelaceItems("badges", config, path); + const newBadges = badges ? [...badges, badgeConfig] : [badgeConfig]; + const newConfig = updateLovelaceItems("badges", config, path, newBadges); + return newConfig; +}; + +export const replaceBadge = ( + config: LovelaceConfig, + path: LovelaceCardPath, + cardConfig: LovelaceBadgeConfig +): LovelaceConfig => { + const { cardIndex } = parseLovelaceCardPath(path); + const containerPath = getLovelaceContainerPath(path); + + const badges = findLovelaceItems("badges", config, containerPath); + + const newBadges = (badges ?? []).map((origConf, ind) => + ind === cardIndex ? cardConfig : origConf + ); + + const newConfig = updateLovelaceItems( + "badges", + config, + containerPath, + newBadges + ); + return newConfig; +}; + +export const deleteBadge = ( + config: LovelaceConfig, + path: LovelaceCardPath +): LovelaceConfig => { + const { cardIndex } = parseLovelaceCardPath(path); + const containerPath = getLovelaceContainerPath(path); + + const badges = findLovelaceItems("badges", config, containerPath); + + const newBadges = (badges ?? []).filter( + (_origConf, ind) => ind !== cardIndex + ); + + const newConfig = updateLovelaceItems( + "badges", + config, + containerPath, + newBadges + ); + return newConfig; +}; + +export const insertBadge = ( + config: LovelaceConfig, + path: LovelaceCardPath, + badgeConfig: LovelaceBadgeConfig +) => { + const { cardIndex } = parseLovelaceCardPath(path); + const containerPath = getLovelaceContainerPath(path); + + const badges = findLovelaceItems("badges", config, containerPath); + + const newBadges = badges + ? [...badges.slice(0, cardIndex), badgeConfig, ...badges.slice(cardIndex)] + : [badgeConfig]; + + const newConfig = updateLovelaceItems( + "badges", + config, + containerPath, + newBadges + ); + return newConfig; +}; + +export const moveBadge = ( + config: LovelaceConfig, + fromPath: LovelaceCardPath, + toPath: LovelaceCardPath +): LovelaceConfig => { + const { cardIndex: fromCardIndex } = parseLovelaceCardPath(fromPath); + const fromContainerPath = getLovelaceContainerPath(fromPath); + const badges = findLovelaceItems("badges", config, fromContainerPath); + const badge = badges![fromCardIndex]; + + let newConfig = deleteBadge(config, fromPath); + newConfig = insertBadge(newConfig, toPath, badge); + + return newConfig; +}; diff --git a/src/panels/lovelace/editor/get-badge-stub-config.ts b/src/panels/lovelace/editor/get-badge-stub-config.ts new file mode 100644 index 0000000000..63c64e57a8 --- /dev/null +++ b/src/panels/lovelace/editor/get-badge-stub-config.ts @@ -0,0 +1,26 @@ +import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; +import { HomeAssistant } from "../../../types"; +import { getBadgeElementClass } from "../create-element/create-badge-element"; + +export const getBadgeStubConfig = async ( + hass: HomeAssistant, + type: string, + entities: string[], + entitiesFallback: string[] +): Promise => { + let badgeConfig: LovelaceCardConfig = { type }; + + const elClass = await getBadgeElementClass(type); + + if (elClass && elClass.getStubConfig) { + const classStubConfig = await elClass.getStubConfig( + hass, + entities, + entitiesFallback + ); + + badgeConfig = { ...badgeConfig, ...classStubConfig }; + } + + return badgeConfig; +}; diff --git a/src/panels/lovelace/editor/get-card-documentation-url.ts b/src/panels/lovelace/editor/get-dashboard-documentation-url.ts similarity index 55% rename from src/panels/lovelace/editor/get-card-documentation-url.ts rename to src/panels/lovelace/editor/get-dashboard-documentation-url.ts index e312463d4b..aada4e5978 100644 --- a/src/panels/lovelace/editor/get-card-documentation-url.ts +++ b/src/panels/lovelace/editor/get-dashboard-documentation-url.ts @@ -1,4 +1,5 @@ import { + getCustomBadgeEntry, getCustomCardEntry, isCustomType, stripCustomPrefix, @@ -14,5 +15,16 @@ export const getCardDocumentationURL = ( return getCustomCardEntry(stripCustomPrefix(type))?.documentationURL; } - return `${documentationUrl(hass, "/lovelace/")}${type}`; + return `${documentationUrl(hass, "/dashboards/")}${type}`; +}; + +export const getBadgeDocumentationURL = ( + hass: HomeAssistant, + type: string +): string | undefined => { + if (isCustomType(type)) { + return getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL; + } + + return `${documentationUrl(hass, "/dashboards/badges")}`; }; diff --git a/src/panels/lovelace/editor/lovelace-path.ts b/src/panels/lovelace/editor/lovelace-path.ts index d4527126ba..c27716d050 100644 --- a/src/panels/lovelace/editor/lovelace-path.ts +++ b/src/panels/lovelace/editor/lovelace-path.ts @@ -1,3 +1,4 @@ +import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import { LovelaceSectionRawConfig, @@ -80,35 +81,6 @@ export const findLovelaceContainer: FindLovelaceContainer = ( 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, @@ -153,10 +125,16 @@ export const updateLovelaceContainer = ( }; }; -export const updateLovelaceCards = ( +type LovelaceItemKeys = { + cards: LovelaceCardConfig[]; + badges: LovelaceBadgeConfig[]; +}; + +export const updateLovelaceItems = ( + key: T, config: LovelaceConfig, path: LovelaceContainerPath, - cards: LovelaceCardConfig[] + items: LovelaceItemKeys[T] ): LovelaceConfig => { const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path); @@ -164,13 +142,13 @@ export const updateLovelaceCards = ( 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"); + throw new Error(`Can not update ${key} in a strategy view`); } if (sectionIndex === undefined) { updated = true; return { ...view, - cards, + [key]: items, }; } @@ -181,12 +159,12 @@ export const updateLovelaceCards = ( 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"); + throw new Error(`Can not update ${key} in a strategy section`); } updated = true; return { ...section, - cards, + [key]: items, }; }); return { @@ -196,10 +174,43 @@ export const updateLovelaceCards = ( }); if (!updated) { - throw new Error("Can not update cards in a non-existing view/section"); + throw new Error(`Can not update ${key} in a non-existing view/section`); } return { ...config, views: newViews, }; }; + +export const findLovelaceItems = ( + key: T, + config: LovelaceConfig, + path: LovelaceContainerPath +): LovelaceItemKeys[T] | undefined => { + const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path); + + const view = config.views[viewIndex]; + + if (!view) { + throw new Error("View does not exist"); + } + if (isStrategyView(view)) { + throw new Error("Can not find cards in a strategy view"); + } + if (sectionIndex === undefined) { + return view[key] as LovelaceItemKeys[T] | undefined; + } + + 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"); + } + if (key === "cards") { + return section[key as "cards"] as LovelaceItemKeys[T] | undefined; + } + throw new Error(`${key} is not supported in section`); +}; diff --git a/src/panels/lovelace/editor/structs/base-badge-struct.ts b/src/panels/lovelace/editor/structs/base-badge-struct.ts new file mode 100644 index 0000000000..b738119cef --- /dev/null +++ b/src/panels/lovelace/editor/structs/base-badge-struct.ts @@ -0,0 +1,6 @@ +import { object, string, any } from "superstruct"; + +export const baseLovelaceBadgeConfig = object({ + type: string(), + visibility: any(), +}); diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index 4c7ae4d164..5a522ea156 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -82,6 +82,16 @@ export interface LovelaceCardConstructor extends Constructor { getConfigForm?: () => LovelaceConfigForm; } +export interface LovelaceBadgeConstructor extends Constructor { + getStubConfig?: ( + hass: HomeAssistant, + entities: string[], + entitiesFallback: string[] + ) => LovelaceBadgeConfig; + getConfigElement?: () => LovelaceBadgeEditor; + getConfigForm?: () => LovelaceConfigForm; +} + export interface LovelaceHeaderFooterConstructor extends Constructor { getStubConfig?: ( @@ -107,6 +117,10 @@ export interface LovelaceCardEditor extends LovelaceGenericElementEditor { setConfig(config: LovelaceCardConfig): void; } +export interface LovelaceBadgeEditor extends LovelaceGenericElementEditor { + setConfig(config: LovelaceBadgeConfig): void; +} + export interface LovelaceHeaderFooterEditor extends LovelaceGenericElementEditor { setConfig(config: LovelaceHeaderFooterConfig): void; diff --git a/src/panels/lovelace/views/hui-masonry-view.ts b/src/panels/lovelace/views/hui-masonry-view.ts index 520856ad7b..500bc441cf 100644 --- a/src/panels/lovelace/views/hui-masonry-view.ts +++ b/src/panels/lovelace/views/hui-masonry-view.ts @@ -15,9 +15,11 @@ import "../../../components/ha-svg-icon"; import type { LovelaceViewElement } from "../../../data/lovelace"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { HomeAssistant } from "../../../types"; +import { HuiBadge } from "../badges/hui-badge"; +import "../badges/hui-view-badges"; import { HuiCard } from "../cards/hui-card"; import { computeCardSize } from "../common/compute-card-size"; -import type { Lovelace, LovelaceBadge } from "../types"; +import type { Lovelace } from "../types"; // Find column with < 5 size, else smallest column const getColumnIndex = (columnSizes: number[], size: number) => { @@ -50,7 +52,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement { @property({ attribute: false }) public cards: HuiCard[] = []; - @property({ attribute: false }) public badges: LovelaceBadge[] = []; + @property({ attribute: false }) public badges: HuiBadge[] = []; @state() private _columns?: number; @@ -78,9 +80,12 @@ export class MasonryView extends LitElement implements LovelaceViewElement { protected render(): TemplateResult { return html` - ${this.badges.length > 0 - ? html`
    ${this.badges}
    ` - : ""} +
    (); - private _getKey(sectionConfig: HuiSection) { - if (!this._sectionConfigKeys.has(sectionConfig)) { - this._sectionConfigKeys.set(sectionConfig, Math.random().toString()); + private _getSectionKey(section: HuiSection) { + if (!this._sectionConfigKeys.has(section)) { + this._sectionConfigKeys.set(section, Math.random().toString()); } - return this._sectionConfigKeys.get(sectionConfig)!; + return this._sectionConfigKeys.get(section)!; } private _computeSectionsCount() { @@ -83,9 +88,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement { const maxColumnsCount = this._config?.max_columns; return html` - ${this.badges.length > 0 - ? html`
    ${this.badges}
    ` - : ""} + ${repeat( sections, - (section) => this._getKey(section), + (section) => this._getSectionKey(section), (section, idx) => { (section as any).itemPath = [idx]; return html` @@ -141,7 +149,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { ${editMode ? html`