Add new badges design with UI editor (#21401)

* Add new entity badge

* Improve badge render

* Add edit mode

* Add editor

* Increase height

* Use hui-badge

* Add editor

* Add drag and drop

* Fix editor translations

* Fix icon

* Fix inactive color

* Add state content

* Add default config

* Fix types

* Add custom badge support to editor

* Fix custom badges

* Add new badges to masonry view

* fix lint

* Fix inactive color

* Fix entity filter card

* Add display type option

* Add support for picture

* Improve focus style

* Add visibility editor

* Fix visibility

* Fix add/delete card inside section

* Fix translations

* Add error badge

* Rename classes

* Fix badge type

* Remove badges from section type

* Add missing types
This commit is contained in:
Paul Bottein 2024-07-19 10:52:22 +02:00 committed by GitHub
parent ce43774b5f
commit 729a12af0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2460 additions and 180 deletions

View File

@ -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;

View File

@ -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,
});

View File

@ -27,7 +27,7 @@ export interface LovelaceBaseViewConfig {
export interface LovelaceViewConfig extends LovelaceBaseViewConfig {
type?: string;
badges?: Array<string | LovelaceBadgeConfig>;
badges?: (string | LovelaceBadgeConfig)[]; // Badge can be just an entity_id
cards?: LovelaceCardConfig[];
sections?: LovelaceSectionRawConfig[];
}

View File

@ -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);

View File

@ -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<typeof this>): void {
super.willUpdate(changedProps);
if (!this._element) {
this.load();
}
}
protected update(changedProps: PropertyValues<typeof this>) {
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;
}
}

View File

@ -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<LovelaceBadgeEditor> {
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`
<state-display
.stateObj=${stateObj}
.hass=${this.hass}
.content=${this._config.state_content}
>
</state-display>
`;
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`
<div
style=${styleMap(style)}
class="badge ${classMap({
active,
[displayType]: true,
})}"
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this._config!.hold_action),
hasDoubleClick: hasAction(this._config!.double_tap_action),
})}
role=${ifDefined(this.hasAction ? "button" : undefined)}
tabindex=${ifDefined(this.hasAction ? "0" : undefined)}
>
<ha-ripple .disabled=${!this.hasAction}></ha-ripple>
${imageUrl
? html`<img src=${imageUrl} aria-hidden />`
: html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObj}
.icon=${this._config.icon}
></ha-state-icon>
`}
${displayType !== "minimal"
? html`
<span class="content">
${displayType === "complete"
? html`<span class="name">${name}</span>`
: nothing}
<span class="state">${stateDisplay}</span>
</span>
`
: nothing}
</div>
`;
}
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;
}
}

View File

@ -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 {

View File

@ -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`<pre>${dumped}</pre>` : "",
});
}
protected render() {
if (!this._config) {
return nothing;
}
return html`
<ha-label-badge label="Error" description=${this._config.error}>
<ha-svg-icon .path=${mdiAlert}></ha-svg-icon>
</ha-label-badge>
<button class="badge error" @click=${this._viewDetail}>
<ha-svg-icon .hass=${this.hass} .path=${mdiAlertCircle}></ha-svg-icon>
<ha-ripple></ha-ripple>
<span class="content">
<span class="name">Error</span>
<span class="state">${this._config.error}</span>
</span>
</button>
`;
}
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;
}
`,
];
}
}

View File

@ -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<HuiBadge, string>();
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`
<ha-sortable
.disabled=${!editMode}
@item-moved=${this._badgeMoved}
@drag-start=${this._dragStart}
@drag-end=${this._dragEnd}
group="badge"
draggable-selector="[data-sortable]"
.path=${[this.viewIndex]}
.rollback=${false}
.options=${BADGE_SORTABLE_OPTIONS}
invert-swap
>
<div class="badges">
${repeat(
badges,
(badge) => this._getBadgeKey(badge),
(badge, idx) => html`
${editMode
? html`
<hui-badge-edit-mode
data-sortable
.hass=${this.hass}
.lovelace=${this.lovelace}
.path=${[this.viewIndex, idx]}
.hiddenOverlay=${this._dragging}
>
${badge}
</hui-badge-edit-mode>
`
: badge}
`
)}
${editMode
? html`
<button
class="add"
@click=${this._addBadge}
aria-label=${this.hass.localize(
"ui.panel.lovelace.editor.section.add_card"
)}
.title=${this.hass.localize(
"ui.panel.lovelace.editor.section.add_card"
)}
>
<ha-svg-icon .path=${mdiPlus}></ha-svg-icon>
</button>
`
: nothing}
</div>
</ha-sortable>
`
: 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;
}
}

