Custom Cards with GUI editor as of 2023

Sorry if this is the wrong category.

I have read the documentation again and again, and I have looked at other integrations and custom cards, but I still struggle to get this right.

In the documentation, there is an example of graphical card configuration, but it does not contain any form elements to actually do any changes to the config.

Other cards I have looked at include a variety of “paper” elements, but these are deprecated (or removed?).

There is a reference to the Boiler Plate Card, but that has not received any updates in a year, and the latest release is even older, and it includes a lot of code and elements that is probably fine to show off every single option and possibility, but that makes it hard to untangle what is really needed to get a simple card with a simple graphical editor to select a couple of entities up and running.

I have come as far as getting the actual card working fine.
And I can also find the card in the GUI card selector.
I have managed to create a list of selectable entities,

class ContentCardEditor extends LitElement {
(...)
  getEntitiesByType(type) {
    return Object.keys(this._hass.states).filter(
      (eid) => eid.substr(0, eid.indexOf('.')) === type
    );
  }
 render() {
    const supportedEntities = this.getEntitiesByType("plant");
    console.log(supportedEntities);
    return html`
      <mwc-select
        naturalMenuWidth
        fixedMenuPosition
        label="Plant (Required)"
        .configValue=${'entity'}
        .value=${this._config.entity}
        @selected=${this._valueChanged}
        @closed=${(ev) => ev.stopPropagation()}
      >
        ${supportedEntities.map((entity) => {
          return html`<mwc-list-item .value=${entity}>${entity}</mwc-list-item>`;
        })}
      </mwc-select>
    `;
  }

(the code is basically copied from here)

image

but clicking on an element in the list does not trigger “_valueChanged”.

(valueChanged is currently just logging so I can see what happens)

  _valueChanged(ev) {
    console.log("VC");
    console.log(ev);
    if (!this._config || !this._hass) {
      return;
    }
  }

If I add a textfield,

      <mwc-textfield
        label="Battery entity (Optional)"
        .value=${this._name}
        .configValue=${'name'}
        @input=${this._valueChanged}
      ></mwc-textfield>

I can see that _valueChanged is triggered every time I type a letter. But nothing happens if I click on any of the entities in the list.

I know I have not included all the elements in top of the Boiler Plate Card, and that is probably the reason, but there seems to be a real “dependency hell”, where every include depends on some other which again depends on some other include etc.

(adding the selectDefinition depends on select.js, which depends on a bunch of @material includes, which does not work, because using those depends on some scoped browser features(?) (as far as I understand) which again depends on some js-include that I have so far not been able to figure out…). It might be that all of this is clear as water for experienced js-developers, but for a mere amateur this is a web of sticky strings where it is impossible to get out.

It can’t really be necessary to add hundred different files just to get a simple entity selector working in the graphical setup of a custom card?

Is there any other documentation available or are there any simple examples of a card editor that uses the current best practice (as of march 2023) with a working entity selector that I can look at and learn from?

3 Likes

I’m replying just to bump this, as it’s basically the same hurdle I’m looking at; trying to understand whether the intent is that everything ought to be super self contained - raising the bar for a card GUI editor quite a bit - or whether there’s some Best Practise that just isn’t super evident.

Spent about an evening pouring over code, commits and forum posts without getting much wiser. (The answer may be quite evident to a frontend developer, which I’m not.)

The developer documentation is not very exhaustive, leaves a lot of assumptions, and even if you were a frontend dev, lovelace uses an uncommon paradigm of using web-components with shadow-roots for everything.

You can actually just use the components that home assistant provides for common configurations. There’s a component called <ha-form> that if you pass it a correct “schema” object described by the superstruct library it will generate an editor for you. With dynamic content you should memoize the struct to prevent issues and don’t define it in the render function.

you can otherwise make any editor you want with any web technology you want just make sure you dispatch the event config-changed with a copy of the new config in it.

