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",