Add picker for badges (#21436)

This commit is contained in:
Paul Bottein 2024-07-19 20:56:29 +02:00 committed by GitHub
parent 345000a0e9
commit 179245e1aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1300 additions and 40 deletions

View File

@ -1,12 +1,25 @@
import { Condition } from "../../../panels/lovelace/common/validate-condition";
export interface LovelaceBadgeConfig {
type?: string;
type: string;
[key: string]: any;
visibility?: Condition[];
}
export const defaultBadgeConfig = (entity_id: string): LovelaceBadgeConfig => ({
type: "entity",
entity: entity_id,
});
export const ensureBadgeConfig = (
config: Partial<LovelaceBadgeConfig> | string
): LovelaceBadgeConfig => {
if (typeof config === "string") {
return {
type: "entity",
entity: config,
};
}
if ("type" in config && config.type) {
return config as LovelaceBadgeConfig;
}
return {
type: "entity",
...config,
};
};

View File

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

View File

@ -127,7 +127,10 @@ export class HuiEntityFilterBadge
const element = document.createElement("hui-badge");
element.hass = this.hass;
element.preview = this.preview;
element.config = badgeConfig;
element.config = {
type: "entity",
...badgeConfig,
};
element.load();
this._elements.push(element);
}

View File

@ -31,6 +31,8 @@ import {
} from "../cards/types";
import { EntityConfig } from "../entity-rows/types";
import { ButtonsHeaderFooterConfig } from "../header-footer/types";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { EntityBadgeConfig } from "../badges/types";
const HIDE_DOMAIN = new Set([
"automation",
@ -310,6 +312,23 @@ export const computeCards = (
];
};
export const computeBadges = (
_states: HassEntities,
entityIds: string[]
): LovelaceBadgeConfig[] => {
const badges: LovelaceBadgeConfig[] = [];
for (const entityId of entityIds) {
const config: EntityBadgeConfig = {
type: "entity",
entity: entityId,
};
badges.push(config);
}
return badges;
};
const computeDefaultViewStates = (
entities: HassEntities,
entityEntries: HomeAssistant["entities"]

View File

@ -1,10 +1,13 @@
import { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import {
mdiContentCopy,
mdiContentCut,
mdiContentDuplicate,
mdiDelete,
mdiDotsVertical,
mdiPencil,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@ -25,6 +28,7 @@ import {
parseLovelaceCardPath,
} from "../editor/lovelace-path";
import { Lovelace } from "../types";
import { ensureBadgeConfig } from "../../../data/lovelace/config/badge";
@customElement("hui-badge-edit-mode")
export class HuiBadgeEditMode extends LitElement {
@ -46,7 +50,7 @@ export class HuiBadgeEditMode extends LitElement {
public _focused: boolean = false;
@storage({
key: "lovelaceClipboard",
key: "dashboardBadgeClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",
@ -134,6 +138,14 @@ export class HuiBadgeEditMode extends LitElement {
"ui.panel.lovelace.editor.edit_card.duplicate"
)}
</ha-list-item>
<ha-list-item graphic="icon">
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize("ui.panel.lovelace.editor.edit_card.copy")}
</ha-list-item>
<ha-list-item graphic="icon">
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
${this.hass.localize("ui.panel.lovelace.editor.edit_card.cut")}
</ha-list-item>
<li divider role="separator"></li>
<ha-list-item graphic="icon" class="warning">
${this.hass.localize("ui.panel.lovelace.editor.edit_card.delete")}
@ -159,18 +171,35 @@ export class HuiBadgeEditMode extends LitElement {
private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._duplicateCard();
this._duplicateBadge();
break;
case 1:
this._deleteCard();
this._copyBadge();
break;
case 2:
this._cutBadge();
break;
case 3:
this._deleteBadge();
break;
}
}
private _duplicateCard(): void {
private _cutBadge(): void {
this._copyBadge();
this._deleteBadge();
}
private _copyBadge(): void {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const cardConfig = this._badges[cardIndex];
this._clipboard = deepClone(cardConfig);
}
private _duplicateBadge(): void {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const containerPath = getLovelaceContainerPath(this.path!);
const badgeConfig = this._badges![cardIndex];
const badgeConfig = ensureBadgeConfig(this._badges![cardIndex]);
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
@ -191,7 +220,7 @@ export class HuiBadgeEditMode extends LitElement {
fireEvent(this, "ll-edit-badge", { path: this.path! });
}
private _deleteCard(): void {
private _deleteBadge(): void {
fireEvent(this, "ll-delete-badge", { path: this.path! });
}

View File

@ -50,7 +50,7 @@ export class HuiCardEditMode extends LitElement {
public _focused: boolean = false;
@storage({
key: "lovelaceClipboard",
key: "dashboardCardClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",

View File

@ -67,7 +67,7 @@ export class HuiCardOptions extends LitElement {
@property({ type: Boolean }) public hidePosition = false;
@storage({
key: "lovelaceClipboard",
key: "dashboardCardClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",

View File

@ -4,6 +4,7 @@ import "../badges/hui-state-label-badge";
import {
createLovelaceElement,
getLovelaceElementClass,
tryCreateLovelaceElement,
} from "./create-element-base";
const ALWAYS_LOADED_TYPES = new Set(["error", "state-label", "entity"]);
@ -11,6 +12,17 @@ const LAZY_LOAD_TYPES = {
"entity-filter": () => import("../badges/hui-entity-filter-badge"),
};
// This will not return an error card but will throw the error
export const tryCreateBadgeElement = (config: LovelaceBadgeConfig) =>
tryCreateLovelaceElement(
"badge",
config,
ALWAYS_LOADED_TYPES,
LAZY_LOAD_TYPES,
undefined,
undefined
);
export const createBadgeElement = (config: LovelaceBadgeConfig) =>
createLovelaceElement(
"badge",

View File

@ -0,0 +1,590 @@
import Fuse, { IFuseOptions } from "fuse.js";
import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stringCompare } from "../../../../common/string/compare";
import { stripDiacritics } from "../../../../common/string/strip-diacritics";
import "../../../../components/ha-circular-progress";
import "../../../../components/search-input";
import { isUnavailableState } from "../../../../data/entity";
import { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import {
CUSTOM_TYPE_PREFIX,
CustomBadgeEntry,
customBadges,
getCustomBadgeEntry,
} from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types";
import { getStripDiacriticsFn } from "../../../../util/fuse";
import {
calcUnusedEntities,
computeUsedEntities,
} from "../../common/compute-unused-entities";
import { tryCreateBadgeElement } from "../../create-element/create-badge-element";
import type { LovelaceBadge } from "../../types";
import { getBadgeStubConfig } from "../get-badge-stub-config";
import { coreBadges } from "../lovelace-badges";
import type { Badge, BadgePickTarget } from "../types";
interface BadgeElement {
badge: Badge;
element: TemplateResult;
}
@customElement("hui-badge-picker")
export class HuiBadgePicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public suggestedBadges?: string[];
@storage({
key: "dashboardBadgeClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
private _clipboard?: LovelaceBadgeConfig;
@state() private _badges: BadgeElement[] = [];
public lovelace?: LovelaceConfig;
public badgePicked?: (badgeConf: LovelaceBadgeConfig) => void;
@state() private _filter = "";
@state() private _width?: number;
@state() private _height?: number;
private _unusedEntities?: string[];
private _usedEntities?: string[];
private _filterBadges = memoizeOne(
(badgeElements: BadgeElement[], filter?: string): BadgeElement[] => {
if (!filter) {
return badgeElements;
}
let badges = badgeElements.map(
(badgeElement: BadgeElement) => badgeElement.badge
);
const options: IFuseOptions<Badge> = {
keys: ["type", "name", "description"],
isCaseSensitive: false,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
getFn: getStripDiacriticsFn,
};
const fuse = new Fuse(badges, options);
badges = fuse
.search(stripDiacritics(filter))
.map((result) => result.item);
return badgeElements.filter((badgeElement: BadgeElement) =>
badges.includes(badgeElement.badge)
);
}
);
private _suggestedBadges = memoizeOne(
(badgeElements: BadgeElement[]): BadgeElement[] =>
badgeElements.filter(
(badgeElement: BadgeElement) => badgeElement.badge.isSuggested
)
);
private _customBadges = memoizeOne(
(badgeElements: BadgeElement[]): BadgeElement[] =>
badgeElements.filter(
(badgeElement: BadgeElement) =>
badgeElement.badge.isCustom && !badgeElement.badge.isSuggested
)
);
private _otherBadges = memoizeOne(
(badgeElements: BadgeElement[]): BadgeElement[] =>
badgeElements.filter(
(badgeElement: BadgeElement) =>
!badgeElement.badge.isSuggested && !badgeElement.badge.isCustom
)
);
protected render() {
if (
!this.hass ||
!this.lovelace ||
!this._unusedEntities ||
!this._usedEntities
) {
return nothing;
}
const suggestedBadges = this._suggestedBadges(this._badges);
const otherBadges = this._otherBadges(this._badges);
const customBadgesItems = this._customBadges(this._badges);
return html`
<search-input
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.edit_badge.search_badgess"
)}
></search-input>
<div
id="content"
style=${styleMap({
width: this._width ? `${this._width}px` : "auto",
height: this._height ? `${this._height}px` : "auto",
})}
>
<div class="badges-container">
${this._filter
? this._filterBadges(this._badges, this._filter).map(
(badgeElement: BadgeElement) => badgeElement.element
)
: html`
${suggestedBadges.length > 0
? html`
<div class="badges-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.badge.generic.suggested_badges`
)}
</div>
`
: nothing}
${this._renderClipboardBadge()}
${suggestedBadges.map(
(badgeElement: BadgeElement) => badgeElement.element
)}
${suggestedBadges.length > 0
? html`
<div class="badges-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.badge.generic.other_badges`
)}
</div>
`
: nothing}
${otherBadges.map(
(badgeElement: BadgeElement) => badgeElement.element
)}
${customBadgesItems.length > 0
? html`
<div class="badges-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.badge.generic.custom_badges`
)}
</div>
`
: nothing}
${customBadgesItems.map(
(badgeElement: BadgeElement) => badgeElement.element
)}
`}
</div>
<div class="badges-container">
<div
class="badge manual"
@click=${this._badgePicked}
.config=${{ type: "" }}
>
<div class="badge-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.badge.generic.manual`
)}
</div>
<div class="preview description">
${this.hass!.localize(
`ui.panel.lovelace.editor.badge.generic.manual_description`
)}
</div>
</div>
</div>
</div>
`;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass) {
return true;
}
if (oldHass.locale !== this.hass!.locale) {
return true;
}
return false;
}
protected firstUpdated(): void {
if (!this.hass || !this.lovelace) {
return;
}
const usedEntities = computeUsedEntities(this.lovelace);
const unusedEntities = calcUnusedEntities(this.hass, usedEntities);
this._usedEntities = [...usedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
!isUnavailableState(this.hass!.states[eid].state)
);
this._unusedEntities = [...unusedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
!isUnavailableState(this.hass!.states[eid].state)
);
this._loadBages();
}
private _loadBages() {
let badges = coreBadges.map<Badge>((badge) => ({
name: this.hass!.localize(
`ui.panel.lovelace.editor.badge.${badge.type}.name`
),
description: this.hass!.localize(
`ui.panel.lovelace.editor.badge.${badge.type}.description`
),
isSuggested: this.suggestedBadges?.includes(badge.type) || false,
...badge,
}));
badges = badges.sort((a, b) => {
if (a.isSuggested && !b.isSuggested) {
return -1;
}
if (!a.isSuggested && b.isSuggested) {
return 1;
}
return stringCompare(
a.name || a.type,
b.name || b.type,
this.hass?.language
);
});
if (customBadges.length > 0) {
badges = badges.concat(
customBadges
.map((cbadge: CustomBadgeEntry) => ({
type: cbadge.type,
name: cbadge.name,
description: cbadge.description,
showElement: cbadge.preview,
isCustom: true,
}))
.sort((a, b) =>
stringCompare(
a.name || a.type,
b.name || b.type,
this.hass?.language
)
)
);
}
this._badges = badges.map((badge) => ({
badge: badge,
element: html`${until(
this._renderBadgeElement(badge),
html`
<div class="badge spinner">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>
`
)}`,
}));
}
private _renderClipboardBadge() {
if (!this._clipboard) {
return nothing;
}
return html` ${until(
this._renderBadgeElement(
{
type: this._clipboard.type,
showElement: true,
isCustom: false,
name: this.hass!.localize(
"ui.panel.lovelace.editor.badge.generic.paste"
),
description: `${this.hass!.localize(
"ui.panel.lovelace.editor.badge.generic.paste_description",
{
type: this._clipboard.type,
}
)}`,
},
this._clipboard
),
html`
<div class="badge spinner">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>
`
)}`;
}
private _handleSearchChange(ev: CustomEvent) {
const value = ev.detail.value;
if (!value) {
// Reset when we no longer filter
this._width = undefined;
this._height = undefined;
} else if (!this._width || !this._height) {
// Save height and width so the dialog doesn't jump while searching
const div = this.shadowRoot!.getElementById("content");
if (div && !this._width) {
const width = div.clientWidth;
if (width) {
this._width = width;
}
}
if (div && !this._height) {
const height = div.clientHeight;
if (height) {
this._height = height;
}
}
}
this._filter = value;
}
private _badgePicked(ev: Event): void {
const config: LovelaceBadgeConfig = (ev.currentTarget! as BadgePickTarget)
.config;
fireEvent(this, "config-changed", { config });
}
private _tryCreateBadgeElement(badge: LovelaceBadgeConfig) {
const element = tryCreateBadgeElement(badge) as LovelaceBadge;
element.hass = this.hass;
element.addEventListener(
"ll-rebuild",
(ev) => {
ev.stopPropagation();
this._rebuildBadge(element, badge);
},
{ once: true }
);
return element;
}
private _rebuildBadge(
badgeElToReplace: LovelaceBadge,
config: LovelaceBadgeConfig
): void {
let newBadgeEl: LovelaceBadge;
try {
newBadgeEl = this._tryCreateBadgeElement(config);
} catch (err: any) {
return;
}
if (badgeElToReplace.parentElement) {
badgeElToReplace.parentElement!.replaceChild(
newBadgeEl,
badgeElToReplace
);
}
}
private async _renderBadgeElement(
badge: Badge,
config?: LovelaceBadgeConfig
): Promise<TemplateResult> {
let { type } = badge;
const { showElement, isCustom, name, description } = badge;
const customBadge = isCustom ? getCustomBadgeEntry(type) : undefined;
if (isCustom) {
type = `${CUSTOM_TYPE_PREFIX}${type}`;
}
let element: LovelaceBadge | undefined;
let badgeConfig: LovelaceBadgeConfig = config ?? { type };
if (this.hass && this.lovelace) {
if (!config) {
badgeConfig = await getBadgeStubConfig(
this.hass,
type,
this._unusedEntities!,
this._usedEntities!
);
}
if (showElement) {
try {
element = this._tryCreateBadgeElement(badgeConfig);
} catch (err: any) {
element = undefined;
}
}
}
return html`
<div class="badge">
<div
class="overlay"
@click=${this._badgePicked}
.config=${badgeConfig}
></div>
<div class="badge-header">
${customBadge
? `${this.hass!.localize(
"ui.panel.lovelace.editor.badge_picker.custom_badge"
)}: ${customBadge.name || customBadge.type}`
: name}
</div>
<div
class="preview ${classMap({
description: !element || element.tagName === "HUI-ERROR-BADGE",
})}"
>
${element && element.tagName !== "HUI-ERROR-BADGE"
? element
: customBadge
? customBadge.description ||
this.hass!.localize(
`ui.panel.lovelace.editor.badge_picker.no_description`
)
: description}
</div>
</div>
`;
}
static get styles(): CSSResultGroup {
return [
css`
search-input {
display: block;
--mdc-shape-small: var(--badge-picker-search-shape);
margin: var(--badge-picker-search-margin);
}
.badges-container-header {
font-size: 16px;
font-weight: 500;
padding: 12px 8px 4px 8px;
margin: 0;
grid-column: 1 / -1;
}
.badges-container {
display: grid;
grid-gap: 8px 8px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
margin-top: 20px;
}
.badge {
height: 100%;
max-width: 500px;
display: flex;
flex-direction: column;
border-radius: var(--ha-card-border-radius, 12px);
background: var(--primary-background-color, #fafafa);
cursor: pointer;
position: relative;
overflow: hidden;
border: var(--ha-card-border-width, 1px) solid
var(--ha-card-border-color, var(--divider-color));
}
.badge-header {
color: var(--ha-card-header-color, --primary-text-color);
font-family: var(--ha-card-header-font-family, inherit);
font-size: 16px;
font-weight: bold;
letter-spacing: -0.012em;
line-height: 20px;
padding: 12px 16px;
display: block;
text-align: center;
background: var(
--ha-card-background,
var(--card-background-color, white)
);
border-bottom: 1px solid var(--divider-color);
}
.preview {
pointer-events: none;
margin: 20px;
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
}
.description {
text-align: center;
}
.spinner {
align-items: center;
justify-content: center;
}
.overlay {
position: absolute;
width: 100%;
height: 100%;
z-index: 1;
box-sizing: border-box;
border-radius: var(--ha-card-border-radius, 12px);
}
.manual {
grid-column: 1 / -1;
max-width: none;
}
.icon {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: 8px;
inset-inline-end: 8px;
border-radius: 50%;
--mdc-icon-size: 16px;
line-height: 16px;
box-sizing: border-box;
color: var(--text-primary-color);
padding: 4px;
}
.icon.custom {
background: var(--warning-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-badge-picker": HuiBadgePicker;
}
}

View File

@ -0,0 +1,289 @@
import "@material/mwc-tab-bar/mwc-tab-bar";
import "@material/mwc-tab/mwc-tab";
import { mdiClose } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import memoize from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { DataTableRowData } from "../../../../components/data-table/ha-data-table";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { computeBadges } from "../../common/generate-lovelace-config";
import "../card-editor/hui-entity-picker-table";
import { findLovelaceContainer } from "../lovelace-path";
import "./hui-badge-picker";
import { CreateBadgeDialogParams } from "./show-create-badge-dialog";
import { showEditBadgeDialog } from "./show-edit-badge-dialog";
import { showSuggestBadgeDialog } from "./show-suggest-badge-dialog";
declare global {
interface HASSDomEvents {
"selected-changed": SelectedChangedEvent;
}
}
interface SelectedChangedEvent {
selectedEntities: string[];
}
@customElement("hui-dialog-create-badge")
export class HuiCreateDialogBadge
extends LitElement
implements HassDialog<CreateBadgeDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: CreateBadgeDialogParams;
@state() private _containerConfig!: LovelaceViewConfig;
@state() private _selectedEntities: string[] = [];
@state() private _currTabIndex = 0;
public async showDialog(params: CreateBadgeDialogParams): Promise<void> {
this._params = params;
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
params.path
);
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
}
this._containerConfig = containerConfig;
}
public closeDialog(): boolean {
this._params = undefined;
this._currTabIndex = 0;
this._selectedEntities = [];
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
protected render() {
if (!this._params) {
return nothing;
}
const title = this._containerConfig.title
? this.hass!.localize(
"ui.panel.lovelace.editor.edit_badge.pick_badge_title",
{ name: `"${this._containerConfig.title}"` }
)
: this.hass!.localize("ui.panel.lovelace.editor.edit_badge.pick_badge");
return html`
<ha-dialog
open
scrimClickAction
@keydown=${this._ignoreKeydown}
@closed=${this._cancel}
.heading=${title}
class=${classMap({ table: this._currTabIndex === 1 })}
>
<ha-dialog-header show-border slot="heading">
<ha-icon-button
slot="navigationIcon"
dialogAction="cancel"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title"> ${title} </span>
<mwc-tab-bar
.activeIndex=${this._currTabIndex}
@MDCTabBar:activated=${this._handleTabChanged}
>
<mwc-tab
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.badge_picker.by_badge"
)}
dialogInitialFocus
></mwc-tab>
<mwc-tab
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.badge_picker.by_entity"
)}
></mwc-tab>
</mwc-tab-bar>
</ha-dialog-header>
${cache(
this._currTabIndex === 0
? html`
<hui-badge-picker
.suggestedBadges=${this._params.suggestedBadges}
.lovelace=${this._params.lovelaceConfig}
.hass=${this.hass}
@config-changed=${this._handleBadgePicked}
></hui-badge-picker>
`
: html`
<hui-entity-picker-table
no-label-float
.hass=${this.hass}
.narrow=${true}
.entities=${this._allEntities(this.hass.states)}
@selected-changed=${this._handleSelectedChanged}
></hui-entity-picker-table>
`
)}
<div slot="primaryAction">
<mwc-button @click=${this._cancel}>
${this.hass!.localize("ui.common.cancel")}
</mwc-button>
${this._selectedEntities.length
? html`
<mwc-button @click=${this._suggestBadges}>
${this.hass!.localize("ui.common.continue")}
</mwc-button>
`
: ""}
</div>
</ha-dialog>
`;
}
private _ignoreKeydown(ev: KeyboardEvent) {
ev.stopPropagation();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
@media all and (max-width: 450px), all and (max-height: 500px) {
/* overrule the ha-style-dialog max-height on small screens */
ha-dialog {
--mdc-dialog-max-height: 100%;
height: 100%;
}
}
@media all and (min-width: 850px) {
ha-dialog {
--mdc-dialog-min-width: 845px;
}
}
ha-dialog {
--mdc-dialog-max-width: 845px;
--dialog-content-padding: 2px 24px 20px 24px;
--dialog-z-index: 6;
}
ha-dialog.table {
--dialog-content-padding: 0;
}
@media (min-width: 1200px) {
ha-dialog {
--mdc-dialog-max-width: calc(100vw - 32px);
--mdc-dialog-min-width: 1000px;
}
}
hui-badge-picker {
--badge-picker-search-shape: 0;
--badge-picker-search-margin: -2px -24px 0;
}
hui-entity-picker-table {
display: block;
height: calc(100vh - 198px);
--mdc-shape-small: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
hui-entity-picker-table {
height: calc(100vh - 158px);
}
}
`,
];
}
private _handleBadgePicked(ev) {
const config = ev.detail.config;
if (this._params!.entities && this._params!.entities.length) {
if (Object.keys(config).includes("entities")) {
config.entities = this._params!.entities;
} else if (Object.keys(config).includes("entity")) {
config.entity = this._params!.entities[0];
}
}
showEditBadgeDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: this._params!.path,
badgeConfig: config,
});
this.closeDialog();
}
private _handleTabChanged(ev: CustomEvent): void {
const newTab = ev.detail.index;
if (newTab === this._currTabIndex) {
return;
}
this._currTabIndex = ev.detail.index;
this._selectedEntities = [];
}
private _handleSelectedChanged(ev: CustomEvent): void {
this._selectedEntities = ev.detail.selectedEntities;
}
private _cancel(ev?: Event) {
if (ev) {
ev.stopPropagation();
}
this.closeDialog();
}
private _suggestBadges(): void {
const badgeConfig = computeBadges(this.hass.states, this._selectedEntities);
showSuggestBadgeDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: this._params!.path as [number],
entities: this._selectedEntities,
badgeConfig,
});
this.closeDialog();
}
private _allEntities = memoize((entities) =>
Object.keys(entities).map((entity) => {
const stateObj = this.hass.states[entity];
return {
icon: "",
entity_id: entity,
stateObj,
name: computeStateName(stateObj),
domain: computeDomain(entity),
last_changed: stateObj!.last_changed,
} as DataTableRowData;
})
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-create-badge": HuiCreateDialogBadge;
}
}

View File

@ -17,7 +17,7 @@ import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import {
defaultBadgeConfig,
ensureBadgeConfig,
LovelaceBadgeConfig,
} from "../../../../data/lovelace/config/badge";
import { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
@ -106,8 +106,7 @@ export class HuiDialogEditBadge
this._dirty = true;
} else {
const badge = this._containerConfig.badges?.[params.badgeIndex];
this._badgeConfig =
typeof badge === "string" ? defaultBadgeConfig(badge) : badge;
this._badgeConfig = badge != null ? ensureBadgeConfig(badge) : badge;
}
this.large = false;

View File

@ -0,0 +1,204 @@
import deepFreeze from "deep-freeze";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { haStyleDialog } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge";
import { addBadges } from "../config-util";
import {
LovelaceContainerPath,
parseLovelaceContainerPath,
} from "../lovelace-path";
import { SuggestBadgeDialogParams } from "./show-suggest-badge-dialog";
@customElement("hui-dialog-suggest-badge")
export class HuiDialogSuggestBadge extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: SuggestBadgeDialogParams;
@state() private _badgeConfig?: LovelaceBadgeConfig[];
@state() private _saving = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
public showDialog(params: SuggestBadgeDialogParams): void {
this._params = params;
this._badgeConfig = params.badgeConfig;
if (!Object.isFrozen(this._badgeConfig)) {
this._badgeConfig = deepFreeze(this._badgeConfig);
}
if (this._yamlEditor) {
this._yamlEditor.setValue(this._badgeConfig);
}
}
public closeDialog(): void {
this._params = undefined;
this._badgeConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _renderPreview() {
if (this._badgeConfig) {
return html`
<div class="element-preview">
${this._badgeConfig.map(
(badgeConfig) => html`
<hui-badge
.hass=${this.hass}
.config=${badgeConfig}
preview
></hui-badge>
`
)}
</div>
`;
}
return nothing;
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
open
scrimClickAction
@closed=${this.closeDialog}
.heading=${this.hass!.localize(
"ui.panel.lovelace.editor.suggest_badge.header"
)}
>
<div>
${this._renderPreview()}
${this._params.yaml && this._badgeConfig
? html`
<div class="editor">
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this._badgeConfig}
></ha-yaml-editor>
</div>
`
: nothing}
</div>
<mwc-button
slot="secondaryAction"
@click=${this.closeDialog}
dialogInitialFocus
>
${this._params.yaml
? this.hass!.localize("ui.common.close")
: this.hass!.localize("ui.common.cancel")}
</mwc-button>
${!this._params.yaml
? html`
<mwc-button
slot="primaryAction"
.disabled=${this._saving}
@click=${this._save}
>
${this._saving
? html`
<ha-circular-progress
indeterminate
aria-label="Saving"
size="small"
></ha-circular-progress>
`
: this.hass!.localize(
"ui.panel.lovelace.editor.suggest_badge.add"
)}
</mwc-button>
`
: nothing}
</ha-dialog>
`;
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
@media all and (max-width: 450px), all and (max-height: 500px) {
/* overrule the ha-style-dialog max-height on small screens */
ha-dialog {
max-height: 100%;
height: 100%;
}
}
@media all and (min-width: 850px) {
ha-dialog {
width: 845px;
}
}
ha-dialog {
max-width: 845px;
--dialog-z-index: 6;
}
.hidden {
display: none;
}
.element-preview {
position: relative;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin: 0;
}
.editor {
padding-top: 16px;
}
`,
];
}
private _computeNewConfig(
config: LovelaceConfig,
path: LovelaceContainerPath
): LovelaceConfig {
const { viewIndex } = parseLovelaceContainerPath(path);
const newBadges = this._badgeConfig!;
return addBadges(config, [viewIndex], newBadges);
}
private async _save(): Promise<void> {
if (
!this._params?.lovelaceConfig ||
!this._params?.path ||
!this._params?.saveConfig ||
!this._badgeConfig
) {
return;
}
this._saving = true;
const newConfig = this._computeNewConfig(
this._params.lovelaceConfig,
this._params.path
);
await this._params!.saveConfig(newConfig);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-suggest-badge": HuiDialogSuggestBadge;
}
}

View File

@ -0,0 +1,25 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { LovelaceContainerPath } from "../lovelace-path";
export interface CreateBadgeDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelaceContainerPath;
suggestedBadges?: string[];
entities?: string[]; // We can pass entity id's that will be added to the config when a badge is picked
}
export const importCreateBadgeDialog = () =>
import("./hui-dialog-create-badge");
export const showCreateBadgeDialog = (
element: HTMLElement,
createBadgeDialogParams: CreateBadgeDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-create-badge",
dialogImport: importCreateBadgeDialog,
dialogParams: createBadgeDialogParams,
});
};

View File

@ -0,0 +1,26 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { LovelaceContainerPath } from "../lovelace-path";
export interface SuggestBadgeDialogParams {
lovelaceConfig?: LovelaceConfig;
yaml?: boolean;
saveConfig?: (config: LovelaceConfig) => void;
path?: LovelaceContainerPath;
entities?: string[]; // We pass this to create dialog when user chooses "Pick own"
badgeConfig: LovelaceBadgeConfig[]; // We can pass a suggested config
}
const importSuggestBadgeDialog = () => import("./hui-dialog-suggest-badge");
export const showSuggestBadgeDialog = (
element: HTMLElement,
suggestBadgeDialogParams: SuggestBadgeDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-suggest-badge",
dialogImport: importSuggestBadgeDialog,
dialogParams: suggestBadgeDialogParams,
});
};

View File

@ -52,7 +52,7 @@ export class HuiCardPicker extends LitElement {
@property({ attribute: false }) public suggestedCards?: string[];
@storage({
key: "lovelaceClipboard",
key: "dashboardCardClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
@ -490,7 +490,7 @@ export class HuiCardPicker extends LitElement {
.cards-container {
display: grid;
grid-gap: 8px 8px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
margin-top: 20px;
}
@ -560,6 +560,7 @@ export class HuiCardPicker extends LitElement {
.manual {
max-width: none;
grid-column: 1 / -1;
}
.icon {

View File

@ -45,7 +45,7 @@ export class HuiConditionalCardEditor
@property({ attribute: false }) public lovelace?: LovelaceConfig;
@storage({
key: "lovelaceClipboard",
key: "dashboardCardClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",

View File

@ -67,7 +67,7 @@ export class HuiStackCardEditor
@property({ attribute: false }) public lovelace?: LovelaceConfig;
@storage({
key: "lovelaceClipboard",
key: "dashboardCardClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",

View File

@ -1,4 +1,7 @@
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import {
ensureBadgeConfig,
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";
@ -331,6 +334,17 @@ export const addBadge = (
return newConfig;
};
export const addBadges = (
config: LovelaceConfig,
path: LovelaceContainerPath,
badgeConfig: LovelaceBadgeConfig[]
): LovelaceConfig => {
const badges = findLovelaceItems("badges", config, path);
const newBadges = badges ? [...badges, ...badgeConfig] : [...badgeConfig];
const newConfig = updateLovelaceItems("badges", config, path, newBadges);
return newConfig;
};
export const replaceBadge = (
config: LovelaceConfig,
path: LovelaceCardPath,
@ -410,7 +424,7 @@ export const moveBadge = (
const badge = badges![fromCardIndex];
let newConfig = deleteBadge(config, fromPath);
newConfig = insertBadge(newConfig, toPath, badge);
newConfig = insertBadge(newConfig, toPath, ensureBadgeConfig(badge));
return newConfig;
};

View File

@ -32,6 +32,7 @@ import type { HuiFormEditor } from "./config-elements/hui-form-editor";
import "./config-elements/hui-generic-entity-row-editor";
import { GUISupportError } from "./gui-support-error";
import { EditSubElementEvent, GUIModeChangedEvent } from "./types";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
export interface ConfigChangedEvent {
config:
@ -39,7 +40,8 @@ export interface ConfigChangedEvent {
| LovelaceRowConfig
| LovelaceHeaderFooterConfig
| LovelaceCardFeatureConfig
| LovelaceStrategyConfig;
| LovelaceStrategyConfig
| LovelaceBadgeConfig;
error?: string;
guiModeAvailable?: boolean;
}

View File

@ -0,0 +1,8 @@
import { Badge } from "./types";
export const coreBadges: Badge[] = [
{
type: "entity",
showElement: true,
},
];

View File

@ -127,7 +127,7 @@ export const updateLovelaceContainer = (
type LovelaceItemKeys = {
cards: LovelaceCardConfig[];
badges: LovelaceBadgeConfig[];
badges: (Partial<LovelaceBadgeConfig> | string)[];
};
export const updateLovelaceItems = <T extends keyof LovelaceItemKeys>(

View File

@ -7,6 +7,7 @@ import {
import { EntityConfig, LovelaceRowConfig } from "../entity-rows/types";
import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { LovelaceCardFeatureConfig } from "../card-features/types";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
export interface YamlChangedEvent extends Event {
detail: {
@ -65,6 +66,15 @@ export interface Card {
isSuggested?: boolean;
}
export interface Badge {
type: string;
name?: string;
description?: string;
showElement?: boolean;
isCustom?: boolean;
isSuggested?: boolean;
}
export interface HeaderFooter {
type: LovelaceHeaderFooterConfig["type"];
icon?: string;
@ -74,6 +84,10 @@ export interface CardPickTarget extends EventTarget {
config: LovelaceCardConfig;
}
export interface BadgePickTarget extends EventTarget {
config: LovelaceBadgeConfig;
}
export interface SubElementEditorConfig {
index?: number;
elementConfig?:

View File

@ -6,7 +6,7 @@ import "../../../components/entity/ha-state-label-badge";
import "../../../components/ha-svg-icon";
import type { LovelaceViewElement } from "../../../data/lovelace";
import {
defaultBadgeConfig,
ensureBadgeConfig,
LovelaceBadgeConfig,
} from "../../../data/lovelace/config/badge";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@ -26,7 +26,6 @@ import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dia
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { deleteBadge, deleteCard } from "../editor/config-util";
import { confDeleteCard } from "../editor/delete-card";
import { getBadgeStubConfig } from "../editor/get-badge-stub-config";
import {
LovelaceCardPath,
parseLovelaceCardPath,
@ -37,6 +36,7 @@ import type { HuiSection } from "../sections/hui-section";
import { generateLovelaceViewStrategy } from "../strategies/get-strategy";
import type { Lovelace } from "../types";
import { DEFAULT_VIEW_LAYOUT, PANEL_VIEW_LAYOUT } from "./const";
import { showCreateBadgeDialog } from "../editor/badge-editor/show-create-badge-dialog";
declare global {
// for fire event
@ -332,18 +332,10 @@ export class HUIView extends ReactiveElement {
}
});
this._layoutElement.addEventListener("ll-create-badge", async () => {
const defaultConfig = await getBadgeStubConfig(
this.hass,
"entity",
Object.keys(this.hass.entities),
[]
);
showEditBadgeDialog(this, {
showCreateBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
badgeConfig: defaultConfig,
});
});
this._layoutElement.addEventListener("ll-edit-badge", (ev) => {
@ -368,8 +360,7 @@ export class HUIView extends ReactiveElement {
}
this._badges = config.badges.map((badge) => {
const badgeConfig =
typeof badge === "string" ? defaultBadgeConfig(badge) : badge;
const badgeConfig = ensureBadgeConfig(badge);
const element = this._createBadgeElement(badgeConfig);
return element;
});

View File

@ -5586,6 +5586,10 @@
"explanation": "The badge will be shown when ALL conditions below are fulfilled. If no conditions are set, the badge will always be shown."
}
},
"suggest_badge": {
"header": "[%key:ui::panel::lovelace::editor::suggest_card::header%]",
"add": "[%key:ui::panel::lovelace::editor::suggest_card::add%]"
},
"move_card": {
"header": "Choose a view to move the card to",
"error_title": "Impossible to move the card",
@ -6047,6 +6051,15 @@
"standard": "Standard (icon and state)",
"complete": "Complete (icon, name and state)"
}
},
"generic": {
"manual": "Manual",
"manual_description": "Need to add a custom badge or just want to manually write the YAML?",
"paste": "Paste from clipboard",
"paste_description": "Paste a {type} badge from the clipboard",
"suggested_badges": "Suggested badges",
"other_badges": "Other badges",
"custom_badges": "Custom badges"
}
},
"features": {
@ -6236,7 +6249,15 @@
"domain": "Domain",
"entity": "Entity",
"by_entity": "By entity",
"by_card": "By Card"
"by_card": "By card"
},
"badge_picker": {
"no_description": "No description available.",
"custom_badge": "Custom",
"domain": "Domain",
"entity": "Entity",
"by_entity": "By entity",
"by_badge": "By badge"
},
"header-footer": {
"header": "Header",