   private _valueChanged(ev: CustomEvent): void {
        fireEvent(this, "config-changed", { config: ev.detail.value });
    }

This is the magic, if you fire dispatch that event with a mutated config, the config will in-fact be mutated in real time.

As far as why OPs select doesn’t work, I can tell you it doesn’t look like its working correctly in the image, but without the rest of the file its not enough to go off of.

1 Like

I’ll add to that my findings which may or may not be accurate or comprehensive (feel free to correct):

  • Use non-HA elements - HA developer suggested approach - The best solution we see is a set of elements created by the custom card community. This set would have its own namespace that would not collide with that of the elements that Home Assistant uses. All custom cards could use these elements, without the risk of breaking changes. (Per the developer blog)
    • Pro: Independent of whatever HA ends up changing in terms of UI elements.
    • Con: Doesn’t actually seem to exist. Even if it did, card authors would be tracking the project that’s tracking HA. Still not independent of API changes. Hell of a lot of duplication of efforts. Likely not the same look and feel as HA native.
  • Use ha-form (what @diamonddrake points at above) - decently documented, the Selectors docs cover the ha-form components. Seems handle lazy loading woes for you, will handle the config state.
    • Pro: Relatively simple and straightforward, uses native HA elements. Seem to be used by card authors as is, so it’s a beaten path. Also used by HA frontend, so it shouldn’t go obsolete overnight.
    • Con: Limited to fairly simple interfaces (but then again, it’s a config interface for a card - if you need something more ambitious, re-consider your approach), seems pretty tricky to do anything dynamic. Impossible to inject any explanatory content inline in the form. Doesn’t support nested structures in the resulting config.
  • Use ha-elements directly - What the more messy cards do in HA frontend itself. Will require some hackery such as possible manual management of preloading (the world owes @thomasloven a beer or two for putting that page together).
    • Pro: More flexibility. Uses native HA elements. Used by existing custom cards.
    • Con: You own a lot more of the plumbing and may have to track lower level changes than with the ha-form approach. Documentation reference = HA frontend repo. Code base is a bit special and you have to understand at least a few non-obvious bits such as how lazy loading is tied to cards.

tl;dr: If you can do so, use ha-form and save yourself a ton of headache. If you need something more exotic but still want to look and feel like HA and not reinvent the wheel entirely, use the HA config elements directly. If you really don’t want to risk breaking due to HA changes down the line, roll your own config editor using whatever libs you fancy (unless it’s React).

2 Likes

Thanks for the hints. I was able to get quite a few steps further. I would not say that ha-form is “decently documented” - Google shows almost zero hits on “homeassistiant ha-form”, but with some trial and error, I have a working PoC at least.

      <ha-form
      .hass=${this._hass}
      .data=${this._config}
      .schema=${[
        {name: "entity", selector: { entity: { domain: "plant" } }},
        {name: "battery_sensor", selector: { text: {} }},
        {name: "show_bars", selector: { select: { multiple: true, mode: "list", options: [
          {label: "Moisture", value: "moisture"},
          {label: "Conductivity", value: "conductivity"}, 
          {label: "Temperature", value: "temperature"},
          {label: "Illuminance", value: "illuminance"}, 
          {label: "Humidity", value: "humidity"},
          {label: "Daily Light Integral", value: "dli"}
          ]} 
        }}
      ]}
      .computeLabel=${this._computeLabel}
      @value-changed=${this._valueChanged} 
      ></ha-form>

One problem seems to be that the “render()” function is not updated with the new config after the config-changed event is dispatched. This means that if you have more than one input-field in the form, you need to save the config and open the editor again after each modification.

For instance, if the user first selects a new “plant”, then an event is created where “entity” is modified. If the user then changes the “battery_sensor”, a new even is sent, with the new battery sensor, but then entity is returned to its original value.

And if the “show_bars” is initially empty and the user checks “Moisture”, an event is created where “moisture” is set and all the other options are unset. That is fine.
But the user then clicks “condictivity”, a new event is created, with “conductivity” set but “moisture” is then unset again (since it was unset in the initial config).
So the user needs to click one option, then save, then click the next options and save again etc.

It seems like the @value-changed=${this._valueChanged} is always just sending the last change, while all other values are from the initial config.

Is there any way to “tell” or the render() function that it needs to update its internal representation of the config?
I am already updating self._config after each change based on the code from here: Simplest custom card · GitHub, so that is not enough.

