Improve element editor and migrate heading-entity editor (#22034)

* Extract load config element

* Improve error by using ha-alert

* Create hui-hase-editor

* Migrate heading entity form to its own editor

* Rename editor

* Rename

* Rename

* Move heading entity to its own component

* Fix default action for heading entity
This commit is contained in:
Paul Bottein 2024-09-24 11:17:29 +02:00 committed by GitHub
parent a759767d79
commit c4a700a55c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 532 additions and 575 deletions

View File

@ -0,0 +1,113 @@
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import "../../../../components/ha-card";
import "../../../../components/ha-icon";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-state-icon";
import { ActionHandlerEvent } from "../../../../data/lovelace/action_handler";
import "../../../../state-display/state-display";
import { HomeAssistant } from "../../../../types";
import { actionHandler } from "../../common/directives/action-handler-directive";
import { handleAction } from "../../common/handle-action";
import { hasAction } from "../../common/has-action";
import type { HeadingEntityConfig } from "../types";
@customElement("hui-heading-entity")
export class HuiHeadingEntity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: HeadingEntityConfig | string;
private _handleAction(ev: ActionHandlerEvent) {
const config: HeadingEntityConfig = {
tap_action: {
action: "none",
},
...this._config(this.config),
};
handleAction(this, this.hass!, config, ev.detail.action!);
}
private _config = memoizeOne(
(configOrString: HeadingEntityConfig | string): HeadingEntityConfig => {
const config =
typeof configOrString === "string"
? { entity: configOrString }
: configOrString;
return {
tap_action: {
action: "none",
},
...config,
};
}
);
protected render() {
const config = this._config(this.config);
const stateObj = this.hass!.states[config.entity];
if (!stateObj) {
return nothing;
}
const actionable = hasAction(config.tap_action);
return html`
<div
class="entity"
@action=${this._handleAction}
.actionHandler=${actionHandler()}
role=${ifDefined(actionable ? "button" : undefined)}
tabindex=${ifDefined(actionable ? "0" : undefined)}
>
<ha-state-icon
.hass=${this.hass}
.icon=${config.icon}
.stateObj=${stateObj}
></ha-state-icon>
<state-display
.hass=${this.hass}
.stateObj=${stateObj}
.content=${config.content || "state"}
></state-display>
</div>
`;
}
static get styles(): CSSResultGroup {
return css`
[role="button"] {
cursor: pointer;
}
.entity {
display: flex;
flex-direction: row;
white-space: nowrap;
align-items: center;
gap: 3px;
color: var(--secondary-text-color);
font-family: Roboto;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: 0.1px;
--mdc-icon-size: 14px;
}
.entity ha-state-icon {
--ha-icon-display: block;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-heading-entity": HuiHeadingEntity;
}
}

View File

@ -16,7 +16,8 @@ import type {
LovelaceCardEditor,
LovelaceLayoutOptions,
} from "../types";
import type { HeadingCardConfig, HeadingCardEntityConfig } from "./types";
import "./heading/hui-heading-entity";
import type { HeadingCardConfig } from "./types";
@customElement("hui-heading-card")
export class HuiHeadingCard extends LitElement implements LovelaceCard {
@ -91,8 +92,11 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
${this._config.entities?.length
? html`
<div class="entities">
${this._config.entities.map((config) =>
this._renderEntity(config)
${this._config.entities.map(
(config) => html`
<hui-heading-entity .config=${config} .hass=${this.hass}>
</hui-heading-entity>
`
)}
</div>
`
@ -102,54 +106,6 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
`;
}
private _handleEntityAction(ev: ActionHandlerEvent) {
const config = {
tap_action: {
action: "none",
},
...(ev.currentTarget as any).config,
};
handleAction(this, this.hass!, config, ev.detail.action!);
}
_renderEntity(entityConfig: string | HeadingCardEntityConfig) {
const config =
typeof entityConfig === "string"
? { entity: entityConfig }
: entityConfig;
const stateObj = this.hass!.states[config.entity];
if (!stateObj) {
return nothing;
}
const actionable = hasAction(config.tap_action || { action: "none" });
return html`
<div
.config=${config}
class="entity"
@action=${this._handleEntityAction}
.actionHandler=${actionHandler()}
role=${ifDefined(actionable ? "button" : undefined)}
tabindex=${ifDefined(actionable ? "0" : undefined)}
>
<ha-state-icon
.hass=${this.hass}
.icon=${config.icon}
.stateObj=${stateObj}
></ha-state-icon>
<state-display
.hass=${this.hass}
.stateObj=${stateObj}
.content=${config.content || "state"}
></state-display>
</div>
`;
}
static get styles(): CSSResultGroup {
return css`
ha-card {
@ -231,24 +187,6 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
justify-content: flex-end;
gap: 4px 10px;
}
.entities .entity {
display: flex;
flex-direction: row;
white-space: nowrap;
align-items: center;
gap: 3px;
color: var(--secondary-text-color);
font-family: Roboto;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: 0.1px;
--mdc-icon-size: 14px;
}
.entities .entity ha-state-icon {
--ha-icon-display: block;
}
`;
}
}

View File

@ -503,7 +503,7 @@ export interface TileCardConfig extends LovelaceCardConfig {
features?: LovelaceCardFeatureConfig[];
}
export interface HeadingCardEntityConfig {
export interface HeadingEntityConfig {
entity: string;
content?: string | string[];
icon?: string;
@ -515,5 +515,5 @@ export interface HeadingCardConfig extends LovelaceCardConfig {
heading?: string;
icon?: string;
tap_action?: ActionConfig;
entities?: (string | HeadingCardEntityConfig)[];
entities?: (string | HeadingEntityConfig)[];
}

View File

@ -5,13 +5,13 @@ import { customElement, state } from "lit/decorators";
import { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import { getBadgeElementClass } from "../../create-element/create-badge-element";
import type { LovelaceCardEditor, LovelaceConfigForm } from "../../types";
import { HuiElementEditor } from "../hui-element-editor";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
import "./hui-badge-visibility-editor";
const tabs = ["config", "visibility"] as const;
@customElement("hui-badge-element-editor")
export class HuiBadgeElementEditor extends HuiElementEditor<LovelaceBadgeConfig> {
export class HuiBadgeElementEditor extends HuiTypedElementEditor<LovelaceBadgeConfig> {
@state() private _currTab: (typeof tabs)[number] = tabs[0];
protected async getConfigElement(): Promise<LovelaceCardEditor | undefined> {
@ -88,7 +88,7 @@ export class HuiBadgeElementEditor extends HuiElementEditor<LovelaceBadgeConfig>
static get styles(): CSSResultGroup {
return [
HuiElementEditor.styles,
HuiTypedElementEditor.styles,
css`
mwc-tab-bar {
text-transform: uppercase;

View File

@ -5,7 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import { getCardElementClass } from "../../create-element/create-card-element";
import type { LovelaceCardEditor, LovelaceConfigForm } from "../../types";
import { HuiElementEditor } from "../hui-element-editor";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
import "./hui-card-layout-editor";
import "./hui-card-visibility-editor";
import { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
@ -13,7 +13,7 @@ import { LovelaceSectionConfig } from "../../../../data/lovelace/config/section"
const tabs = ["config", "visibility", "layout"] as const;
@customElement("hui-card-element-editor")
export class HuiCardElementEditor extends HuiElementEditor<LovelaceCardConfig> {
export class HuiCardElementEditor extends HuiTypedElementEditor<LovelaceCardConfig> {
@property({ type: Boolean, attribute: "show-visibility-tab" })
public showVisibilityTab = false;
@ -119,7 +119,7 @@ export class HuiCardElementEditor extends HuiElementEditor<LovelaceCardConfig> {
static get styles(): CSSResultGroup {
return [
HuiElementEditor.styles,
HuiTypedElementEditor.styles,
css`
mwc-tab-bar {
text-transform: uppercase;

View File

@ -8,11 +8,10 @@ import {
array,
assert,
assign,
literal,
enums,
object,
optional,
string,
union,
} from "superstruct";
import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event";
import { LocalizeFunc } from "../../../../common/translations/localize";
@ -24,17 +23,14 @@ import type {
} from "../../../../components/ha-form/types";
import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
import type {
HeadingCardConfig,
HeadingCardEntityConfig,
} from "../../cards/types";
import type { HeadingCardConfig, HeadingEntityConfig } from "../../cards/types";
import { UiAction } from "../../components/hui-action-editor";
import type { LovelaceCardEditor } from "../../types";
import "../hui-sub-form-editor";
import "../hui-sub-element-editor";
import { processEditorEntities } from "../process-editor-entities";
import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { SubFormEditorData } from "../types";
import { SubElementEditorConfig } from "../types";
import { configElementStyle } from "./config-elements-style";
import "./hui-entities-editor";
@ -43,7 +39,7 @@ const actions: UiAction[] = ["navigate", "url", "perform-action", "none"];
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
heading_style: optional(union([literal("title"), literal("subtitle")])),
heading_style: optional(enums(["title", "subtitle"])),
heading: optional(string()),
icon: optional(string()),
tap_action: optional(actionConfigStruct),
@ -51,13 +47,6 @@ const cardConfigStruct = assign(
})
);
const entityConfigStruct = object({
entity: string(),
content: optional(union([string(), array(string())])),
icon: optional(string()),
tap_action: optional(actionConfigStruct),
});
@customElement("hui-heading-card-editor")
export class HuiHeadingCardEditor
extends LitElement
@ -68,17 +57,13 @@ export class HuiHeadingCardEditor
@state() private _config?: HeadingCardConfig;
@state()
private _entityFormEditorData?: SubFormEditorData<HeadingCardEntityConfig>;
private _subElementEditorConfig?: SubElementEditorConfig;
public setConfig(config: HeadingCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
}
public _assertEntityConfig(config: HeadingCardEntityConfig): void {
assert(config, entityConfigStruct);
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
@ -123,68 +108,27 @@ export class HuiHeadingCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _entitySchema = memoizeOne(
() =>
[
{
name: "entity",
selector: { entity: {} },
},
{
name: "icon",
selector: { icon: {} },
context: { icon_entity: "entity" },
},
{
name: "content",
selector: { ui_state_content: {} },
context: { filter_entity: "entity" },
},
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "none",
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
return cache(
this._entityFormEditorData ? this._renderEntityForm() : this._renderForm()
this._subElementEditorConfig
? this._renderEntityForm()
: this._renderForm()
);
}
private _renderEntityForm() {
const schema = this._entitySchema();
return html`
<hui-sub-form-editor
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.entities.form-label"
)}
<hui-sub-element-editor
.hass=${this.hass}
.data=${this._entityFormEditorData!.data}
.config=${this._subElementEditorConfig}
@go-back=${this._goBack}
@value-changed=${this._subFormChanged}
.schema=${schema}
.assertConfig=${this._assertEntityConfig}
.computeLabel=${this._computeEntityLabelCallback}
@config-changed=${this._subElementChanged}
>
</hui-sub-form-editor>
</hui-sub-element-editor>
`;
}
@ -239,7 +183,7 @@ export class HuiHeadingCardEditor
const config = {
...this._config,
entities: ev.detail.entities as HeadingCardEntityConfig[],
entities: ev.detail.entities as HeadingEntityConfig[],
};
fireEvent(this, "config-changed", { config });
@ -256,30 +200,30 @@ export class HuiHeadingCardEditor
fireEvent(this, "config-changed", { config });
}
private _subFormChanged(ev: CustomEvent): void {
private _subElementChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const value = ev.detail.value;
const value = ev.detail.config;
const newEntities = this._config!.entities
const newConfigEntities = this._config!.entities
? [...this._config!.entities]
: [];
if (!value) {
newEntities.splice(this._entityFormEditorData!.index!, 1);
newConfigEntities.splice(this._subElementEditorConfig!.index!, 1);
this._goBack();
} else {
newEntities[this._entityFormEditorData!.index!] = value;
newConfigEntities[this._subElementEditorConfig!.index!] = value;
}
this._config = { ...this._config!, entities: newEntities };
this._config = { ...this._config!, entities: newConfigEntities };
this._entityFormEditorData = {
...this._entityFormEditorData!,
data: value,
this._subElementEditorConfig = {
...this._subElementEditorConfig!,
elementConfig: value,
};
fireEvent(this, "config-changed", { config: this._config });
@ -287,31 +231,17 @@ export class HuiHeadingCardEditor
private _editEntity(ev: HASSDomEvent<{ index: number }>): void {
const entities = this._entities(this._config!.entities);
this._entityFormEditorData = {
data: entities[ev.detail.index],
this._subElementEditorConfig = {
elementConfig: entities[ev.detail.index],
index: ev.detail.index,
type: "heading-entity",
};
}
private _goBack(): void {
this._entityFormEditorData = undefined;
this._subElementEditorConfig = undefined;
}
private _computeEntityLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._entitySchema>>
) => {
switch (schema.name) {
case "content":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.heading.entity_config.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {

View File

@ -2,10 +2,10 @@ import { customElement } from "lit/decorators";
import { LovelaceDashboardStrategyConfig } from "../../../../data/lovelace/config/types";
import { getLovelaceStrategy } from "../../strategies/get-strategy";
import { LovelaceStrategyEditor } from "../../strategies/types";
import { HuiElementEditor } from "../hui-element-editor";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
@customElement("hui-dashboard-strategy-element-editor")
export class HuiDashboardStrategyElementEditor extends HuiElementEditor<LovelaceDashboardStrategyConfig> {
export class HuiDashboardStrategyElementEditor extends HuiTypedElementEditor<LovelaceDashboardStrategyConfig> {
protected async getConfigElement(): Promise<
LovelaceStrategyEditor | undefined
> {

View File

@ -2,12 +2,12 @@ import { customElement } from "lit/decorators";
import { getRowElementClass } from "../../create-element/create-row-element";
import { LovelaceRowConfig } from "../../entity-rows/types";
import type { LovelaceRowEditor } from "../../types";
import { HuiElementEditor } from "../hui-element-editor";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
const GENERIC_ROW_TYPE = "generic-row";
@customElement("hui-row-element-editor")
export class HuiRowElementEditor extends HuiElementEditor<LovelaceRowConfig> {
export class HuiRowElementEditor extends HuiTypedElementEditor<LovelaceRowConfig> {
protected get configElementType(): string | undefined {
if (!this.value?.type && "entity" in this.value!) {
return GENERIC_ROW_TYPE;

View File

@ -8,10 +8,10 @@ import type {
LovelaceConfigForm,
LovelaceCardFeatureEditor,
} from "../../types";
import { HuiElementEditor } from "../hui-element-editor";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
@customElement("hui-card-feature-element-editor")
export class HuiCardFeatureElementEditor extends HuiElementEditor<
export class HuiCardFeatureElementEditor extends HuiTypedElementEditor<
LovelaceCardFeatureConfig,
LovelaceCardFeatureContext
> {

View File

@ -2,10 +2,10 @@ import { customElement } from "lit/decorators";
import { getHeaderFooterElementClass } from "../../create-element/create-header-footer-element";
import type { LovelaceHeaderFooterConfig } from "../../header-footer/types";
import type { LovelaceHeaderFooterEditor } from "../../types";
import { HuiElementEditor } from "../hui-element-editor";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
@customElement("hui-headerfooter-element-editor")
export class HuiHeaderFooterElementEditor extends HuiElementEditor<LovelaceHeaderFooterConfig> {
export class HuiHeaderFooterElementEditor extends HuiTypedElementEditor<LovelaceHeaderFooterConfig> {
protected async getConfigElement(): Promise<
LovelaceHeaderFooterEditor | undefined
> {

View File

@ -0,0 +1,140 @@
import { mdiGestureTap } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { array, assert, object, optional, string, union } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig, HeadingEntityConfig } from "../../cards/types";
import type { LovelaceGenericElementEditor } from "../../types";
import { configElementStyle } from "../config-elements/config-elements-style";
import { actionConfigStruct } from "../structs/action-struct";
const entityConfigStruct = object({
entity: string(),
content: optional(union([string(), array(string())])),
icon: optional(string()),
tap_action: optional(actionConfigStruct),
});
@customElement("hui-heading-entity-editor")
export class HuiHeadingEntityEditor
extends LitElement
implements LovelaceGenericElementEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: HeadingEntityConfig;
public setConfig(config: HeadingEntityConfig): void {
assert(config, entityConfigStruct);
this._config = config;
}
private _schema = memoizeOne(
() =>
[
{
name: "entity",
selector: { entity: {} },
},
{
name: "icon",
selector: { icon: {} },
context: { icon_entity: "entity" },
},
{
name: "content",
selector: { ui_state_content: {} },
context: { filter_entity: "entity" },
},
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "none",
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const schema = this._schema();
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const config = ev.detail.value as HeadingCardConfig;
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "content":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.heading.entity_config.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
static get styles() {
return [
configElementStyle,
css`
.container {
display: flex;
flex-direction: column;
}
ha-form {
display: block;
margin-bottom: 24px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-heading-entity-editor": HuiHeadingEntityEditor;
}
}

View File

@ -0,0 +1,20 @@
import { customElement } from "lit/decorators";
import { HeadingEntityConfig } from "../../cards/types";
import { HuiElementEditor } from "../hui-element-editor";
import type { HuiHeadingEntityEditor } from "./hui-heading-entity-editor";
@customElement("hui-heading-entity-element-editor")
export class HuiHeadingEntityElementEditor extends HuiElementEditor<HeadingEntityConfig> {
protected async getConfigElement(): Promise<
HuiHeadingEntityEditor | undefined
> {
await import("./hui-heading-entity-editor");
return document.createElement("hui-heading-entity-editor");
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-heading-entity-element-editor": HuiHeadingEntityElementEditor;
}
}

View File

@ -1,4 +1,3 @@
import "@material/mwc-button";
import { dump, load } from "js-yaml";
import {
CSSResultGroup,
@ -7,6 +6,7 @@ import {
TemplateResult,
css,
html,
nothing,
} from "lit";
import { property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
@ -16,34 +16,18 @@ import "../../../components/ha-alert";
import "../../../components/ha-circular-progress";
import "../../../components/ha-code-editor";
import type { HaCodeEditor } from "../../../components/ha-code-editor";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import { LovelaceConfig } from "../../../data/lovelace/config/types";
import type { HomeAssistant } from "../../../types";
import { LovelaceCardFeatureConfig } from "../card-features/types";
import type { LovelaceRowConfig } from "../entity-rows/types";
import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { LovelaceElementConfig } from "../elements/types";
import type {
LovelaceConfigForm,
LovelaceGenericElementEditor,
} from "../types";
import "./card-editor/hui-card-visibility-editor";
import type { HuiFormEditor } from "./config-elements/hui-form-editor";
import "./config-elements/hui-generic-entity-row-editor";
import { GUISupportError } from "./gui-support-error";
import { EditSubElementEvent, GUIModeChangedEvent } from "./types";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
export interface ConfigChangedEvent {
config:
| LovelaceCardConfig
| LovelaceRowConfig
| LovelaceHeaderFooterConfig
| LovelaceCardFeatureConfig
| LovelaceStrategyConfig
| LovelaceElementConfig
| LovelaceBadgeConfig;
export interface ConfigChangedEvent<T extends object = object> {
config: T;
error?: string;
guiModeAvailable?: boolean;
}
@ -56,17 +40,16 @@ declare global {
}
}
export interface UIConfigChangedEvent extends Event {
export interface UIConfigChangedEvent<T extends object = object> extends Event {
detail: {
config:
| LovelaceCardConfig
| LovelaceRowConfig
| LovelaceHeaderFooterConfig
| LovelaceCardFeatureConfig;
config: T;
};
}
export abstract class HuiElementEditor<T, C = any> extends LitElement {
export abstract class HuiElementEditor<
T extends object = object,
C = any,
> extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace?: LovelaceConfig;
@ -79,8 +62,6 @@ export abstract class HuiElementEditor<T, C = any> extends LitElement {
@state() private _configElement?: LovelaceGenericElementEditor;
@state() private _configElementType?: string;
@state() private _guiMode = true;
// Error: Configuration broken - do not save
@ -199,10 +180,6 @@ export abstract class HuiElementEditor<T, C = any> extends LitElement {
return undefined;
}
protected get configElementType(): string | undefined {
return this.value ? (this.value as any).type : undefined;
}
protected renderConfigElement(): TemplateResult {
return html`${this._configElement}`;
}
@ -239,45 +216,51 @@ export abstract class HuiElementEditor<T, C = any> extends LitElement {
></ha-code-editor>
</div>
`}
${this._guiSupported === false && this.configElementType
${this._guiSupported === false && this._loading === false
? html`
<div class="info">
${this.hass.localize("ui.errors.config.editor_not_available", {
type: this.configElementType,
})}
</div>
<ha-alert
alert-type="info"
.title=${this.hass.localize(
"ui.errors.config.visual_editor_not_supported"
)}
>
${this.hass.localize(
"ui.errors.config.visual_editor_not_supported_reason_type"
)}
<br />
${this.hass.localize("ui.errors.config.edit_in_yaml_supported")}
</ha-alert>
`
: ""}
: nothing}
${this.hasError
? html`
<div class="error">
${this.hass.localize("ui.errors.config.error_detected")}:
<br />
<ha-alert
alert-type="error"
.title=${this.hass.localize(
"ui.errors.config.configuration_error"
)}
>
<ul>
${this._errors!.map((error) => html`<li>${error}</li>`)}
</ul>
</div>
</ha-alert>
`
: ""}
: nothing}
${this.hasWarning
? html`
<ha-alert
alert-type="warning"
.title="${this.hass.localize(
"ui.errors.config.editor_not_supported"
)}:"
.title=${this.hass.localize(
"ui.errors.config.visual_editor_not_supported"
)}
>
${this._warnings!.length > 0 && this._warnings![0] !== undefined
? html` <ul>
${this._warnings!.map(
(warning) => html`<li>${warning}</li>`
)}
</ul>`
: ""}
<ul>
${this._warnings!.map((warning) => html`<li>${warning}</li>`)}
</ul>
${this.hass.localize("ui.errors.config.edit_in_yaml_supported")}
</ha-alert>
`
: ""}
: nothing}
</div>
`;
}
@ -300,7 +283,7 @@ export abstract class HuiElementEditor<T, C = any> extends LitElement {
}
}
private _handleUIConfigChanged(ev: UIConfigChangedEvent) {
private _handleUIConfigChanged(ev: UIConfigChangedEvent<T>) {
ev.stopPropagation();
const config = ev.detail.config;
Object.keys(config).forEach((key) => {
@ -319,66 +302,62 @@ export abstract class HuiElementEditor<T, C = any> extends LitElement {
}
}
protected async unloadConfigElement(): Promise<void> {
this._configElement = undefined;
this._guiSupported = undefined;
}
protected async loadConfigElement(): Promise<void> {
if (this._configElement) return;
let configElement = await this.getConfigElement();
if (!configElement) {
const form = await this.getConfigForm();
if (form) {
await import("./config-elements/hui-form-editor");
configElement = document.createElement("hui-form-editor");
const { schema, assertConfig, computeLabel, computeHelper } = form;
(configElement as HuiFormEditor).schema = schema;
if (computeLabel) {
(configElement as HuiFormEditor).computeLabel = computeLabel;
}
if (computeHelper) {
(configElement as HuiFormEditor).computeHelper = computeHelper;
}
if (assertConfig) {
(configElement as HuiFormEditor).assertConfig = assertConfig;
}
}
}
if (configElement) {
configElement.hass = this.hass;
if ("lovelace" in configElement) {
configElement.lovelace = this.lovelace;
}
configElement.context = this.context;
configElement.addEventListener("config-changed", (ev) =>
this._handleUIConfigChanged(ev as UIConfigChangedEvent<T>)
);
this._guiSupported = true;
} else {
this._guiSupported = false;
}
this._configElement = configElement;
}
private async _updateConfigElement(): Promise<void> {
if (!this.value) {
return;
}
let configElement: LovelaceGenericElementEditor | undefined;
try {
this._errors = undefined;
this._warnings = undefined;
if (this._configElementType !== this.configElementType) {
// If the type has changed, we need to load a new GUI editor
this._guiSupported = undefined;
this._configElement = undefined;
if (!this.configElementType) {
throw new Error(
this.hass.localize("ui.errors.config.no_type_provided")
);
}
this._configElementType = this.configElementType;
this._loading = true;
configElement = await this.getConfigElement();
if (!configElement) {
const form = await this.getConfigForm();
if (form) {
await import("./config-elements/hui-form-editor");
configElement = document.createElement("hui-form-editor");
const { schema, assertConfig, computeLabel, computeHelper } = form;
(configElement as HuiFormEditor).schema = schema;
if (computeLabel) {
(configElement as HuiFormEditor).computeLabel = computeLabel;
}
if (computeHelper) {
(configElement as HuiFormEditor).computeHelper = computeHelper;
}
if (assertConfig) {
(configElement as HuiFormEditor).assertConfig = assertConfig;
}
}
}
if (configElement) {
configElement.hass = this.hass;
if ("lovelace" in configElement) {
configElement.lovelace = this.lovelace;
}
configElement.context = this.context;
configElement.addEventListener("config-changed", (ev) =>
this._handleUIConfigChanged(ev as UIConfigChangedEvent)
);
this._configElement = configElement;
this._guiSupported = true;
}
}
await this.loadConfigElement();
if (this._configElement) {
// Setup GUI editor and check that it can handle the current config
@ -428,26 +407,6 @@ export abstract class HuiElementEditor<T, C = any> extends LitElement {
ha-code-editor {
--code-mirror-max-height: calc(100vh - 245px);
}
.error,
.warning,
.info {
word-break: break-word;
margin-top: 8px;
}
.error {
color: var(--error-color);
}
.warning {
color: var(--warning-color);
}
.warning ul,
.error ul {
margin: 4px 0;
}
.warning li,
.error li {
white-space: pre-wrap;
}
ha-circular-progress {
display: block;
margin: auto;

View File

@ -1,4 +1,3 @@
import "@material/mwc-button";
import { mdiCodeBraces, mdiListBoxOutline } from "@mdi/js";
import {
css,
@ -13,14 +12,13 @@ import { fireEvent, HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-prev";
import type { HomeAssistant } from "../../../types";
import type { LovelaceRowConfig } from "../entity-rows/types";
import type { LovelaceHeaderFooterConfig } from "../header-footer/types";
import "./entity-row-editor/hui-row-element-editor";
import "./header-footer-editor/hui-header-footer-element-editor";
import type { HuiElementEditor } from "./hui-element-editor";
import "./feature-editor/hui-card-feature-element-editor";
import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types";
import "./header-footer-editor/hui-header-footer-element-editor";
import "./heading-entity/hui-heading-entity-element-editor";
import type { HuiElementEditor } from "./hui-element-editor";
import "./picture-element-editor/hui-picture-element-element-editor";
import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types";
declare global {
interface HASSDomEvents {
@ -40,11 +38,14 @@ export class HuiSubElementEditor extends LitElement {
@state() private _guiMode = true;
@query(".editor") private _editorElement?: HuiElementEditor<
LovelaceRowConfig | LovelaceHeaderFooterConfig
>;
@query(".editor") private _editorElement?: HuiElementEditor;
protected render(): TemplateResult {
const elementType =
this.config.elementConfig && "type" in this.config.elementConfig
? this.config.elementConfig.type
: "";
return html`
<div class="header">
<div class="back-title">
@ -52,21 +53,21 @@ export class HuiSubElementEditor extends LitElement {
.label=${this.hass!.localize("ui.common.back")}
@click=${this._goBack}
></ha-icon-button-prev>
<span slot="title"
>${this.config?.type === "element"
<span slot="title">
${this.config?.type === "element"
? this.hass.localize(
`ui.panel.lovelace.editor.sub-element-editor.types.element_type`,
{
type:
this.hass.localize(
`ui.panel.lovelace.editor.card.picture-elements.element_types.${this.config?.elementConfig?.type}`
) || this.config?.elementConfig?.type,
`ui.panel.lovelace.editor.card.picture-elements.element_types.${elementType}`
) || elementType,
}
)
: this.hass.localize(
`ui.panel.lovelace.editor.sub-element-editor.types.${this.config?.type}`
)}</span
>
)}
</span>
</div>
<ha-icon-button
class="gui-mode-button"
@ -80,54 +81,74 @@ export class HuiSubElementEditor extends LitElement {
.path=${this._guiMode ? mdiCodeBraces : mdiListBoxOutline}
></ha-icon-button>
</div>
${this.config.type === "row"
? html`
<hui-row-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-row-element-editor>
`
: this.config.type === "header" || this.config.type === "footer"
? html`
<hui-headerfooter-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-headerfooter-element-editor>
`
: this.config.type === "feature"
? html`
<hui-card-feature-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-card-feature-element-editor>
`
: this.config.type === "element"
? html`
<hui-picture-element-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-picture-element-element-editor>
`
: nothing}
${this._renderEditor()}
`;
}
private _renderEditor() {
const type = this.config.type;
switch (type) {
case "row":
return html`
<hui-row-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-row-element-editor>
`;
case "header":
case "footer":
return html`
<hui-headerfooter-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-headerfooter-element-editor>
`;
case "element":
return html`
<hui-picture-element-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-picture-element-element-editor>
`;
case "feature":
return html`
<hui-card-feature-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-card-feature-element-editor>
`;
case "heading-entity":
return html`
<hui-heading-entity-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-heading-entity-element-editor>
`;
default:
return nothing;
}
}
private _goBack(): void {
fireEvent(this, "go-back");
}

View File

@ -1,190 +0,0 @@
import "@material/mwc-button";
import { mdiCodeBraces, mdiListBoxOutline } from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-form/ha-form";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-prev";
import "../../../components/ha-yaml-editor";
import "../../../components/ha-alert";
import type { HomeAssistant } from "../../../types";
import type { LovelaceConfigForm } from "../types";
import type { EditSubFormEvent } from "./types";
import { handleStructError } from "../../../common/structs/handle-errors";
declare global {
interface HASSDomEvents {
"go-back": undefined;
"edit-sub-form": EditSubFormEvent;
}
}
@customElement("hui-sub-form-editor")
export class HuiSubFormEditor<T = any> extends LitElement {
public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public data!: T;
public schema!: LovelaceConfigForm["schema"];
public assertConfig?: (config: T) => void;
public computeLabel?: LovelaceConfigForm["computeLabel"];
public computeHelper?: LovelaceConfigForm["computeHelper"];
@state() public _yamlMode = false;
@state() private _errors?: string[];
@state() private _warnings?: string[];
protected render(): TemplateResult {
const uiAvailable = !this.hasWarning && !this.hasError;
return html`
<div class="header">
<div class="back-title">
<ha-icon-button-prev
.label=${this.hass!.localize("ui.common.back")}
@click=${this._goBack}
></ha-icon-button-prev>
<span slot="title">${this.label}</span>
</div>
<ha-icon-button
class="gui-mode-button"
@click=${this._toggleMode}
.disabled=${!uiAvailable}
.label=${this.hass!.localize(
this._yamlMode
? "ui.panel.lovelace.editor.edit_card.show_visual_editor"
: "ui.panel.lovelace.editor.edit_card.show_code_editor"
)}
.path=${this._yamlMode ? mdiListBoxOutline : mdiCodeBraces}
></ha-icon-button>
</div>
${this._yamlMode
? html`
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this.data}
@value-changed=${this._valueChanged}
></ha-yaml-editor>
`
: html`
<ha-form
.hass=${this.hass}
.schema=${this.schema}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.data=${this.data}
@value-changed=${this._valueChanged}
>
</ha-form>
`}
${this.hasError
? html`
<ha-alert alert-type="error">
${this.hass.localize("ui.errors.config.error_detected")}:
<br />
<ul>
${this._errors!.map((error) => html`<li>${error}</li>`)}
</ul>
</ha-alert>
`
: nothing}
${this.hasWarning
? html`
<ha-alert
alert-type="warning"
.title="${this.hass.localize(
"ui.errors.config.editor_not_supported"
)}:"
>
${this._warnings!.length > 0 && this._warnings![0] !== undefined
? html`
<ul>
${this._warnings!.map(
(warning) => html`<li>${warning}</li>`
)}
</ul>
`
: nothing}
${this.hass.localize("ui.errors.config.edit_in_yaml_supported")}
</ha-alert>
`
: nothing}
`;
}
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("data")) {
if (this.assertConfig) {
try {
this.assertConfig(this.data);
this._warnings = undefined;
this._errors = undefined;
} catch (err: any) {
const msgs = handleStructError(this.hass, err);
this._warnings = msgs.warnings ?? [err.message];
this._errors = msgs.errors || undefined;
this._yamlMode = true;
}
}
}
}
public get hasWarning(): boolean {
return this._warnings !== undefined && this._warnings.length > 0;
}
public get hasError(): boolean {
return this._errors !== undefined && this._errors.length > 0;
}
private _goBack(): void {
fireEvent(this, "go-back");
}
private _toggleMode(): void {
this._yamlMode = !this._yamlMode;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const value = (ev.detail.value ?? (ev.target as any).value ?? {}) as T;
fireEvent(this, "value-changed", { value });
}
static get styles(): CSSResultGroup {
return css`
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.back-title {
display: flex;
align-items: center;
font-size: 18px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-sub-form-editor": HuiSubFormEditor;
}
}

View File

@ -0,0 +1,30 @@
import { state } from "lit/decorators";
import { HuiElementEditor } from "./hui-element-editor";
export abstract class HuiTypedElementEditor<
T extends object,
C = any,
> extends HuiElementEditor<T, C> {
@state() private _configElementType?: string;
protected get configElementType(): string | undefined {
return this.value ? (this.value as any).type : undefined;
}
protected async loadConfigElement(): Promise<void> {
// If the type has changed, we need to unload the current editor and load the new one
if (this._configElementType !== this.configElementType) {
this.unloadConfigElement();
if (!this.configElementType) {
throw new Error(
this.hass.localize("ui.errors.config.no_type_provided")
);
}
this._configElementType = this.configElementType;
}
return super.loadConfigElement();
}
}

View File

@ -1,11 +1,11 @@
import { customElement } from "lit/decorators";
import { LovelaceElementConfig } from "../../elements/types";
import type { LovelacePictureElementEditor } from "../../types";
import { HuiElementEditor } from "../hui-element-editor";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
import { getPictureElementClass } from "../../create-element/create-picture-element";
@customElement("hui-picture-element-element-editor")
export class HuiPictureElementElementEditor extends HuiElementEditor<LovelaceElementConfig> {
export class HuiPictureElementElementEditor extends HuiTypedElementEditor<LovelaceElementConfig> {
protected get configElementType(): string | undefined {
return this.value?.type === "action-button"
? "service-button"

View File

@ -9,6 +9,7 @@ import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { LovelaceCardFeatureConfig } from "../card-features/types";
import { LovelaceElementConfig } from "../elements/types";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { HeadingEntityConfig } from "../cards/types";
export interface YamlChangedEvent extends Event {
detail: {
@ -95,19 +96,11 @@ export interface SubElementEditorConfig {
| LovelaceRowConfig
| LovelaceHeaderFooterConfig
| LovelaceCardFeatureConfig
| LovelaceElementConfig;
type: "header" | "footer" | "row" | "feature" | "element";
| LovelaceElementConfig
| HeadingEntityConfig;
type: "header" | "footer" | "row" | "feature" | "element" | "heading-entity";
}
export interface EditSubElementEvent {
subElementConfig: SubElementEditorConfig;
}
export interface SubFormEditorData<T = any> {
index?: number;
data?: T;
}
export interface EditSubFormEvent<T = any> {
subFormData: SubFormEditorData<T>;
}

View File

@ -1796,11 +1796,13 @@
},
"errors": {
"config": {
"visual_editor_not_supported": "Visual editor not supported",
"visual_editor_not_supported_reason_type": "The visual editor is not available for this type of element.",
"edit_in_yaml_supported": "You can still edit your config using YAML.",
"configuration_error": "Configuration error",
"configuration_error_reason": "The configuration is not valid. Check the logs for more information.",
"no_type_provided": "No type provided.",
"error_detected": "Configuration errors detected",
"editor_not_available": "No visual editor available for type ''{type}''.",
"editor_not_supported": "Visual editor is not supported for this configuration",
"edit_in_yaml_supported": "You can still edit your config in YAML.",
"key_missing": "Required key ''{key}'' is missing.",
"key_not_expected": "Key ''{key}'' is not expected or not supported by the visual editor.",
"key_wrong_type": "The provided value for ''{key}'' is not supported by the visual editor. We support ({type_correct}) but received ({type_wrong}).",
@ -6379,6 +6381,7 @@
"row": "Entity row editor",
"feature": "Feature editor",
"element": "Element editor",
"heading-entity": "Entity editor",
"element_type": "{type} element editor"
}
}