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