    const _config = Object.assign({}, this._config);
    _config.entity = ev.detail.value.entity;
    _config.battery_sensor = ev.detail.value.battery_sensor;
    _config.show_bars = ev.detail.value.show_bars;

    this._config = _config;

    const event = new CustomEvent("config-changed", {
      detail: { config: _config },
      bubbles: true,
      composed: true,
    });
    this.dispatchEvent(event);

Dont know if this helps point you in the right direction, but here is a custom card i wrote with editor and drop down selector elements.

You will see it uses card-helpers and implements LovelaceCardEditor

1 Like

Replying to myself again. I was missing a detail in the editor - you need to have this:

  static get properties() {
    return {
      hass: {},
      _config: {},
    };
  }

in the editor-class to make the editor behave properly.

Posting this here, to summarize my findings in case others have the same problem. I could add a documentation PR, but someone more familiar with JS and frontend development should probably look through it first, as this is mostly coded by trial and error, and I don’t really understand what all of this does and how it is connected…

This is a good start to create a minimal card with a simple editor:
https://gist.githubusercontent.com/thomasloven/1de8c62d691e754f95b023105fe4b74b/raw/359d81fc3e78e1f3ec8fd0e7a9701bc738292044/my-custom-card4-with-editor.js

the static get properties ... is important, or else the editor will not work properly.

Copy the code exactly as it is, and do not rename any functions or variables unless you know what you do…

To display a more advanced form than one simple entity, with a look that matches the rest of HA, you can use <ha form>:

Given a yaml-config like this:

entity: light.foo_bar
battery_sensor: sensor.my_battery
show_bars:
- bar1
- bar2
- bar3
- bar4

You can change render() function and, replace Thomas’ return html... with something like

    return html`
      <ha-form
      .hass=${this._hass}
      .data=${this._config}
      .schema=${[
        {name: "entity", selector: { entity: { domain: "light" } }},
        {name: "battery_sensor", selector: { entity: { device_class: "battery" } }},
        {name: "show_bars", selector: { select: { multiple: true, mode: "list", options: [
          {label: "Label 1", value: "bar1"},
          {label: "Label 2", value: "bar2"},
          {label: "Another Label", value: "bar3"},
          {label: "What now?", value: "bar4"},
          ]}
        }}
      ]}
      .computeLabel=${this._computeLabel}
      @value-changed=${this._valueChanged} 
      ></ha-form>
    `;
  }

From what I have learned:

  • .hass and data must be set.
  • I am not sure if the computeLabel is necessary, but it was in one of the examples, and does not seem to break anything at least…
  • The schema is basically a json-representation of a list of selectors: Selectors - Home Assistant
    • name must match the configuration-stanza you want to show
    • You can modify the labels for the select-options. I have not been able to modify the labels for the other elements.

Whenever the form is updated, the function _valueChanged is called with the event as a parameter. The updated config is found under ev.detail.value. Add a simple console.log(ev) to inspect this object further.
Make sure the functon updates this._config with all the new values and emits an event for HA to update and save the config as well:

  _valueChanged(ev) {
    if (!this._config || !this._hass) {
      return;
    }
    const _config = Object.assign({}, this._config);
    _config.entity = ev.detail.value.entity;
    _config.battery_sensor = ev.detail.value.battery_sensor;
    _config.show_bars = ev.detail.value.show_bars;

    this._config = _config;

    const event = new CustomEvent("config-changed", {
      detail: { config: _config },
      bubbles: true,
      composed: true,
    });
    this.dispatchEvent(event);
  }

This function can be renamed (in Thomas’ example it is called entityChanged), but make sure you then also change the reference in the html part of the render() functon: @value-changed=${this.WhateverYouCallIt}

Any handling of default values, optional values etc. needs to be done in one or both of “render()” and “_valueChanged()”.