View File

@ -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;
}

View File

@ -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`
<state-display
@ -311,7 +311,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
<ha-tile-info
id="info"
.primary=${name}
.secondary=${localizedState}
.secondary=${stateDisplay}
></ha-tile-info>
</div>
${this._config.features

View File

@ -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`
<div class="badge-wrapper" inert><slot></slot></div>
<div class="badge-overlay ${classMap({ visible: showOverlay })}">
<div
class="edit"
@click=${this._editBadge}
@keydown=${this._editBadge}
tabindex="0"
>
<div class="edit-overlay"></div>
<ha-svg-icon class="edit" .path=${mdiPencil}> </ha-svg-icon>
</div>
<ha-button-menu
class="more"
corner="BOTTOM_END"
menuCorner="END"
.path=${[this.path!]}
@action=${this._handleAction}
@opened=${this._handleOpened}
@closed=${this._handleClosed}
>
<ha-icon-button slot="trigger" .path=${mdiDotsVertical}>
</ha-icon-button>
<ha-list-item graphic="icon">
<ha-svg-icon
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.duplicate"
)}
</ha-list-item>
<li divider role="separator"></li>
<ha-list-item graphic="icon" class="warning">
${this.hass.localize("ui.panel.lovelace.editor.edit_card.delete")}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
</div>
`;
}
private _handleOpened() {
this._menuOpened = true;
}
private _handleClosed() {
this._menuOpened = false;
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._duplicateCard();
break;
case 1:
this._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;
}
}

View File

@ -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;

View File

@ -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 {

View File

@ -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);

View File

@ -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 = <T extends keyof CreateElementConfigTypes>(
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 = <T extends keyof CreateElementConfigTypes>(
};
const _createErrorElement = <T extends keyof CreateElementConfigTypes>(
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 = <T extends keyof CreateElementConfigTypes>(
tagSuffix: T,
tag: string,
config: CreateElementConfigTypes[T]["config"]
) => {
@ -119,6 +146,7 @@ const _customCreate = <T extends keyof CreateElementConfigTypes>(
}
const element = _createErrorElement(
tagSuffix,
`Custom element doesn't exist: ${tag}.`,
config
);
@ -175,7 +203,7 @@ export const createLovelaceElement = <T extends keyof CreateElementConfigTypes>(
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 = <T extends keyof CreateElementConfigTypes>(
} 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;

View File

@ -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<LovelaceBadgeConfig> {
@state() private _curTab: Tab = "config";
protected async getConfigElement(): Promise<LovelaceCardEditor | undefined> {
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<LovelaceConfigForm | undefined> {
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`
<hui-badge-visibility-editor
.hass=${this.hass}
.config=${this.value}
@value-changed=${this._configChanged}
></hui-badge-visibility-editor>
`;
break;
}
return html`
<paper-tabs
scrollable
hide-scroll-buttons
.selected=${displayedTabs.indexOf(this._curTab)}
@selected-item-changed=${this._handleTabSelected}
>
${displayedTabs.map(
(tab, index) => html`
<paper-tab id=${tab} .dialogInitialFocus=${index === 0}>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_badge.tab_${tab}`
)}
</paper-tab>
`
)}
</paper-tabs>
${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;
}
}

View File

@ -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`
<p class="intro">
${this.hass.localize(
`ui.panel.lovelace.editor.edit_badge.visibility.explanation`
)}
</p>
<ha-card-conditions-editor
.hass=${this.hass}
.conditions=${conditions}
@value-changed=${this._valueChanged}
>
</ha-card-conditions-editor>
`;
}
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;
}
}

View File

