From 51a54b99def698d25b7d9a15eaef7e7d98f2f103 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Fri, 15 Mar 2024 02:19:52 -0700 Subject: [PATCH 1/7] 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; +} From 7e64589f6bb70a9d67baf1145deb3cb62db5202c Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 16 Mar 2024 14:14:21 -0700 Subject: [PATCH 2/7] Improve polymorphism, fully support cards, elements, and entity rows. Represent each type as an abstract thing instead of pretending they are all cards. Choose the correct editor for each type (when one is available). Decluttering cards can now be safely embedded within entities cards as entity row and within picture-elements cards as elements. --- src/decluttering-card.ts | 244 +++++++++++++++++++++++++++++---------- src/deep-replace.ts | 11 +- src/types.ts | 29 ++++- 3 files changed, 217 insertions(+), 67 deletions(-) diff --git a/src/decluttering-card.ts b/src/decluttering-card.ts index 961debb..e66a792 100644 --- a/src/decluttering-card.ts +++ b/src/decluttering-card.ts @@ -3,12 +3,19 @@ import { HomeAssistant, createThing, fireEvent, - LovelaceCardConfig, LovelaceCard, LovelaceCardEditor, LovelaceConfig, } from 'custom-card-helpers'; -import { DeclutteringCardConfig, DeclutteringTemplateConfig, TemplateConfig, VariablesConfig } from './types'; +import { + DeclutteringCardConfig, + DeclutteringTemplateConfig, + TemplateConfig, + VariablesConfig, + LovelaceThing, + LovelaceThingConfig, + LovelaceThingType, +} from './types'; import deepReplace from './deep-replace'; import { getLovelaceConfig } from './utils'; import { ResizeObserver } from 'resize-observer'; @@ -23,7 +30,7 @@ console.info( 'color: white; font-weight: bold; background: dimgray', ); -async function loadCardPicker(): Promise { +async function loadCardEditorPicker(): 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'); @@ -32,7 +39,22 @@ async function loadCardPicker(): Promise { await customElements.whenDefined('hui-vertical-stack-card'); cls = customElements.get('hui-vertical-stack-card'); } - if (cls) await cls.prototype.constructor.getConfigElement(); + if (cls) cls = cls.prototype.constructor; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (cls && (cls as any).getConfigElement) await (cls as any).getConfigElement(); +} + +async function loadRowEditor(): Promise { + // Ensure hui-row-element-editor 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-entities-card'); + if (!cls) { + (await HELPERS).createCardElement({ type: 'entities', entities: [] }); + await customElements.whenDefined('hui-entities-card'); + cls = customElements.get('hui-entities-card'); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (cls && (cls as any).getConfigElement) await (cls as any).getConfigElement(); } function getTemplateConfig(ll: LovelaceConfig, template: string): TemplateConfig | null { @@ -102,18 +124,24 @@ function getTemplates(ll: LovelaceConfig): Record { return templates; } -class DeclutteringElement extends LitElement { +function getThingType(templateConfig: TemplateConfig): LovelaceThingType | undefined { + const thingTypes = Object.keys(templateConfig).filter(key => ['card', 'row', 'element'].includes(key)); + return thingTypes.length === 1 ? (thingTypes[0] as LovelaceThingType) : undefined; +} + +abstract class DeclutteringElement extends LitElement { @state() private _hass?: HomeAssistant; - @state() private _card?: LovelaceCard; + @state() private _thing?: LovelaceThing; - private _config?: LovelaceCardConfig; + private _thingConfig?: LovelaceThingConfig; + private _thingType?: LovelaceThingType; private _ro?: ResizeObserver; private _savedStyles?: Map; set hass(hass: HomeAssistant) { if (!hass) return; this._hass = hass; - if (this._card) this._card.hass = hass; + if (this._thing) this._thing.hass = hass; } static get styles(): CSSResult { @@ -135,7 +163,7 @@ class DeclutteringElement extends LitElement { } protected _displayHidden(): void { - if (this._card?.style.display === 'none') { + if (this._thing?.style.display === 'none') { this.classList.add('child-card-hidden'); } else if (this.classList.contains('child-card-hidden')) { this.classList.remove('child-card-hidden'); @@ -143,21 +171,22 @@ class DeclutteringElement extends LitElement { } 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 cannnot define both a card and an element in the template'); + const thingType = getThingType(templateConfig); + if (!thingType) { + throw new Error('You must define one card, element, or row in the template'); } + const thingConfig = deepReplace(variables, templateConfig); - 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); + this._thingConfig = thingConfig; + this._thingType = thingType; + DeclutteringElement._createThing(thingConfig, thingType, (thing: LovelaceThing) => { + if (this._thingConfig === thingConfig) { + this._setThing(thing, thingType === 'element' ? thingConfig.style : undefined); + } }); } - private _setCard(card: LovelaceCard, style?: Record): void { + private _setThing(thing: LovelaceThing, style?: Record): void { this._savedStyles?.forEach((v, k) => this.style.setProperty(k, v[0], v[1])); this._savedStyles = undefined; @@ -169,56 +198,60 @@ class DeclutteringElement extends LitElement { }); } - this._card = card; - if (this._hass) card.hass = this._hass; + this._thing = thing; + if (this._hass) thing.hass = this._hass; this._ro = new ResizeObserver(() => { this._displayHidden(); }); - this._ro.observe(card); + this._ro.observe(thing); } protected render(): TemplateResult | void { - if (!this._hass || !this._card) return html``; + if (!this._hass || !this._thing) return html``; return html` -
${this._card}
+
${this._thing}
`; } - private static async _createCard( - config: LovelaceCardConfig, - type: 'element' | 'card', - handler: (card: LovelaceCard) => void, + private static async _createThing( + thingConfig: LovelaceThingConfig, + thingType: LovelaceThingType, + handler: (thing: LovelaceThing) => void, ): Promise { - let element: LovelaceCard; + let thing: LovelaceThing; if (HELPERS) { - if (type === 'card') { - if (config.type === 'divider') element = (await HELPERS).createRowElement(config); - else element = (await HELPERS).createCardElement(config); - // fireEvent(element, 'll-rebuild'); + if (thingType === 'card') { + if (thingConfig.type === 'divider') thing = (await HELPERS).createRowElement(thingConfig); + else thing = (await HELPERS).createCardElement(thingConfig); + } else if (thingType === 'row') { + thing = (await HELPERS).createRowElement(thingConfig); + } else if (thingType === 'element') { + thing = (await HELPERS).createHuiElement(thingConfig); } else { - element = (await HELPERS).createHuiElement(config); + throw new Error(`Unsupported thing type '${thingType}'`); } } else { - element = createThing(config); + thing = createThing(thingConfig, thingType === 'row'); } - element.addEventListener( + thing.addEventListener( 'll-rebuild', ev => { ev.stopPropagation(); - DeclutteringElement._createCard(config, type, (card: LovelaceCard) => { - element.replaceWith(card); - handler(card); + DeclutteringElement._createThing(thingConfig, thingType, (newThing: LovelaceThing) => { + thing.replaceWith(newThing); + handler(newThing); }); }, { once: true }, ); - element.id = 'declutter-child'; - handler(element); + thing.id = 'declutter-child'; + handler(thing); } + // for LovelaceCard public getCardSize(): Promise | number { - return this._card && typeof this._card.getCardSize === 'function' ? this._card.getCardSize() : 1; + return this._thing && this._thingType === 'card' ? (this._thing as LovelaceCard).getCardSize() : 1; } } @@ -275,7 +308,6 @@ class DeclutteringCardEditor extends LitElement implements LovelaceCardEditor { 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; @@ -288,7 +320,13 @@ class DeclutteringCardEditor extends LitElement implements LovelaceCardEditor { } protected render(): TemplateResult | void { - if (!this.hass || !this._lovelace || !this._config) return html``; + if (!this.hass || !this._config) return html``; + + if (!this._lovelace) { + // The lovelace property is not set when editing row elements so we retrieve it here + this._lovelace = getLovelaceConfig() ?? undefined; + if (!this._lovelace) return; + } if (!this._templates) this._templates = getTemplates(this._lovelace); if (!this._schema) { @@ -419,7 +457,7 @@ class DeclutteringTemplate extends DeclutteringElement { // eslint-disable-next-line @typescript-eslint/no-unused-vars class DeclutteringTemplateEditor extends LitElement implements LovelaceCardEditor { @state() private _config?: DeclutteringTemplateConfig; - @state() private _selectedTab = 0; + @state() private _selectedTab = 'settings'; @property() public lovelace?: LovelaceConfig; @property() public hass?: HomeAssistant; @@ -432,6 +470,20 @@ class DeclutteringTemplateEditor extends LitElement implements LovelaceCardEdito label: 'Template to define', selector: { text: {} }, }, + { + name: 'thingType', + label: 'Type of thing to template', + selector: { + select: { + mode: 'dropdown', + options: [ + { value: 'card', label: 'Card' }, + { value: 'row', label: 'Row' }, + { value: 'element', label: 'Element' }, + ], + }, + }, + }, { name: 'default', label: 'Variables', @@ -465,7 +517,8 @@ class DeclutteringTemplateEditor extends LitElement implements LovelaceCardEdito super.connectedCallback(); if (!this._loadedElements) { - await loadCardPicker(); + await loadCardEditorPicker(); + await loadRowEditor(); this._loadedElements = true; } } @@ -478,19 +531,39 @@ class DeclutteringTemplateEditor extends LitElement implements LovelaceCardEdito error.default = 'The list of variables must be an array of key and value pairs'; } + const data = { + template: this._config.template, + thingType: getThingType(this._config) ?? 'card', + default: this._config.default, + }; + return html`
- - Settings - Card - Change Card Type + + Settings + ${data.thingType === 'card' + ? html` + Card + Change Card Type + ` + : data.thingType === 'row' + ? html` + Row + ` + : html``}
- ${this._selectedTab === 0 + ${this._selectedTab === 'settings' ? html` s.label ?? s.name} @@ -498,7 +571,7 @@ class DeclutteringTemplateEditor extends LitElement implements LovelaceCardEdito @value-changed=${this._valueChanged} > ` - : this._selectedTab == 1 + : this._selectedTab === 'card' ? html` ` - : html` + : this._selectedTab === 'change_card' + ? html` - `} + ` + : this._selectedTab === 'row' + ? html` + + ` + : html``} `; } + private _activateTab(ev: CustomEvent): void { + this._selectedTab = ev.detail.selected; + } + private _valueChanged(ev: CustomEvent): void { - fireEvent(this, 'config-changed', { config: ev.detail.value }); + if (!this._config) return; + const data = ev.detail.value; + + this._config.template = data.template; + DeclutteringTemplateEditor.stubMember(data.thingType === 'card', this._config, 'card', { + type: 'entity', + entity: 'sun.sun', + }); + DeclutteringTemplateEditor.stubMember(data.thingType === 'row', this._config, 'row', { + entity: 'sun.sun', + }); + DeclutteringTemplateEditor.stubMember(data.thingType === 'element', this._config, 'element', { + type: 'icon', + icon: 'mdi:weather-sunny', + style: { + color: 'yellow', + }, + }); + this._config.default = data.default; + this._fireConfigChanged(); } private _cardChanged(ev: CustomEvent): void { @@ -526,15 +633,32 @@ class DeclutteringTemplateEditor extends LitElement implements LovelaceCardEdito if (!this._config) return; this._config.card = ev.detail.config; - fireEvent(this, 'config-changed', { config: this._config }); + this._fireConfigChanged(); } private _cardPicked(ev: CustomEvent): void { - this._selectedTab = 1; + this._selectedTab = 'card'; this._cardChanged(ev); } - private _activateTab(ev: CustomEvent): void { - this._selectedTab = parseInt(ev.detail.selected); + private _rowChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config) return; + + this._config.row = ev.detail.config; + this._fireConfigChanged(); + } + + private _fireConfigChanged(): void { + fireEvent(this, 'config-changed', { config: this._config }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private static stubMember(include: boolean, dict: any, name: string, stub: any): void { + if (include) { + if (!(name in dict)) dict[name] = stub; + } else { + delete dict[name]; + } } } diff --git a/src/deep-replace.ts b/src/deep-replace.ts index 0f6bfeb..eb8a649 100644 --- a/src/deep-replace.ts +++ b/src/deep-replace.ts @@ -1,10 +1,9 @@ -import { VariablesConfig, TemplateConfig } from './types'; -import { LovelaceCardConfig } from 'custom-card-helpers'; +import { VariablesConfig, TemplateConfig, LovelaceThingConfig } from './types'; -export default (variables: VariablesConfig[] | undefined, templateConfig: TemplateConfig): LovelaceCardConfig => { - const cardOrElement = templateConfig.card ?? templateConfig.element; +export default (variables: VariablesConfig[] | undefined, templateConfig: TemplateConfig): LovelaceThingConfig => { + const content = templateConfig.card ?? templateConfig.element ?? templateConfig.row; if (!variables && !templateConfig.default) { - return cardOrElement; + return content; } let variableArray: VariablesConfig[] = []; if (variables) { @@ -13,7 +12,7 @@ export default (variables: VariablesConfig[] | undefined, templateConfig: Templa if (templateConfig.default) { variableArray = variableArray.concat(templateConfig.default); } - let jsonConfig = JSON.stringify(cardOrElement); + let jsonConfig = JSON.stringify(content); 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 2428f7f..e76c9b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { LovelaceCardConfig } from 'custom-card-helpers'; +import { HomeAssistant, LovelaceCard, LovelaceCardConfig } from 'custom-card-helpers'; /* eslint-disable @typescript-eslint/no-explicit-any */ export interface DeclutteringCardConfig extends LovelaceCardConfig { @@ -17,5 +17,32 @@ export interface VariablesConfig { export interface TemplateConfig { default?: VariablesConfig[]; card?: any; + row?: any; element?: any; } + +export interface LovelaceElement extends HTMLElement { + hass?: HomeAssistant; + setConfig(config: LovelaceElementConfig): void; +} + +export interface LovelaceElementConfig { + type: string; + style: Record; + [key: string]: any; +} + +export interface LovelaceRow extends HTMLElement { + hass?: HomeAssistant; + editMode?: boolean; + setConfig(config: LovelaceRowConfig); +} + +export interface LovelaceRowConfig { + type?: string; + [key: string]: any; +} + +export type LovelaceThing = LovelaceCard | LovelaceElement | LovelaceRow; +export type LovelaceThingConfig = LovelaceCardConfig | LovelaceElementConfig | LovelaceRowConfig; +export type LovelaceThingType = 'card' | 'row' | 'element'; From 6dd9e6a7d382441a4b5177c52913bcacd68bf1c2 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 16 Mar 2024 16:01:20 -0700 Subject: [PATCH 3/7] Update the documentation. --- README.md | 305 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 234 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index d84ad48..dfc6539 100644 --- a/README.md +++ b/README.md @@ -23,100 +23,267 @@ We all use multiple times the same block of configuration across our lovelace co ### Defining your templates -First, you need to define your templates. +There are two ways to define your templates. You can use both methods together. -The templates are defined in an object at the root of your lovelace configuration. This object needs to be named `decluttering_templates`. +#### Option 1. Create a template as a card with the visual editor or with YAML. -This object needs to contains your templates declaration, each template has a name and can contain variables. A variable needs to be enclosed in double square brackets `[[variable_name]]`. It will later be replaced by a real value when you instanciate a card which uses this template. If a variable is alone on it's line, enclose it in single quotes: `'[[variable_name]]'`. +Add a *Custom: Decluttering template* card in any view of your dashboard to define your template, +set variables with their default values, and preview the results with those defaults with the +visual editor. The card type is `custom:decluttering-template` in YAML. -You can also define default values for your variables in the `default` object. +You can place the template card anywhere and it will only visible when the dashboard is in edit mode. +Each template must have a unique name. -For a card: +**Example:** ```yaml -decluttering_templates: - - default: # This is optional - - : - - : - [...] - card: # This is where you put your card config (it can be a card embedding other cards) - type: custom:my-super-card - [...] +type: custom:decluttering-template +template: follow_the_sun +card: + type: entity + entity_id: sun.sun ``` -For a Picture-Element: +#### Option 2. Create a template at the root of your lovelace configuration. + +Open your dashboard's YAML configuration file or click on the *Raw configuration editor* menu item +in the dashboard. + +The templates are defined in an object at the root of your lovelace configuration. This object is +named `decluttering_templates` and it contains your template declarations. Each template must have +a unique name. + +**Example:** ```yaml +title: Example Dashboard decluttering_templates: - - default: # This is optional - - : - - : - [...] - element: # This is where you put your element config + follow_the_sun: + card: + type: entity + entity_id: sun.sun + touch_the_sun: + row: + type: button + entity: sun.sun + action_name: Boop + hello_sunshine: + element: type: icon - [...] + icon: mdi:weather-sunny + title: Hello! + style: + color: yellow +views: ``` -Example in your `lovelace-ui.yaml`: -```yaml -resources: - - url: /local/decluttering-card.js - type: module +**Syntax:** +```yaml decluttering_templates: - my_first_template: # This is the name of a template - default: - - icon: fire - card: - type: custom:button-card - name: '[[name]]' - icon: 'mdi:[[icon]]' +