diff --git a/src/panels/lovelace/editor/card-editor/hui-card-editor.ts b/src/panels/lovelace/editor/card-editor/hui-card-editor.ts index 5c786ad71e..ff4613ed0e 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-editor.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-editor.ts @@ -24,7 +24,7 @@ import type { import type { HomeAssistant } from "../../../../types"; import { handleStructError } from "../../common/structs/handle-errors"; import { getCardElementClass } from "../../create-element/create-card-element"; -import type { EntityConfig } from "../../entity-rows/types"; +import type { LovelaceRowConfig } from "../../entity-rows/types"; import type { LovelaceCardEditor } from "../../types"; import { GUISupportError } from "../gui-support-error"; import type { GUIModeChangedEvent } from "../types"; @@ -38,7 +38,7 @@ export interface ConfigChangedEvent { declare global { interface HASSDomEvents { "entities-changed": { - entities: EntityConfig[]; + entities: LovelaceRowConfig[]; }; "config-changed": ConfigChangedEvent; "GUImode-changed": GUIModeChangedEvent; diff --git a/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts index 21c884a702..01fc411c47 100644 --- a/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts @@ -4,26 +4,36 @@ import "@polymer/paper-listbox/paper-listbox"; import { customElement, html, + internalProperty, LitElement, property, - internalProperty, TemplateResult, } from "lit-element"; +import { + array, + assert, + boolean, + object, + optional, + string, + union, +} from "superstruct"; import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeRTLDirection } from "../../../../common/util/compute_rtl"; import "../../../../components/entity/state-badge"; import "../../../../components/ha-card"; +import "../../../../components/ha-formfield"; import "../../../../components/ha-icon"; import "../../../../components/ha-switch"; -import "../../../../components/ha-formfield"; import { HomeAssistant } from "../../../../types"; import { EntitiesCardConfig, EntitiesCardEntityConfig, } from "../../cards/types"; -import "../../components/hui-entity-editor"; import "../../components/hui-theme-select-editor"; import { headerFooterConfigStructs } from "../../header-footer/types"; import { LovelaceCardEditor } from "../../types"; +import "../hui-entities-card-row-editor"; import { processEditorEntities } from "../process-editor-entities"; import { EditorTarget, @@ -31,16 +41,6 @@ import { EntitiesEditorEvent, } from "../types"; import { configElementStyle } from "./config-elements-style"; -import { computeRTLDirection } from "../../../../common/util/compute_rtl"; -import { - string, - optional, - object, - boolean, - array, - union, - assert, -} from "superstruct"; const cardConfigStruct = object({ type: string(), @@ -127,11 +127,12 @@ export class HuiEntitiesCardEditor extends LitElement - + > `; } diff --git a/src/panels/lovelace/editor/hui-entities-card-row-editor.ts b/src/panels/lovelace/editor/hui-entities-card-row-editor.ts new file mode 100644 index 0000000000..760d285f9a --- /dev/null +++ b/src/panels/lovelace/editor/hui-entities-card-row-editor.ts @@ -0,0 +1,268 @@ +import { mdiClose, mdiDrag } from "@mdi/js"; +import { + css, + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, + PropertyValues, + TemplateResult, +} from "lit-element"; +import { guard } from "lit-html/directives/guard"; +import type { SortableEvent } from "sortablejs"; +import Sortable, { + AutoScroll, + OnSpill, +} from "sortablejs/modular/sortable.core.esm"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/entity/ha-entity-picker"; +import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker"; +import "../../../components/ha-icon-button"; +import { sortableStyles } from "../../../resources/ha-sortable-style"; +import { HomeAssistant } from "../../../types"; +import { EntityConfig, LovelaceRowConfig } from "../entity-rows/types"; + +@customElement("hui-entities-card-row-editor") +export class HuiEntitiesCardRowEditor extends LitElement { + @property({ attribute: false }) protected hass?: HomeAssistant; + + @property({ attribute: false }) protected entities?: LovelaceRowConfig[]; + + @property() protected label?: string; + + @internalProperty() private _attached = false; + + @internalProperty() private _renderEmptySortable = false; + + private _sortable?: Sortable; + + public connectedCallback() { + super.connectedCallback(); + this._attached = true; + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._attached = false; + } + + protected render(): TemplateResult { + if (!this.entities || !this.hass) { + return html``; + } + + return html` +

+ ${this.label || + `${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.entities" + )} (${this.hass!.localize( + "ui.panel.lovelace.editor.card.config.required" + )})`} +

+
+ ${guard([this.entities, this._renderEmptySortable], () => + this._renderEmptySortable + ? "" + : this.entities!.map((entityConf, index) => { + return html` +
+ + ${entityConf.type + ? html` +
+
+ + ${this.hass!.localize( + `ui.panel.lovelace.editor.card.entities.entity_row.${entityConf.type}` + )} + + ${this.hass!.localize( + "ui.panel.lovelace.editor.card.entities.edit_special_row" + )} +
+ + + +
+ ` + : html` + + `} +
+ `; + }) + )} +
+ + `; + } + + protected firstUpdated(): void { + Sortable.mount(OnSpill); + Sortable.mount(new AutoScroll()); + } + + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + + const attachedChanged = changedProps.has("_attached"); + const entitiesChanged = changedProps.has("entities"); + + if (!entitiesChanged && !attachedChanged) { + return; + } + + if (attachedChanged && !this._attached) { + // Tear down sortable, if available + this._sortable?.destroy(); + this._sortable = undefined; + return; + } + + if (!this._sortable && this.entities) { + this._createSortable(); + return; + } + + if (entitiesChanged) { + this._handleEntitiesChanged(); + } + } + + private async _handleEntitiesChanged() { + this._renderEmptySortable = true; + await this.updateComplete; + const container = this.shadowRoot!.querySelector(".entities")!; + while (container.lastElementChild) { + container.removeChild(container.lastElementChild); + } + this._renderEmptySortable = false; + } + + private _createSortable() { + this._sortable = new Sortable(this.shadowRoot!.querySelector(".entities"), { + animation: 150, + fallbackClass: "sortable-fallback", + handle: ".handle", + onEnd: async (evt: SortableEvent) => this._entityMoved(evt), + }); + } + + private async _addEntity(ev: CustomEvent): Promise { + const value = ev.detail.value; + if (value === "") { + return; + } + const newConfigEntities = this.entities!.concat({ + entity: value as string, + }); + (ev.target as HaEntityPicker).value = ""; + fireEvent(this, "entities-changed", { entities: newConfigEntities }); + } + + private _entityMoved(ev: SortableEvent): void { + if (ev.oldIndex === ev.newIndex) { + return; + } + + const newEntities = this.entities!.concat(); + + newEntities.splice(ev.newIndex!, 0, newEntities.splice(ev.oldIndex!, 1)[0]); + + fireEvent(this, "entities-changed", { entities: newEntities }); + } + + private _removeSpecialRow(ev: CustomEvent): void { + const index = (ev.currentTarget as any).index; + const newConfigEntities = this.entities!.concat(); + + newConfigEntities.splice(index, 1); + + fireEvent(this, "entities-changed", { entities: newConfigEntities }); + } + + private _valueChanged(ev: CustomEvent): void { + const value = ev.detail.value; + const index = (ev.target as any).index; + const newConfigEntities = this.entities!.concat(); + + if (value === "") { + newConfigEntities.splice(index, 1); + } else { + newConfigEntities[index] = { + ...newConfigEntities[index], + entity: value!, + }; + } + + fireEvent(this, "entities-changed", { entities: newConfigEntities }); + } + + static get styles(): CSSResult[] { + return [ + sortableStyles, + css` + .entity { + display: flex; + align-items: center; + } + .entity .handle { + padding-right: 8px; + cursor: move; + } + .entity ha-entity-picker { + flex-grow: 1; + } + .special-row { + height: 60px; + font-size: 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex-grow: 1; + } + + .special-row div { + display: flex; + flex-direction: column; + } + + .special-row mwc-icon-button { + --mdc-icon-button-size: 36px; + color: var(--secondary-text-color); + } + + .secondary { + font-size: 12px; + color: var(--secondary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-entities-card-row-editor": HuiEntitiesCardRowEditor; + } +} diff --git a/src/panels/lovelace/editor/types.ts b/src/panels/lovelace/editor/types.ts index 12d17de3ae..cdefe9ed02 100644 --- a/src/panels/lovelace/editor/types.ts +++ b/src/panels/lovelace/editor/types.ts @@ -1,4 +1,12 @@ -import { boolean, object, optional, string, union } from "superstruct"; +import { + any, + array, + boolean, + object, + optional, + string, + union, +} from "superstruct"; import { ActionConfig, LovelaceCardConfig, @@ -76,6 +84,86 @@ export const actionConfigStruct = object({ service_data: optional(object()), }); +const buttonEntitiesRowConfigStruct = object({ + type: string(), + name: string(), + action_name: optional(string()), + tap_action: actionConfigStruct, + hold_action: optional(actionConfigStruct), + double_tap_action: optional(actionConfigStruct), +}); + +const castEntitiesRowConfigStruct = object({ + type: string(), + view: string(), + dashboard: optional(string()), + name: optional(string()), + icon: optional(string()), + hide_if_unavailable: optional(string()), +}); + +const callServiceEntitiesRowConfigStruct = object({ + type: string(), + name: string(), + icon: optional(string()), + action_name: optional(string()), + service: string(), + service_data: optional(any()), +}); + +const conditionalEntitiesRowConfigStruct = object({ + type: string(), + row: any(), + conditions: array( + object({ + entity: string(), + state: optional(string()), + state_not: optional(string()), + }) + ), +}); + +const dividerEntitiesRowConfigStruct = object({ + type: string(), + style: optional(any()), +}); + +const sectionEntitiesRowConfigStruct = object({ + type: string(), + label: optional(string()), +}); + +const webLinkEntitiesRowConfigStruct = object({ + type: string(), + url: string(), + name: optional(string()), + icon: optional(string()), +}); + +const buttonsEntitiesRowConfigStruct = object({ + type: string(), + entities: array( + union([ + object({ + entity: string(), + icon: optional(string()), + image: optional(string()), + name: optional(string()), + }), + EntityId, + ]) + ), +}); + +const attributeEntitiesRowConfigStruct = object({ + type: string(), + entity: string(), + attribute: string(), + prefix: optional(string()), + suffix: optional(string()), + name: optional(string()), +}); + export const entitiesConfigStruct = union([ object({ entity: EntityId, @@ -90,4 +178,13 @@ export const entitiesConfigStruct = union([ double_tap_action: optional(actionConfigStruct), }), EntityId, + buttonEntitiesRowConfigStruct, + castEntitiesRowConfigStruct, + conditionalEntitiesRowConfigStruct, + dividerEntitiesRowConfigStruct, + sectionEntitiesRowConfigStruct, + webLinkEntitiesRowConfigStruct, + buttonsEntitiesRowConfigStruct, + attributeEntitiesRowConfigStruct, + callServiceEntitiesRowConfigStruct, ]); diff --git a/src/translations/en.json b/src/translations/en.json index f41d4074cf..85fa6823ad 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2266,7 +2266,20 @@ "name": "Entities", "show_header_toggle": "Show Header Toggle?", "toggle": "Toggle entities.", - "description": "The Entities card is the most common type of card. It groups items together into lists." + "description": "The Entities card is the most common type of card. It groups items together into lists.", + "special_row": "special row", + "edit_special_row": "Edit row using the code editor", + "entity_row": { + "divider": "Divider", + "call-service": "Call Service", + "section": "Section", + "weblink": "Web Link", + "attribute": "Attribute", + "buttons": "Buttons", + "conditional": "Conditional", + "cast": "Cast", + "button": "Button" + } }, "entity": { "name": "Entity",