@ -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<undefined>;
}
}
@customElement("hui-dialog-edit-badge")
export class HuiDialogEditBadge
extends LitElement
implements HassDialog<EditBadgeDialogParams>
{
@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<void> {
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`
<ha-dialog
open
scrimClickAction
.escapeKeyAction=${this._isEscapeEnabled ? undefined : ""}
@keydown=${this._ignoreKeydown}
@closed=${this._cancel}
@opened=${this._opened}
.heading=${heading}
>
<ha-dialog-header slot="heading">
<ha-icon-button
slot="navigationIcon"
dialogAction="cancel"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title" @click=${this._enlarge}>${heading}</span>
${this._documentationURL !== undefined
? html`
<a
slot="actionItems"
href=${this._documentationURL}
title=${this.hass!.localize("ui.panel.lovelace.menu.help")}
target="_blank"
rel="noreferrer"
dir=${computeRTLDirection(this.hass)}
>
<ha-icon-button .path=${mdiHelpCircle}></ha-icon-button>
</a>
`
: nothing}
</ha-dialog-header>
<div class="content">
<div class="element-editor">
<hui-badge-element-editor
.hass=${this.hass}
.lovelace=${this._params.lovelaceConfig}
.value=${this._badgeConfig}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
@editor-save=${this._save}
dialogInitialFocus
></hui-badge-element-editor>
</div>
<div class="element-preview">
<hui-badge
.hass=${this.hass}
.config=${this._badgeConfig}
preview
class=${this._error ? "blur" : ""}
></hui-badge>
${this._error
? html`
<ha-circular-progress
indeterminate
aria-label="Can't update badge"
></ha-circular-progress>
`
: ``}
</div>
</div>
${this._badgeConfig !== undefined
? html`
<mwc-button
slot="secondaryAction"
@click=${this._toggleMode}
.disabled=${!this._guiModeAvailable}
class="gui-mode-button"
>
${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"
)}
</mwc-button>
`
: ""}
<div slot="primaryAction" @click=${this._save}>
<mwc-button @click=${this._cancel} dialogInitialFocus>
${this.hass!.localize("ui.common.cancel")}
</mwc-button>
${this._badgeConfig !== undefined && this._dirty
? html`
<mwc-button
?disabled=${!this._canSave || this._saving}
@click=${this._save}
>
${this._saving
? html`
<ha-circular-progress
indeterminate
aria-label="Saving"
size="small"
></ha-circular-progress>
`
: this.hass!.localize("ui.common.save")}
</mwc-button>
`
: ``}
</div>
</ha-dialog>
`;
}
private _enlarge() {
this.large = !this.large;
}
private _ignoreKeydown(ev: KeyboardEvent) {
ev.stopPropagation();
}
private _handleConfigChanged(ev: HASSDomEvent<ConfigChangedEvent>) {
this._badgeConfig = deepFreeze(ev.detail.config);
this._error = ev.detail.error;
this._guiModeAvailable = ev.detail.guiModeAvailable;
this._dirty = true;
}
private _handleGUIModeChanged(ev: HASSDomEvent<GUIModeChangedEvent>): 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<void> {
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;
}
}

View File

@ -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,
});
};

View File

@ -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 {

View File

@ -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`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
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<ReturnType<typeof this._schema>>
) => {
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;
}
}

View File

@ -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;
};

View File

@ -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<LovelaceCardConfig> => {
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;
};

View File

@ -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")}`;
};

View File