So you can add something like

    if (!this._config.hasOwnProperty('show_bars')) {
      // Enable all bars by default
      this._config.show_bars = default_show_bars;
    }

to render() (before the return html obviously).

And/or you can do validations in _valueChanged() and modify the config before it is saved and updated.

Hope someone finds this useful, and please do not hesitate to use this to create documentation PRs. I just don’t feel comfortable doing it without understanding more of what happens in the background here.

1 Like

Thanks this got me most of the way and I figured out how to change labels.

    _computeLabel(schema) {
        var labelMap = {
            field1: "New Label 1",
            field2: "New Label 2"
        }
        return labelMap[schema.name];
    }

field1 and field2 should be the names of the keys in this._config.

@Olen Where you able to use the entity selector with dropdown items?

{name: "entity", selector: { entity: {}}}

I tried

{name: "icon", selector: { icon: {}}},

which works flawless, it shows all the icons that are available. For entities, i have to manually type the wanted entity, which defeats the point.

However, thank you for your writeup. It’s the closest to a “simple” tutorial i could find!

For me it was important to limit the dropdown of entities to certain domains or device-classes, so I never tried to list out every single entity. I think I have thousands of entities so it really helps to limit the number of entities to only the most relevant ones.

For anyone in the future coping with a Preact solution, I’m gonna share my findings and code here (cause this is probably the only resource extensively discussing config GUIs)

In Preact, you have to create the ha-form manually, before render. Lit has a nice way to specify the ‘value-changed’ event. Preact not so much.

In this code, you can preview how I create the component. I also use context to pass hass and config (data). (excuse some of my questional typing please)

It’s important that when passing the props, you bind ‘this’ correctly on the value-changed function.

Looks like this thread is the only useful information about the GUI editor for custom cards.

So far everything is working except one small thing.
If I change a value, the input filed lost the focus.
This is annoying if you have to type a number like 600582 and you lost the focus after each key press.

code snipped from my content-card-another-mvg-livemap.js

class ContentAnotherMVGlivemapEditor extends HTMLElement {
  setConfig(config) {
    this.config = config;
    this.innerHTML = '';

    const container = document.createElement('div');
    container.style.display = "flex";
    container.style.flexDirection = "column";
    container.style.gap = "10px";

    const xField = document.createElement('ha-textfield');
    xField.label = "X Coordinate";
    xField.value = this.config.x || 2750799;
    xField.configValue = "x";
    container.appendChild(xField);

    const yField = document.createElement('ha-textfield');
    yField.label = "Y Coordinate";
    yField.value = this.config.y || 1560004;
    yField.configValue = "y";
    container.appendChild(yField);

    const zoomField = document.createElement('ha-textfield');
	zoomField.label = "Zoom";
	zoomField.type = "number";
	zoomField.value = this.config.zoom || 4.8;
	zoomField.step = "0.01"; 
	zoomField.configValue = "zoom";
	container.appendChild(zoomField);

    const modeField = document.createElement('ha-select');
    modeField.label = "Mode";
    modeField.configValue = "mode";
    modeField.value = this.config.mode || 'schematic';

    const schematicOption = document.createElement('mwc-list-item');
    schematicOption.value = "schematic";
    schematicOption.innerText = "Schematic";

    const topographicOption = document.createElement('mwc-list-item');
    topographicOption.value = "topographic";
    topographicOption.innerText = "Topographic";

    modeField.appendChild(schematicOption);
    modeField.appendChild(topographicOption);
    container.appendChild(modeField);

    this.appendChild(container);

    container.querySelectorAll("ha-textfield, ha-select").forEach((element) => {
      element.addEventListener("input", (event) => this._valueChanged(event));
    });

    // Add a change listener to the dropdown that stops propagation to prevent closing the editor
    modeField.addEventListener("selected", (event) => {
      event.stopPropagation();
      this._valueChanged(event);
    });
  }


