Custom property editor and using @umbraco-cms/backoffice

Hi

I’m upgrading a large v13 site to v15 and we have a lot of custom property editors.

I’m struggeling to figure out how to use the stuff in @umbraco-cms/backoffice api with Lit and TypeScript.

How do I get stuff like editorState.current.contentTypeAlias (from AngularJS in v13)?

Here is some code - please help where this comment are “HERE I WANT TO GET THE CURRENT PAGE CONTENT TYPE ALIAS - HOW??”:

import { LitElement, html, css, customElement, property, state } from "@umbraco-cms/backoffice/external/lit";
import { UmbPropertyEditorUiElement } from "@umbraco-cms/backoffice/property-editor";
import { UmbPropertyValueChangeEvent } from "@umbraco-cms/backoffice/property-editor";
import { TagsPickerTSService } from './tagspickerts.service';

interface Tag {
  Id: number;
  Name: string;
  ParentName: string;
}

@customElement('tagspickerts-ui')
export default class TagsPickerTSUiElement extends LitElement implements UmbPropertyEditorUiElement {

  @property({ type: Object })
  public value: { count: number; tags: number[] } = { count: 0, tags: [] };
  
  @state() private tags: Tag[] = [];
  @state() private tagHeaders: string[] = [];

  #dispatchChangeEvent() {
    this.dispatchEvent(new UmbPropertyValueChangeEvent());
  }

  //HERE I WANT TO GET THE CURRENT PAGE CONTENT TYPE ALIAS - HOW??

  async connectedCallback() {
    super.connectedCallback();
    const service = new TagsPickerTSService();
    const tagsData = await service.getTags();
    this.tags = tagsData;
    this.tagHeaders = [...new Set(this.tags.map(tag => tag.ParentName))];
    this.cleanUpInvalidTags();
    if(this.value == null){
      this.value = { count: 0, tags: [] };
    }
        
  }

  private cleanUpInvalidTags() {
    if(this.value != null){
      const validTagIds = this.tags.map(t => t.Id);
      this.value = {
        ...this.value,
        tags: this.value.tags.filter(tagId => validTagIds.includes(tagId)),
     };
    }
  }

  private toggleTagSelection(tagId: number) {
    const isSelected = this.value.tags.includes(tagId);
  
    this.value = {
      ...this.value,
      tags: isSelected
        ? this.value.tags.filter(id => id !== tagId)
        : [...this.value.tags, tagId],
    };
  
    this.#dispatchChangeEvent();
  }

  private handleCountChange(event: Event) {
    const input = event.target as HTMLInputElement;
    this.value.count = parseInt(input.value) || 0;
    this.value = { ...this.value };
    this.#dispatchChangeEvent();
  }


  render() {
    return html`
      <div class="tags-picker">
        <div class="tags-picker-related">
          <label>Antal relaterede nyheder vist</label>
          <div class="info-message">
            OBS!<br />
            Hvis du angiver et antal herunder, vil relaterede nyheder blive vist automatisk.
            Det betyder også, at det ikke er nødvendigt at indsætte modulet "Relaterede nyheder" på siden.
          </div>
          <input type="number" .value=${String(this.value.count)} @input=${this.handleCountChange} />
        </div>

        <div class="propery-block">
          <label class="tl">Tags</label>
          ${this.tagHeaders.map(
            parent => html`
              <div style="margin-bottom: 1rem;">
                <div class="header-label">${parent}</div>
                <br />
                ${this.tags
                  .filter(tag => tag.ParentName === parent)
                  .map(
                    tag => html`
                      <label
                        class="tag ${this.value.tags.includes(tag.Id) ? 'selected' : ''}"
                        @click=${() => this.toggleTagSelection(tag.Id)}
                      >
                        <input
                          type="checkbox"
                          .checked=${this.value.tags.includes(tag.Id)}
                          style="margin-right: 0.5rem;"
                          disabled
                        />
                        ${tag.Name}
                      </label>
                    `
                  )}
              </div>
            `
          )}
        </div>
      </div>
    `;
  }

static styles = css`
.tag {
  margin-right: 1rem;
  border: 1px solid #f9dede;
  padding: 0.5rem;
  border-radius: 6px;
  cursor: pointer;
  display: inline-block;
}
.tag.selected {
  background-color: #f9dede;
}
.info-message {
  background-color: #f9dede;
  padding: 1rem;
  border-radius: 10px;
  display: inline-block;
}
.header-label {
  display: inline-block;
  font-size: 0.8em;
  font-weight: bold;
  text-transform: uppercase;
  padding: 0.1rem 1rem;
  background-color: #625A74;
  color: #fff;
  border-radius: 5px;
}
`;

}
declare global {
  interface HTMLElementTagNameMap {
      'tagspickerts-ui': TagsPickerTSUiElement;
  }
}
1 Like

Shameless plug, but I think this other issue with Where is the Backoffice UI Documentation? covers your use case as well, where I posted an example:

That will get you the properties on the page, but if you instead want a more generic alias, you could try and consume the UMB_DOCUMENT_WORKSPACE_CONTEXT which has a property for contentTypeUnique that is a GUID of your content type.

1 Like

Thanks Jacob :slight_smile:

I’ve already seen that answer. When using your code I get this error:
Property ‘getContext’ does not exist on type ‘TagsPickerTSUiElement’.

I really can’t figure out how to make this work?

Your component should extend from UmbLitElement to get that method:

import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

export class TagsPickerTSUiElement extends UmbLitElement {
}

Does that work for you?

I also tried your example yesterday, and when i tried to get a property from the context I was returned an object e that had two properties, operator (a function) and source.

Not sure what I did wrong. From the constructor I called an async function.
In this async function I did:

const context = await this.getContext(UMB_PROPERTY_DATASET_CONTEXT);
    const workspaceName = context?.getName();
    console.log('workspaceName', workspaceName);

This worked great, got the workspace name. But continuing with:

const allProperties = context?.properties ?? [];
    console.log('allProperties', allProperties);

I get this returned:
image

My class definition:

export default class TeamAssignerPropertyEditorUIElement
  extends UmbElementMixin(LitElement)
  implements UmbPropertyEditorUiElement
{ // ...

I’m guessing I am requesting things in the wrong place, maybe, somehow?

This is on me, sorry. The property properties is an observable. You can get the values continuously by subscribing to it like so:

if (!context) return;

this.observe(context.properties, (props) => {
  console.log('props', props);
});

Alternatively, you can get a snapshot of the current values by using the method getProperties(), which returns a promise:

const allProperties = await context.getProperties();
1 Like

Oh that last remark is good to know. Observables are nice, but sometimes you just need to have a bunch of values.

2 Likes

I believe that they are based on a BehaviorSubject and thus have a .value method as well. But yeah, the other works nicely.

1 Like

@jacob
Thanks for the answers. I’m still dumbfounded though :sweat_smile:

this is my class definition

export default class TagsPickerTSUiElement extends UmbLitElement implements UmbPropertyEditorUiElement

Then i do this, in connectedCallback()

const context = await this.getContext(UMB_PROPERTY_DATASET_CONTEXT);

    
    this.observe(context.properties, (props) => {
      console.log('props', props);
   });

but context.properties still doesn’t exist. Wonder what I’m doing wrong?

I don’t know if this will help you, because in my situation I needed to check if the current content implements a specific composition (so this is a custom condition). But for that I used the UMB_CONTENT_WORKSPACE_CONTEXT:

import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content';
...

this.consumeContext(UMB_CONTENT_WORKSPACE_CONTEXT, (instance) => {
	this.observe(instance.structure.contentTypeAliases, (aliases) => {

		if (aliases.includes('SOME_ALIAS')) {
			this.permitted = true;
		} else {
			this.permitted = false;
		}
	});
});

Like I said, not sure if this is the right direction, but maybe it helps.

Also, don’t you need to consumeContext instead of await it as in your code example?

1 Like

I actually did help a lot :ok_hand:

Can I ask how you found out to use UMB_CONTENT_WORKSPACE_CONTEXT?
Have you found any useful documentation?

Without having seen your codebase, I would assume that you called it getContext before it existed. It tries to wait until the context is there, but it may be too early. The best approach is probably to use consumeContext instead, which has a callback for when the context becomes available, as @LuukPeters suggests in his reply. You would put that code into the constructor instead so that the controller can clean it up for you when the component gets destroyed:

export default class TagsPickerTSUiElement extends UmbLitElement implements UmbPropertyEditorUiElement {

  constructor() {
    super();

    this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (dataset) => {
      this.observe(context.properties, (props) => {
        console.log('props', props);
      });
    });
  }
}

From our mutual friend @jacob :wink:

And like Jacob mentioned, I always place the consume contexts in the constructor. It’s probably one of the few things that I put into the constructor instead of the connectCallback.

Thanks alot both of you - it really helps a lot :grinning_face:

export default class TagsPickerTSUiElement extends UmbLitElement implements UmbPropertyEditorUiElement {
 constructor() {
    super();
    this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (dataset) => {
      this.observe(dataset.properties, (props) => {
        console.log('props', props);
      });
    });
  }
}

still gives this error:

Property 'properties' does not exist on type 'UmbPropertyDatasetContext'

So I guess the properties property really doesn’t exist in UMB_PROPERTY_DATASET_CONTEXT.

Is there anywhere to see some documentation of the properties on the different types of contexts?

I see your code there has: this.observe(dataset.properties... while Jacobs example leads with this.observe(context.properties...

Could that be a mistake?

I believe its correct with “dataset” as it is the return object from consumeContext :slightly_smiling_face:

You can call the argument whatever you like, so context is the convention in core, but dataset works as well.

So I’m not sure what’s going on, because according to the API Docs, it should exist on the interface:

Maybe you get a different instance at build vs runtime? You could cast it to any in typescript and check the debugger runtime.

It helps to update the npm packages… :face_with_peeking_eye:
Thanks

2 Likes

This works for me:

import { UMB_PROPERTY_DATASET_CONTEXT } from "@umbraco-cms/backoffice/property"

constructor() {
	super();
	this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (dataset) => {
		this.observe(dataset.properties, (props) => {
			console.log('props', props);
		});
	});
}

image

This is with Umbraco 15.2.3.

1 Like

Glad we got your seemingly first issue (on this forum) resolved. Welcome to the new forum! :umbraco-heart: