Property Editor for Umbraco 15

I keep getting so close to making this work, then I try and fix the next problem and find I’ve broken it again!!

I am string to create a single item picker Property Editor for Umbraco 15, that will use a service (registered in DI) to get its data from.

I have got to the point where the Property Editor appears in the list and I can select it when creating a new DataType. However, when I then try to save the DataType I get the error “The targeted property editor was not found”.

I have spent hours on this, going round and round in circles. I have tried using various AI tools to help but they seem to know little about v15.

I think if I was able to get the simplest of Property Editor to fully work, then I could hopefully sort mine out. Can anyone show me the basic setup of a simple editor - say just to show a textbox?

Not state of the art by any means, but you can look at my efforts for the Personalisation Groups package server-side and client-side

I assume you’ve already found this but docs for a custom property editor start here.

Andy

I found the official docs very unclear and confusing, unfortunately.

This is for V15 so I don’t think I can do anything server-side regarding registration.

Unfortunately (again) Copilot, ChatGPT and Claude AI all seem to struggle to get the right answer for v15 and I keep going round in circles!

Maybe someone may spot the issue if I post some code here:

store-picker-property-editor.element.ts

// src/property-editors/store-picker/store-picker-property-editor.element.ts
import { html, customElement, property, state, css } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor';
import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';

interface Store {
    id: string;
    name: string;
    code?: string;
    description?: string;
}

@customElement('loop-store-picker-property-editor')
export class LoopStorePickerPropertyEditorElement extends UmbLitElement implements UmbPropertyEditorUiElement {

    @property({ type: String })
    value?: string = '';

    @property({ attribute: false })
    public config?: UmbPropertyEditorConfigCollection;

    @state()
    private _stores: Store[] = [];

    @state()
    private _loading = false;

    @state()
    private _error?: string;

    async connectedCallback() {
        super.connectedCallback();
        await this._loadStores();
    }

    private async _loadStores() {
        this._loading = true;
        this._error = undefined;

        try {
            const response = await fetch('/umbraco/backoffice/loop/stores', {
                credentials: 'same-origin'
            });

            if (!response.ok) {
                throw new Error(`Failed to load stores: ${response.statusText}`);
            }

            const stores = await response.json();
            this._stores = stores || [];
        } catch (error) {
            console.error('Error loading stores:', error);
            this._error = error instanceof Error ? error.message : 'Failed to load stores';
            this._stores = [];
        } finally {
            this._loading = false;
        }
    }

    private _onStoreChange(event: Event) {
        const target = event.target as HTMLSelectElement;
        const newValue = target.value;

        this.value = newValue;

        // Fixed: Use UmbChangeEvent for proper Umbraco integration
        this.dispatchEvent(new UmbChangeEvent());
    }

    private _getSelectedStoreName(): string {
        if (!this.value) return 'No store selected';

        const selectedStore = this._stores.find(store => store.id === this.value);
        return selectedStore ? selectedStore.name : 'Unknown store';
    }

    render() {
        if (this._loading) {
            return html`
                <div class="store-picker-loading">
                    <uui-loader></uui-loader>
                    <span>Loading stores...</span>
                </div>
            `;
        }

        if (this._error) {
            return html`
                <div class="store-picker-error">
                    <uui-icon name="icon-alert"></uui-icon>
                    <span>Error: ${this._error}</span>
                    <uui-button 
                        label="Retry" 
                        @click=${this._loadStores}
                        look="secondary"
                        compact>
                        Retry
                    </uui-button>
                </div>
            `;
        }

        return html`
            <div class="store-picker">
                <uui-select 
                    .value=${this.value || ''}
                    @change=${this._onStoreChange}
                    placeholder="Select a store">
                    
                    <uui-select-option value="">
                        -- Select a store --
                    </uui-select-option>
                    
                    ${this._stores.map(store => html`
                        <uui-select-option 
                            value=${store.id}
                            ?selected=${this.value === store.id}>
                            ${store.name}
                            ${store.code ? html` (${store.code})` : ''}
                        </uui-select-option>
                    `)}
                </uui-select>
                
                ${this.value ? html`
                    <div class="store-picker-preview">
                        <uui-icon name="icon-store"></uui-icon>
                        <span>Selected: ${this._getSelectedStoreName()}</span>
                    </div>
                ` : ''}
            </div>
        `;
    }

    static styles = [
        css`
            :host {
                display: block;
            }

            .store-picker {
                display: flex;
                flex-direction: column;
                gap: var(--uui-size-space-3);
            }

            .store-picker-loading,
            .store-picker-error {
                display: flex;
                align-items: center;
                gap: var(--uui-size-space-2);
                padding: var(--uui-size-space-3);
                border: 1px solid var(--uui-color-border);
                border-radius: var(--uui-border-radius);
                background-color: var(--uui-color-surface);
            }

            .store-picker-error {
                color: var(--uui-color-danger);
                border-color: var(--uui-color-danger);
            }

            .store-picker-preview {
                display: flex;
                align-items: center;
                gap: var(--uui-size-space-2);
                padding: var(--uui-size-space-2);
                background-color: var(--uui-color-surface-alt);
                border-radius: var(--uui-border-radius);
                font-size: var(--uui-type-small-size);
                color: var(--uui-color-text-alt);
            }

            uui-select {
                width: 100%;
            }
        `
    ];
}

// Default export for the manifest system
export default LoopStorePickerPropertyEditorElement;

manifests.ts

// src/property-editors/store-picker/manifests.ts
export const manifests = [
    {
        type: 'propertyEditorUi',
        alias: 'Loop.StorePickerPropertyEditorUi',
        name: 'Loop Store Picker Property Editor UI',
        element: () => import('./store-picker-property-editor.element.js').then(m => ({ element: m.element })),
        meta: {
            label: 'Loop Store Picker',
            propertyEditorSchemaAlias: 'Loop.StorePicker',
            icon: 'icon-store',
            group: 'pickers'
        }
    },
    {
        type: 'propertyEditorSchema',
        alias: 'Loop.StorePicker',
        name: 'Loop Store Picker',
        meta: {
            defaultPropertyEditorUiAlias: 'Loop.StorePickerPropertyEditorUi',
        }
    }
];

manifests.ts

// src/property-editors/manifest.ts
import { manifests as storePickerManifests } from './store-picker/manifests';

export const manifests = [
    ...storePickerManifests
];

index.ts

// src/index.ts
import { UmbEntryPointOnInit } from '@umbraco-cms/backoffice/extension-api';
import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
import { manifests as documentManifests } from './documents/manifest.ts';
import { manifests as sectionManifests } from './section/manifests.ts';
import { manifests as propertyEditorManifests } from './property-editors/manifests.ts';

export const onInit: UmbEntryPointOnInit = (host, extensionRegistry) => {
    console.log('Registering manifests:', {
        documents: documentManifests,
        sections: sectionManifests,
        propertyEditors: propertyEditorManifests
    });

    extensionRegistry.registerMany([
        ...documentManifests,
        ...sectionManifests,
        ...propertyEditorManifests
    ]);

    host.consumeContext(UMB_AUTH_CONTEXT, async (auth) => {
        if (!auth) return;
    });
};

vite.config.ts

import { defineConfig } from "vite";

export default defineConfig({
    build: {
        lib: {
            entry: "src/index.ts", // your web component source file
            formats: ["es"],
        },
        outDir: "wwwroot/App_Plugins/Loop", // all compiled files will be placed here
        emptyOutDir: true,
        sourcemap: true,
        rollupOptions: {
            external: [/^@umbraco/], // ignore the Umbraco Backoffice package in the build
        },
    },
});

If you want to use a custom property editor schema alias, you need to implement that server side. The editor schema is sort of the gatekeeper between the data that comes from the editor and the database. So you can do server side validation or some transformation of data that goes into or comes out of the database. So you need to have an editor schema (server side) that is called Loop. StorePicker.

If you don’t need that, use one of the default schema’s.

1 Like

So, is this what the selected value is stored as then? The Store ID is a Guid, so would I choose “Umbraco.Plain.String”?

That solved it - not completely as there were other issues, but it allowed me to create the DataType