Custom Property Editor - Simple Examples

I think I’ve got this as far as I can - thanks for the help all!

It works, optionally checks for key uniqueness, has a convertor etc etc.

The two remaining bits;

  1. A sorter - UmbSorterController seems to expect a string array - couldn’t see how this would work here. Guessing I have to roll my own?
  2. UmbValidationContext - no idea how to make this work, didn’t seem very flexible (was tied to a single field and sets the input fields as red after a successful input when I reset them.

Code is in the github repo if anyone finds it useful - don’t feel it’s quite marketplace ready yet - not sure I’m confident it’s done in the right Umbraco way. But it works and you can install if if you want.

import { css, html, customElement, property, state, query, repeat } from "@umbraco-cms/backoffice/external/lit";
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { umbConfirmModal } from '@umbraco-cms/backoffice/modal';
import { UmbPropertyEditorUiElement } from "@umbraco-cms/backoffice/property-editor";
import { UmbPropertyValueChangeEvent } from "@umbraco-cms/backoffice/property-editor";
import { type UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
//import { UmbValidationContext } from "@umbraco-cms/backoffice/validation";

/*
This is a work in progress

WHAT WORKS NOW
* You can add, delete and update items - the save and publish stores to the db and it loads.
* I do check for a new property so that it works to add the first item
* Can enable / disable a uniqueness check on the key

TODO:
1) Sorter - UmbSorterController expects a string array - how would we use this?
2) Maybe a backend property type convertor. - if we use a key value pair then recheck items are unique - what to do if some junk duplicates are there?
3) Use UmbValidationContext properly - couldn't get it to play nicely
*/

// todo - should these be here or inside the custom element
type UmbCommunityKeyValue = {
  key: string;
  value: string;
};
type ArrayOf<T> = T[];

@customElement('key-values-property-editor-ui')
export default class UmbCommunityKeyValuesPropertyEditorUIElement extends UmbLitElement implements UmbPropertyEditorUiElement {

  @property()
  public value: ArrayOf<UmbCommunityKeyValue> = [];

  @state()
  private _items: ArrayOf<UmbCommunityKeyValue> = [];

  @state()
  private _uniquekeys?: boolean;

  @property({ attribute: false })
  public set config(config: UmbPropertyEditorConfigCollection) {
    this._uniquekeys = config.getValueByAlias("uniquekeys");
  }

  @state()
  private _showKeyErrorEmpty: boolean = false;

  @state()
  private _showKeyErrorNotUnique: boolean = false;

  @query('#key-value-new-key')
  newKeyInp!: HTMLInputElement;

  @query('#key-value-new-value')
  newValueInp!: HTMLInputElement;

  // use the connectedCallback as suggested by Jacob Overgaard as this is where the this.value is available and assigned
  connectedCallback() {
    super.connectedCallback();

    // in connectedCallback the this.value is ready
    this._items = this.value;
  }

  // todo - couldn't make this work
  //#validation = new UmbValidationContext(this);

  #onAddRow() {
    // todo - always comes back as valid?
    //this.#validation.validate().then(() => {
    //  console.log('Valid');
    //}, () => {
    //  console.log('Invalid');
    //});

    // check the key is non-empty
    if (this.newKeyInp.value == '') {
      this._showKeyErrorEmpty = true;
      return;
    }

    let newKeyTrimmed = this.newKeyInp.value.trim();

    // if the config is set check if the value is unique
    if (this._uniquekeys && (this._items?.length ?? false) && this._items.some(i => i.key === newKeyTrimmed)) {
        this._showKeyErrorNotUnique = true;
        return;
    }

    const currentInputTyped: UmbCommunityKeyValue = {
      key: newKeyTrimmed,
      value: this.newValueInp.value
    };

    // check the value is an array, the concatenate o/w create an array with this as the first item
    this._items = Array.isArray(this.value) ? [...this.value, currentInputTyped] : [currentInputTyped];

    this.newKeyInp.value = '';
    this.newValueInp.value = '';

    this.#updatePropertyEditorValue();
  }

  #onDelete(index: number) {
    umbConfirmModal(this, { headline: 'Delete?', content: 'Are you sure you want to delete this item?' })
      .then(() => {
        this._items = [...this._items.slice(0, index), ...this._items.slice(index + 1)];
        this.#updatePropertyEditorValue();
      })
      .catch(() => {
        //console.log('Delete cancelled')
      })
  }

  private _onEditRowValue(e: InputEvent, index: number) {
    let currentItem = this._items[index];

    const updatedItem: UmbCommunityKeyValue = {
      key: currentItem.key,
      value: (e.target as HTMLInputElement).value
    };

    this._items = [...this._items.slice(0, index), updatedItem, ...this._items.slice(index + 1)];

    this.#updatePropertyEditorValue();
  }

  private _onEditNewKey() {
    this._showKeyErrorEmpty = false;
    this._showKeyErrorNotUnique = false;
  }

  #updatePropertyEditorValue() {
    this.value = this._items;
    this.dispatchEvent(new UmbPropertyValueChangeEvent());
  }

  // Prevent valid events from bubbling outside the message element
  #onValid(event: Event) {
    event.stopPropagation();
  }

  // Prevent invalid events from bubbling outside the message element
  #onInvalid(event: Event) {
    event.stopPropagation();
  }

  renderItemsList() {
    // writes out the list with fields to update
    if (this._items?.length) {
      return html`
      <ul>
        ${repeat(this._items, (item) => item.key, (item, index) => html`
            <li>
              <umb-form-validation-message id="validation-message" class="wrapper" @invalid=${this.#onInvalid} @valid=${this.#onValid}>
                <uui-input
                  class="kv-input"
                  label="text input"
                  type="text"
                  name="${index}"
                  value="${item.key}"
                  required=true
                  required-message="A key value is required"
                  disabled="disabled"
                  ></uui-input>
                <uui-input
                  class="kv-input"
                  label="text input"
                  type="text"
                  name="${index}"
                  value="${item.value}"
                  @input=${(e: InputEvent) => this._onEditRowValue(e, index)}>
                </uui-input>
                <uui-button
						      compact
						      color="danger"
						      label="remove ${item.key}"
						      look="outline"
						      @click=${() => this.#onDelete(index)}>
						      <uui-icon name="icon-trash"></uui-icon>
					      </uui-button>
              </umb-form-validation-message>
            </li> `
      )
        }
      </ul>`;
    } else {
      return html`<span>You don't have any items yet</span>`;
    }
  }

  render() {
    return html`
        ${this.renderItemsList()}
            <hr/>
            <div class="wrapper">
              <uui-input
                  id="key-value-new-key"
                  class="kv-input"
                  label="text input"
                  placeholder="key*"
                  value=""
                  @input=${this._onEditNewKey}
                  required=true
                  required-message="A key value is required"
              >
              </uui-input>
              <uui-input
                  id="key-value-new-value"
                  class="kv-input"
                  label="text input"
                  value=""
                  placeholder="value"
              >
              </uui-input>
              <uui-button
                  id="add-row-button"
                  class="kv-input"
                  look="primary"
                  label="Add item"
                  @click=${this.#onAddRow}
              >
                  Add item
              </uui-button>
            </div>
            <span id="kv-new-row-error-empty" class=${this._showKeyErrorEmpty ? 'kv-error show' : 'kv-error'}>Error: Key cannot be empty</span>
            <span id="kv-new-row-error-not-unique" class=${this._showKeyErrorNotUnique ? 'kv-error show' : 'kv-error'}>Error: Key already exists</span>
        `;
  }

  static styles = [
    css`
      .wrapper {
          margin-top: 10px;
          display: flex;
          gap: 10px;
      }
      .kv-input {
        flex: 1;
      }
      ul {
        list-style: none;
        padding-inline-start: 0;
      }
      /* the absolute pain to find this is how to change the disabled font color.. */
      uui-input {
        --uui-color-disabled-contrast: black;
      }
      .kv-error {
        color: var(--uui-color-danger-standalone);
        display: none;
      }
      .kv-error.show {
        display: block;
      }
      `,
  ];
}

declare global {
  interface HTMLElementTagNameMap {
    'key-values-property-editor-ui': UmbCommunityKeyValuesPropertyEditorUIElement;
  }
}

1 Like