  _valueChanged(event) {
    const target = event.target;
    const configValue = target.configValue;

    if (!this.config || this.config[configValue] === target.value) return;

    this.config = {
      ...this.config,
      [configValue]: target.value,
    };

    this.dispatchEvent(new CustomEvent("config-changed", { 
      detail: { config: this.config }
    }));
  }

  get value() {
    return this.config;
  }
}

Now its working as expected.

class ContentAnotherMVGlivemapEditor extends HTMLElement {
  constructor() {
    super();
    this.config = {};
  }

  setConfig(config) {
    this.config = config;
    this.render();
  }

  render() {
    this.innerHTML = ''; // Reset inner HTML

    const container = document.createElement('div');
    container.style.display = "flex";
    container.style.flexDirection = "column";

    // Add a description at the top of the editor
    const description = document.createElement('p');
    description.innerHTML = `Alle 4 Werte hängen voneinander ab. Am besten öffnet man die <a href="https://s-bahn-muenchen-live.de/?mode=schematic&showDepartures=true&x=2657030&y=1727560&z=5.02" target="_blank" style="color: #1a73e8; text-decoration: none;">LiveMap (klick)</a> im Browser (PC) und übernimmt die Werte aus der Adresszeile, sobald der gewünschte Zoom eingestellt ist.`;
    description.style.fontSize = "14px";
    description.style.marginBottom = "15px";
    container.appendChild(description);

    // Define input fields with descriptions
    const fields = [
      { name: 'x',    label: 'X - Koordinate',    type: 'number', defaultValue: 2750799, description: 'Je kleiner die Zahl, desto weiter wandert der Mittelpunkt der Ansicht nach links.' },
      { name: 'y',    label: 'Y - Koordinate',    type: 'number', defaultValue: 1560004, description: 'Je kleiner die Zahl, desto weiter wandert der Mittelpunkt der Ansicht nach unten.' },
      { name: 'zoom', label: 'Zoom',              type: 'number', defaultValue: 4.8, step: 0.01, description: 'Größerer Wert bedeutet weiter rangezoomt.' },
      { name: 'mode', label: 'Kartenhintergrund', type: 'dropdown', options: ['schematic', 'topographic'], defaultValue: 'schematic', description: 'Der Hintgergrund der LiveMap: schematic (MVG-Plan) oder topographic (Karte).' },
    ];

    fields.forEach(field => {
      // Create the input element
      let inputElement;
      if (field.type === 'dropdown') {
        inputElement = document.createElement('ha-select');
        field.options.forEach(option => {
          const optionElement = document.createElement('mwc-list-item');
          optionElement.value = option;
          optionElement.innerText = option;
          inputElement.appendChild(optionElement);
        });
        inputElement.value = this.config.mode || field.defaultValue;
      } else {
        inputElement = document.createElement('ha-textfield');
        inputElement.type = field.type;
        inputElement.step = field.step || "1";
        inputElement.value = this.config[field.name] || field.defaultValue;
      }

      inputElement.label = field.label;
      inputElement.configValue = field.name;

      // Event listener to update config on change
      inputElement.addEventListener('change', (event) => {
        const target = event.target;
        this.config = {
          ...this.config,
          [target.configValue]: target.value
        };
        this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: this.config } }));
      });

      // Create description element
      const description = document.createElement('span');
      description.innerText = field.description;
      description.style.fontSize = "12px";
      description.style.color = "#888";
      description.style.marginBottom = "6px";

      // Append elements
      container.appendChild(inputElement);
      container.appendChild(description); // Add description below input
    });

    this.appendChild(container);
  }
}

customElements.define("content-another-mvg-livemap-editor", ContentAnotherMVGlivemapEditor);

// add the card to the list of custom cards for the card picker
window.customCards = window.customCards || []; // Create the list if it doesn't exist.
window.customCards.push({
	type: "content-card-another-mvg-livemap",
	name: "AnotherMVG LiveMap",
	preview: false, // Optional - defaults to false
	description: "Mit dieser Karte kann man sich die MVG Live Map einbinden. Diese Karte ist für Panel (einzelne Karte) gedacht.",
});

PS: In my case I don’t use an entity

1 Like