@ -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 = <T extends keyof LovelaceItemKeys>(
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 = <T extends keyof LovelaceItemKeys>(
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`);
};

View File

@ -0,0 +1,6 @@
import { object, string, any } from "superstruct";
export const baseLovelaceBadgeConfig = object({
type: string(),
visibility: any(),
});

View File

@ -82,6 +82,16 @@ export interface LovelaceCardConstructor extends Constructor<LovelaceCard> {
getConfigForm?: () => LovelaceConfigForm;
}
export interface LovelaceBadgeConstructor extends Constructor<LovelaceBadge> {
getStubConfig?: (
hass: HomeAssistant,
entities: string[],
entitiesFallback: string[]
) => LovelaceBadgeConfig;
getConfigElement?: () => LovelaceBadgeEditor;
getConfigForm?: () => LovelaceConfigForm;
}
export interface LovelaceHeaderFooterConstructor
extends Constructor<LovelaceHeaderFooter> {
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;

View File

@ -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`<div class="badges">${this.badges}</div>`
: ""}
<hui-view-badges
.hass=${this.hass}
.badges=${this.badges}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
></hui-view-badges>
<div
id="columns"
class=${this.lovelace?.editMode ? "edit-mode" : ""}
@ -289,10 +294,10 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
padding-top: 4px;
}
.badges {
margin: 8px 16px;
hui-view-badges {
display: block;
margin: 12px 8px 20px 8px;
font-size: 85%;
text-align: center;
}
#columns {

View File

@ -17,11 +17,14 @@ import type { LovelaceViewElement } from "../../../data/lovelace";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { HuiBadge } from "../badges/hui-badge";
import "../badges/hui-view-badges";
import "../components/hui-badge-edit-mode";
import { addSection, deleteSection, moveSection } from "../editor/config-util";
import { findLovelaceContainer } from "../editor/lovelace-path";
import { showEditSectionDialog } from "../editor/section-editor/show-edit-section-dialog";
import { HuiSection } from "../sections/hui-section";
import type { Lovelace, LovelaceBadge } from "../types";
import type { Lovelace } from "../types";
@customElement("hui-sections-view")
export class SectionsView extends LitElement implements LovelaceViewElement {
@ -35,23 +38,25 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public sections: HuiSection[] = [];
@property({ attribute: false }) public badges: LovelaceBadge[] = [];
@property({ attribute: false }) public badges: HuiBadge[] = [];
@state() private _config?: LovelaceViewConfig;
@state() private _sectionCount = 0;
@state() _dragging = false;
public setConfig(config: LovelaceViewConfig): void {
this._config = config;
}
private _sectionConfigKeys = new WeakMap<HuiSection, string>();
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`<div class="badges">${this.badges}</div>`
: ""}
<hui-view-badges
.hass=${this.hass}
.badges=${this.badges}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
></hui-view-badges>
<ha-sortable
.disabled=${!editMode}
@item-moved=${this._sectionMoved}
@ -103,7 +111,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
>
${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`
<button
class="create"
class="create-section"
@click=${this._createSection}
aria-label=${this.hass.localize(
"ui.panel.lovelace.editor.section.create_section"
@ -235,14 +243,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
display: block;
}
.badges {
margin: 4px 0;
padding: var(--row-gap) var(--column-gap);
padding-bottom: 0;
font-size: 85%;
text-align: center;
}
.container > * {
position: relative;
max-width: var(--column-max-width);
@ -313,7 +313,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
padding: 8px;
}
.create {
.create-section {
margin-top: calc(66px + var(--row-gap));
outline: none;
background: none;
@ -326,13 +326,19 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
box-sizing: border-box;
}
.create:focus {
.create-section:focus {
border: 2px solid var(--primary-color);
}
.sortable-ghost {
border-radius: var(--ha-card-border-radius, 12px);
}
hui-view-badges {
display: block;
margin: 16px 8px;
text-align: center;
}
`;
}
}

View File

@ -5,27 +5,28 @@ 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 {
defaultBadgeConfig,
LovelaceBadgeConfig,
} from "../../../data/lovelace/config/badge";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import {
LovelaceViewConfig,
isStrategyView,
LovelaceViewConfig,
} from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import {
createErrorBadgeConfig,
createErrorBadgeElement,
} from "../badges/hui-error-badge";
import "../badges/hui-badge";
import type { HuiBadge } from "../badges/hui-badge";
import "../cards/hui-card";
import type { HuiCard } from "../cards/hui-card";
import { processConfigEntities } from "../common/process-config-entities";
import { createBadgeElement } from "../create-element/create-badge-element";
import { createViewElement } from "../create-element/create-view-element";
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { deleteCard } from "../editor/config-util";
import { deleteBadge, deleteCard } from "../editor/config-util";
import { confDeleteCard } from "../editor/delete-card";
import { getBadgeStubConfig } from "../editor/get-badge-stub-config";
import {
LovelaceCardPath,
parseLovelaceCardPath,
@ -34,7 +35,7 @@ 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 } from "../types";
import type { Lovelace } from "../types";
import { DEFAULT_VIEW_LAYOUT, PANEL_VIEW_LAYOUT } from "./const";
declare global {
@ -43,11 +44,17 @@ declare global {
"ll-create-card": { suggested?: string[] } | undefined;
"ll-edit-card": { path: LovelaceCardPath };
"ll-delete-card": { path: LovelaceCardPath; confirm: boolean };
"ll-create-badge": undefined;
"ll-edit-badge": { path: LovelaceCardPath };
"ll-delete-badge": { path: LovelaceCardPath };
}
interface HTMLElementEventMap {
"ll-create-card": HASSDomEvent<HASSDomEvents["ll-create-card"]>;
"ll-edit-card": HASSDomEvent<HASSDomEvents["ll-edit-card"]>;
"ll-delete-card": HASSDomEvent<HASSDomEvents["ll-delete-card"]>;
"ll-create-badge": HASSDomEvent<HASSDomEvents["ll-create-badge"]>;
"ll-edit-badge": HASSDomEvent<HASSDomEvents["ll-edit-badge"]>;
"ll-delete-badge": HASSDomEvent<HASSDomEvents["ll-delete-badge"]>;
}
}
@ -63,7 +70,7 @@ export class HUIView extends ReactiveElement {
@state() private _cards: HuiCard[] = [];
@state() private _badges: LovelaceBadge[] = [];
@state() private _badges: HuiBadge[] = [];
@state() private _sections: HuiSection[] = [];
@ -86,20 +93,16 @@ export class HUIView extends ReactiveElement {
return element;
}
public createBadgeElement(badgeConfig: LovelaceBadgeConfig) {
const element = createBadgeElement(badgeConfig) as LovelaceBadge;
try {
element.hass = this.hass;
} catch (e: any) {
return createErrorBadgeElement(createErrorBadgeConfig(e.message));
}
element.addEventListener(
"ll-badge-rebuild",
() => {
this._rebuildBadge(element, badgeConfig);
},
{ once: true }
);
public _createBadgeElement(badgeConfig: LovelaceBadgeConfig) {
const element = document.createElement("hui-badge");
element.hass = this.hass;
element.preview = this.lovelace.editMode;
element.config = badgeConfig;
element.addEventListener("badge-updated", (ev: Event) => {
ev.stopPropagation();
this._badges = [...this._badges];
});
element.load();
return element;
}
@ -169,11 +172,7 @@ export class HUIView extends ReactiveElement {
// Config has not changed. Just props
if (changedProperties.has("hass")) {
this._badges.forEach((badge) => {
try {
badge.hass = this.hass;
} catch (e: any) {
this._rebuildBadge(badge, createErrorBadgeConfig(e.message));
}
badge.hass = this.hass;
});
this._cards.forEach((element) => {
@ -219,6 +218,9 @@ export class HUIView extends ReactiveElement {
this._cards.forEach((element) => {
element.preview = this.lovelace.editMode;
});
this._badges.forEach((element) => {
element.preview = this.lovelace.editMode;
});
}
if (changedProperties.has("_cards")) {
this._layoutElement.cards = this._cards;
@ -329,6 +331,34 @@ export class HUIView extends ReactiveElement {
this.lovelace.saveConfig(newLovelace);
}
});
this._layoutElement.addEventListener("ll-create-badge", async () => {
const defaultConfig = await getBadgeStubConfig(
this.hass,
"entity",
Object.keys(this.hass.entities),
[]
);
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
badgeConfig: defaultConfig,
});
});
this._layoutElement.addEventListener("ll-edit-badge", (ev) => {
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
badgeIndex: cardIndex,
});
});
this._layoutElement.addEventListener("ll-delete-badge", (ev) => {
const newLovelace = deleteBadge(this.lovelace!.config, ev.detail.path);
this.lovelace.saveConfig(newLovelace);
});
}
private _createBadges(config: LovelaceViewConfig): void {
@ -337,14 +367,10 @@ export class HUIView extends ReactiveElement {
return;
}
const badges = processConfigEntities(config.badges as any);
this._badges = badges.map((badge) => {
const element = createBadgeElement(badge);
try {
element.hass = this.hass;
} catch (e: any) {
return createErrorBadgeElement(createErrorBadgeConfig(e.message));
}
this._badges = config.badges.map((badge) => {
const badgeConfig =
typeof badge === "string" ? defaultBadgeConfig(badge) : badge;
const element = this._createBadgeElement(badgeConfig);
return element;
});
}
@ -374,27 +400,6 @@ export class HUIView extends ReactiveElement {
});
}
private _rebuildBadge(
badgeElToReplace: LovelaceBadge,
config: LovelaceBadgeConfig
): void {
let newBadgeEl = this.createBadgeElement(config);
try {
newBadgeEl.hass = this.hass;
} catch (e: any) {
newBadgeEl = createErrorBadgeElement(createErrorBadgeConfig(e.message));
}
if (badgeElToReplace.parentElement) {
badgeElToReplace.parentElement!.replaceChild(
newBadgeEl,
badgeElToReplace
);
}
this._badges = this._badges!.map((curBadgeEl) =>
curBadgeEl === badgeElToReplace ? newBadgeEl : curBadgeEl
);
}
private _rebuildSection(
sectionElToReplace: HuiSection,
config: LovelaceSectionConfig

View File

@ -5560,9 +5560,31 @@
"tab_layout": "Layout",
"visibility": {
"explanation": "The card will be shown when ALL conditions below are fulfilled. If no conditions are set, the card will always be shown."
},
"layout": {
"explanation": "Configure how the card will appear on the dashboard. This settings will override the default size and position of the card."
}
},
"edit_badge": {
"header": "Badge configuration",
"typed_header": "{type} Badge configuration",
"pick_badge": "Which badge would you like to add?",
"pick_badge_title": "Which badge would you like to add to {name}",
"toggle_editor": "[%key:ui::panel::lovelace::editor::edit_card::toggle_editor%]",
"unsaved_changes": "[%key:ui::panel::lovelace::editor::edit_card::unsaved_changes%]",
"confirm_cancel": "[%key:ui::panel::lovelace::editor::edit_card::confirm_cancel%]",
"show_visual_editor": "[%key:ui::panel::lovelace::editor::edit_card::show_visual_editor%]",
"show_code_editor": "[%key:ui::panel::lovelace::editor::edit_card::show_code_editor%]",
"edit_ui": "[%key:ui::panel::config::automation::editor::edit_ui%]",
"edit_yaml": "[%key:ui::panel::config::automation::editor::edit_yaml%]",
"add": "Add badge",
"edit": "[%key:ui::panel::lovelace::editor::edit_card::edit%]",
"clear": "[%key:ui::panel::lovelace::editor::edit_card::clear%]",
"delete": "[%key:ui::panel::lovelace::editor::edit_card::delete%]",
"copy": "[%key:ui::panel::lovelace::editor::edit_card::copy%]",
"cut": "[%key:ui::panel::lovelace::editor::edit_card::cut%]",
"duplicate": "[%key:ui::panel::lovelace::editor::edit_card::duplicate%]",
"tab_config": "[%key:ui::panel::lovelace::editor::edit_card::tab_config%]",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_card::tab_visibility%]",
"visibility": {
"explanation": "The badge will be shown when ALL conditions below are fulfilled. If no conditions are set, the badge will always be shown."
}
},
"move_card": {
@ -5987,7 +6009,6 @@
"icon_tap_action": "Icon tap behavior",
"actions": "Actions",
"appearance": "Appearance",
"default_color": "Default color (state)",
"show_entity_picture": "Show entity picture",
"vertical": "Vertical",
"hide_state": "Hide state",
@ -6012,6 +6033,23 @@
"twice_daily": "Twice daily"
}
},
"badge": {
"entity": {
"name": "Entity",
"description": "The Entity badge gives you a quick overview of your entity.",
"color": "Color",
"actions": "Actions",
"appearance": "Appearance",
"show_entity_picture": "Show entity picture",
"state_content": "State content",
"display_type": "Display type",
"display_type_options": {
"minimal": "Minimal (icon only)",
"standard": "Standard (icon and state)",
"complete": "Complete (icon, name and state)"
}
}
},
"features": {
"name": "Features",
"not_compatible": "Not compatible",