From 51a54b99def698d25b7d9a15eaef7e7d98f2f103 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Fri, 15 Mar 2024 02:19:52 -0700 Subject: [PATCH] Create visual editors for decluttering cards and templates. The new 'custom:decluttering-template' card declares a template. It can be placed in any view of the dashboard and it is only visible in edit mode. Use the visual editor to conveniently create a template, configure the card or element, set variables with their default values, and preview the results. The existing 'custom:decluttering-card' card now searches for templates declared by 'custom:decluttering-template' cards in addition to those in the traditional decluttering_templates dashboard configuration. Use the visual editor to conveniently pick an existing template defined elsewhere, set variables, and preview the results. Fixed possible race conditions when cards are loaded and streamlined the logic. Restored previously set styles when element styles are modified. The element styling behavior is curiously undocumented...? --- src/decluttering-card.ts | 518 ++++++++++++++++++++++++++++++++++----- src/deep-replace.ts | 5 +- src/types.ts | 10 +- src/utils.ts | 5 + 4 files changed, 471 insertions(+), 67 deletions(-) diff --git a/src/decluttering-card.ts b/src/decluttering-card.ts index f8b0b5b..961debb 100644 --- a/src/decluttering-card.ts +++ b/src/decluttering-card.ts @@ -1,8 +1,16 @@ -import { LitElement, html, customElement, property, TemplateResult, css, CSSResult } from 'lit-element'; -import { HomeAssistant, createThing, LovelaceCardConfig, LovelaceCard } from 'custom-card-helpers'; -import { DeclutteringCardConfig, TemplateConfig } from './types'; +import { LitElement, html, customElement, property, state, TemplateResult, css, CSSResult } from 'lit-element'; +import { + HomeAssistant, + createThing, + fireEvent, + LovelaceCardConfig, + LovelaceCard, + LovelaceCardEditor, + LovelaceConfig, +} from 'custom-card-helpers'; +import { DeclutteringCardConfig, DeclutteringTemplateConfig, TemplateConfig, VariablesConfig } from './types'; import deepReplace from './deep-replace'; -import { getLovelace, getLovelaceCast } from './utils'; +import { getLovelaceConfig } from './utils'; import { ResizeObserver } from 'resize-observer'; import * as pjson from '../package.json'; @@ -15,33 +23,97 @@ console.info( 'color: white; font-weight: bold; background: dimgray', ); -@customElement('decluttering-card') -// eslint-disable-next-line @typescript-eslint/no-unused-vars -class DeclutteringCard extends LitElement { - @property() protected _card?: LovelaceCard; +async function loadCardPicker(): Promise { + // Ensure hui-card-element-editor and hui-card-picker are loaded. + // They happen to be used by the vertical-stack card editor but there must be a better way? + let cls = customElements.get('hui-vertical-stack-card'); + if (!cls) { + (await HELPERS).createCardElement({ type: 'vertical-stack', cards: [] }); + await customElements.whenDefined('hui-vertical-stack-card'); + cls = customElements.get('hui-vertical-stack-card'); + } + if (cls) await cls.prototype.constructor.getConfigElement(); +} - @property() private _config?: LovelaceCardConfig; +function getTemplateConfig(ll: LovelaceConfig, template: string): TemplateConfig | null { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const templates = (ll as any).decluttering_templates; + const config = templates?.[template] as TemplateConfig; + if (config) return config; - private _ro?: ResizeObserver; + if (ll.views) { + for (const view of ll.views) { + if (view.cards) { + for (const card of view.cards) { + if (card.type === 'custom:decluttering-template' && card.template === template) { + return card as DeclutteringTemplateConfig; + } + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sections = (view as any).sections; + if (sections) { + for (const section of sections) { + if (section.cards) { + for (const card of section.cards) { + if (card.type === 'custom:decluttering-template' && card.template === template) { + return card as DeclutteringTemplateConfig; + } + } + } + } + } + } + } + return null; +} + +function getTemplates(ll: LovelaceConfig): Record { + const templates: Record = {}; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dt = (ll as any).decluttering_templates; + if (dt) Object.assign(templates, dt); + + if (ll.views) { + for (const view of ll.views) { + if (view.cards) { + for (const card of view.cards) { + if (card.type === 'custom:decluttering-template') { + templates[card.template] = card as DeclutteringTemplateConfig; + } + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sections = (view as any).sections; + if (sections) { + for (const section of sections) { + if (section.cards) { + for (const card of section.cards) { + if (card.type === 'custom:decluttering-template') { + templates[card.template] = card as DeclutteringTemplateConfig; + } + } + } + } + } + } + } + return templates; +} - private _hass?: HomeAssistant; +class DeclutteringElement extends LitElement { + @state() private _hass?: HomeAssistant; + @state() private _card?: LovelaceCard; - private _type?: 'element' | 'card'; + private _config?: LovelaceCardConfig; + private _ro?: ResizeObserver; + private _savedStyles?: Map; set hass(hass: HomeAssistant) { if (!hass) return; - if (!this._hass && hass) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this._createCard(this._config!, this._type!).then(card => { - this._card = card; - this._card && this._ro?.observe(this._card); - return this._card; - }); - } this._hass = hass; - if (this._card) { - this._card.hass = hass; - } + if (this._card) this._card.hass = hass; } static get styles(): CSSResult { @@ -49,6 +121,10 @@ class DeclutteringCard extends LitElement { :host(.child-card-hidden) { display: none; } + :host([edit-mode='true']) { + display: block !important; + border: 1px solid var(--primary-color); + } `; } @@ -66,38 +142,54 @@ class DeclutteringCard extends LitElement { } } - public setConfig(config: DeclutteringCardConfig): void { - if (!config.template) { - throw new Error('Missing template object in your config'); - } - const ll = getLovelace() || getLovelaceCast(); - if (!ll.config && !ll.config.decluttering_templates) { - throw new Error("The object decluttering_templates doesn't exist in your main lovelace config."); - } - const templateConfig = ll.config.decluttering_templates[config.template] as TemplateConfig; - if (!templateConfig) { - throw new Error(`The template "${config.template}" doesn't exist in decluttering_templates`); - } else if (!(templateConfig.card || templateConfig.element)) { - throw new Error('You shoud define either a card or an element in the template'); + protected _setTemplateConfig(templateConfig: TemplateConfig, variables: VariablesConfig[] | undefined): void { + if (!(templateConfig.card || templateConfig.element)) { + throw new Error('You should define either a card or an element in the template'); } else if (templateConfig.card && templateConfig.element) { - throw new Error('You can define a card and an element in the template'); + throw new Error('You cannnot define both a card and an element in the template'); + } + + const type = templateConfig.card ? 'card' : 'element'; + const config = deepReplace(variables, templateConfig); + this._config = config; + DeclutteringElement._createCard(config, type, (card: LovelaceCard) => { + if (this._config === config) this._setCard(card, templateConfig.element ? config.style : undefined); + }); + } + + private _setCard(card: LovelaceCard, style?: Record): void { + this._savedStyles?.forEach((v, k) => this.style.setProperty(k, v[0], v[1])); + this._savedStyles = undefined; + + if (style) { + this._savedStyles = new Map(); + Object.keys(style).forEach(prop => { + this._savedStyles?.set(prop, [this.style.getPropertyValue(prop), this.style.getPropertyPriority(prop)]); + this.style.setProperty(prop, style[prop]); + }); } + + this._card = card; + if (this._hass) card.hass = this._hass; this._ro = new ResizeObserver(() => { this._displayHidden(); }); - this._config = deepReplace(config.variables, templateConfig); - this._type = templateConfig.card ? 'card' : 'element'; + this._ro.observe(card); } protected render(): TemplateResult | void { - if (!this._hass || !this._card || !this._config) return html``; + if (!this._hass || !this._card) return html``; return html`
${this._card}
`; } - private async _createCard(config: LovelaceCardConfig, type: 'element' | 'card'): Promise { + private static async _createCard( + config: LovelaceCardConfig, + type: 'element' | 'card', + handler: (card: LovelaceCard) => void, + ): Promise { let element: LovelaceCard; if (HELPERS) { if (type === 'card') { @@ -106,43 +198,343 @@ class DeclutteringCard extends LitElement { // fireEvent(element, 'll-rebuild'); } else { element = (await HELPERS).createHuiElement(config); - if (config.style) { - Object.keys(config.style).forEach(prop => { - this.style.setProperty(prop, config.style[prop]); - }); - } } } else { element = createThing(config); } - if (this._hass) { - element.hass = this._hass; - } element.addEventListener( 'll-rebuild', ev => { ev.stopPropagation(); - this._rebuildCard(element, config, type); + DeclutteringElement._createCard(config, type, (card: LovelaceCard) => { + element.replaceWith(card); + handler(card); + }); }, { once: true }, ); element.id = 'declutter-child'; - return element; - } - - private async _rebuildCard( - element: LovelaceCard, - config: LovelaceCardConfig, - type: 'element' | 'card', - ): Promise { - const newCard = await this._createCard(config, type); - element.replaceWith(newCard); - this._card = newCard; - this._ro?.observe(this._card); - return; + handler(element); } public getCardSize(): Promise | number { return this._card && typeof this._card.getCardSize === 'function' ? this._card.getCardSize() : 1; } } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any).customCards = (window as any).customCards || []; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any).customCards.push({ + type: 'decluttering-card', + name: 'Decluttering card', + preview: false, + description: 'Reuse multiple times the same card configuration with variables to declutter your config.', +}); + +@customElement('decluttering-card') +// eslint-disable-next-line @typescript-eslint/no-unused-vars +class DeclutteringCard extends DeclutteringElement { + static getConfigElement(): HTMLElement { + return document.createElement('decluttering-card-editor'); + } + + static getStubConfig(): DeclutteringCardConfig { + return { + type: 'custom:decluttering-card', + template: 'follow_the_sun', + }; + } + + public setConfig(config: DeclutteringCardConfig): void { + if (!config.template) { + throw new Error('Missing template object in your config'); + } + const ll = getLovelaceConfig(); + if (!ll) { + throw new Error('Could not retrieve the lovelace configuration.'); + } + const templateConfig = getTemplateConfig(ll, config.template); + if (!templateConfig) { + throw new Error( + `The template "${config.template}" doesn't exist in decluttering_templates or in a custom:decluttering-template card`, + ); + } + this._setTemplateConfig(templateConfig, config.variables); + } +} + +@customElement('decluttering-card-editor') +// eslint-disable-next-line @typescript-eslint/no-unused-vars +class DeclutteringCardEditor extends LitElement implements LovelaceCardEditor { + @state() private _lovelace?: LovelaceConfig; + @state() private _config?: DeclutteringCardConfig; + + @property() public hass?: HomeAssistant; + + private _templates?: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _schema: any; + private _loadedElements = false; + + set lovelace(lovelace: LovelaceConfig) { + this._lovelace = lovelace; + this._templates = undefined; + this._schema = undefined; + } + + public setConfig(config: DeclutteringCardConfig): void { + this._config = config; + } + + protected render(): TemplateResult | void { + if (!this.hass || !this._lovelace || !this._config) return html``; + + if (!this._templates) this._templates = getTemplates(this._lovelace); + if (!this._schema) { + this._schema = [ + { + name: 'template', + label: 'Template to use', + selector: { + select: { + mode: 'dropdown', + sort: true, + custom_value: true, + options: Object.keys(this._templates), + }, + }, + }, + { + name: 'variables', + label: 'Variables', + helper: 'Example: - variable_name: value', + selector: { object: {} }, + }, + ]; + } + + const error: Record = {}; + if (!this._templates[this._config.template]) { + error.template = 'No template exists with this name'; + } + if (this._config.variables !== undefined && !Array.isArray(this._config.variables)) { + error.variables = 'The list of variables must be an array of key and value pairs'; + } + + return html` + s.label ?? s.name} + .computeHelper=${(s): string => s.helper ?? ''} + @value-changed=${this._valueChanged} + > + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, 'config-changed', { config: ev.detail.value }); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any).customCards = (window as any).customCards || []; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any).customCards.push({ + type: 'decluttering-template', + name: 'Decluttering template', + preview: false, + description: 'Define a reusable template for decluttering cards to instantiate.', +}); + +@customElement('decluttering-template') +// eslint-disable-next-line @typescript-eslint/no-unused-vars +class DeclutteringTemplate extends DeclutteringElement { + @property({ attribute: 'edit-mode', reflect: true }) editMode; + @state() private _previewMode = false; + @state() private _template?: string; + + static getConfigElement(): HTMLElement { + return document.createElement('decluttering-template-editor'); + } + + static getStubConfig(): DeclutteringTemplateConfig { + return { + type: 'custom:decluttering-template', + template: 'follow_the_sun', + card: { + type: 'entity', + entity: 'sun.sun', + }, + }; + } + + static get styles(): CSSResult { + return css` + ${DeclutteringElement.styles} + .badge { + margin: 8px; + color: var(--primary-color); + } + `; + } + + public setConfig(config: DeclutteringTemplateConfig): void { + if (!config.template) { + throw new Error('Missing template property'); + } + this._template = config.template; + this._setTemplateConfig(config, undefined); + } + + async connectedCallback(): Promise { + super.connectedCallback(); + + this._previewMode = this.parentElement?.localName === 'hui-card-preview'; + if (!this.editMode && !this._previewMode) { + this.setAttribute('hidden', ''); + } else { + this.removeAttribute('hidden'); + } + } + + protected render(): TemplateResult | void { + if (this._template) { + if (this._previewMode) return super.render(); + if (this.editMode) { + return html` +
${this._template}
+ ${super.render()} + `; + } + } + return html``; + } +} + +@customElement('decluttering-template-editor') +// eslint-disable-next-line @typescript-eslint/no-unused-vars +class DeclutteringTemplateEditor extends LitElement implements LovelaceCardEditor { + @state() private _config?: DeclutteringTemplateConfig; + @state() private _selectedTab = 0; + + @property() public lovelace?: LovelaceConfig; + @property() public hass?: HomeAssistant; + + private _loadedElements = false; + + private static schema = [ + { + name: 'template', + label: 'Template to define', + selector: { text: {} }, + }, + { + name: 'default', + label: 'Variables', + helper: 'Example: - variable_name: default_value', + selector: { object: {} }, + }, + ]; + + public setConfig(config: DeclutteringTemplateConfig): void { + this._config = config; + } + + static get styles(): CSSResult { + return css` + ${DeclutteringElement.styles} + .toolbar { + display: flex; + --paper-tabs-selection-bar-color: var(--primary-color); + --paper-tab-ink: var(--primary-color); + } + paper-tabs { + display: flex; + font-size: 14px; + flex-grow: 1; + text-transform: uppercase; + } + `; + } + + async connectedCallback(): Promise { + super.connectedCallback(); + + if (!this._loadedElements) { + await loadCardPicker(); + this._loadedElements = true; + } + } + + protected render(): TemplateResult | void { + if (!this.hass || !this._config) return html``; + + const error: Record = {}; + if (this._config.default !== undefined && !Array.isArray(this._config.default)) { + error.default = 'The list of variables must be an array of key and value pairs'; + } + + return html` +
+ + Settings + Card + Change Card Type + +
+ ${this._selectedTab === 0 + ? html` + s.label ?? s.name} + .computeHelper=${(s): string => s.helper ?? ''} + @value-changed=${this._valueChanged} + > + ` + : this._selectedTab == 1 + ? html` + + ` + : html` + + `} + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, 'config-changed', { config: ev.detail.value }); + } + + private _cardChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config) return; + + this._config.card = ev.detail.config; + fireEvent(this, 'config-changed', { config: this._config }); + } + + private _cardPicked(ev: CustomEvent): void { + this._selectedTab = 1; + this._cardChanged(ev); + } + + private _activateTab(ev: CustomEvent): void { + this._selectedTab = parseInt(ev.detail.selected); + } +} diff --git a/src/deep-replace.ts b/src/deep-replace.ts index 5c37752..0f6bfeb 100644 --- a/src/deep-replace.ts +++ b/src/deep-replace.ts @@ -2,8 +2,9 @@ import { VariablesConfig, TemplateConfig } from './types'; import { LovelaceCardConfig } from 'custom-card-helpers'; export default (variables: VariablesConfig[] | undefined, templateConfig: TemplateConfig): LovelaceCardConfig => { + const cardOrElement = templateConfig.card ?? templateConfig.element; if (!variables && !templateConfig.default) { - return templateConfig.card; + return cardOrElement; } let variableArray: VariablesConfig[] = []; if (variables) { @@ -12,7 +13,7 @@ export default (variables: VariablesConfig[] | undefined, templateConfig: Templa if (templateConfig.default) { variableArray = variableArray.concat(templateConfig.default); } - let jsonConfig = templateConfig.card ? JSON.stringify(templateConfig.card) : JSON.stringify(templateConfig.element); + let jsonConfig = JSON.stringify(cardOrElement); variableArray.forEach(variable => { const key = Object.keys(variable)[0]; const value = Object.values(variable)[0]; diff --git a/src/types.ts b/src/types.ts index 3c12ee2..2428f7f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,15 +1,21 @@ +import { LovelaceCardConfig } from 'custom-card-helpers'; + /* eslint-disable @typescript-eslint/no-explicit-any */ -export interface DeclutteringCardConfig { +export interface DeclutteringCardConfig extends LovelaceCardConfig { variables?: VariablesConfig[]; template: string; } +export interface DeclutteringTemplateConfig extends LovelaceCardConfig, TemplateConfig { + template: string; +} + export interface VariablesConfig { [key: string]: any; } export interface TemplateConfig { - default: VariablesConfig[]; + default?: VariablesConfig[]; card?: any; element?: any; } diff --git a/src/utils.ts b/src/utils.ts index ca272bd..4c9fe31 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -34,3 +34,8 @@ export function getLovelace(): LovelaceConfig | null { } return null; } + +export function getLovelaceConfig(): LovelaceConfig | null { + const ll = getLovelace() || getLovelaceCast(); + return ll?.config; +}