V16 Client-side and Server-side Validation Compared To AngularJS Validation In v7 - v13

I have reviewed various help topics, documentation, and the source code to try and determine how I can create both client-side and server-side validation for a custom property editor. I built a custom property editor for creating a custom URL in previous versions of Umbraco (v7 to v13) in AngularJS, along with C# for the server-side validation. I am having an issue with determining how to do the same using v16 of Umbraco with Lit and the UUI library. I found the following documentation:
Property Editors - Integrate Validation

I need to submit the value of my property editor (a custom URL), as well as an optional GUID for the current node being edited (which will be null if I’m dealing with a new content item).

It appears in my constructor, I need to get a reference to UmbValidationContext. I then need to wrap my custom URL property editor (which just uses a ) in a tag, and have ${umbBindToValidation(this)} on my along with a #validate method on my custom Lit element. I can then use a modal context to display an error message, along with the inline error.

How do I go about having an async method called to send the custom URL, as well as the entity GUID, to the server? What type of controller do I use in C# on the server-side to accept this data, and do I just use the native fetch method to call the validation, or is there a better way to go about this?

I have the custom URL value, as well as the entity ID, after consuming the UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT and calling .getUnique() on the context (and this.value carries the custom URL from the TextBox). I understand using the Notification Context to display any errors, as well as using the , but I’m also wondering how I can create server-side validation in case my user doesn’t click a “Validate URL” button. I see the documentation at:
Property Editor - Adding Server-Side Validation

I apologize for such a lengthy post. I have been going through the documentation, source code, and help topics such as:
Property Editor: How Do I Use Validation?

This has really been the issue that has prevented me from moving nearly 90+ Umbraco websites to the latest version of Umbraco. I am in talks with my employer to release this Custom URL property editor under an open-source license so anyone can make use of it. I have a Cloudflare caching plugin written in AngularJS that will also be able to take advantage of this knowledge if I’m able to get this working, to clear the cache of a specific page, any listed URLs, or the entire site cache.

I appreciate any and all help with this issue.

We need to expand this topic to the backoffice as a whole.

It is a big task to update out of Angular. 100% but from a few releases of 15 and now we have 16 I do not think the issues outstanding in the backoffice are being made serious enough and I think other stuff needs to be paused and a big focus on this stuff needs to be made.

Umbraco forms with multiple columns do not even fit on screen, you cant put validation reasons into the fields any more due to UI bugs.

On a general level everything is more clunky, the font in areas is incredibly small and for clients we built sites on this is one of the primary things they keep going on about. But there is just A LOT of issues. All the stuff you have mentioned, sidebars simply not loading sometimes and other things simply not working.

I created a big topic as well because just to make Blog grid views for even simple stuff so you can follow a grid users content to some form is now really hard to do, the lack of the Angular magic strings now has limited options, some stuff that you could easily build and not worry about to make good back office experiences are now harder and there is a lot of things like the grid content management that are actually more clunky.
And that is the running theme, Clunky.
Adding a new field to an Umbraco form for that long list you get now for example, everything is Jarring. Your post Chris hints to that even with 9-13 space, its even worse 14-16.

I have wasted hours myself trying to figure things out now.

I fully support the direction and see the goals ahead and it is all for the better but even the documentation is not properly covering things and really lagging behind.
In the last two days alone I have clicked maybe 10 links in the back office to have them 404 regarding version 15 or 16.
Important stuff around the UI and changes to properties are buried and hard to find.
Took me a while to work out this for example:

What you raise is 100% valid and opens the can to all this other stuff and I think it should be a massive concern.
Will all be sorted but I honestly feel with 15 for example, that never should have launched when it did because so much was not usable.

The path forward looks good and you can do more and there is more stuff and it is good BUT Umbraco is loosing site of what and why many love it. Various aspects of it are very easy to get into. The Models concept and how quickly you can create stuff for example is a core love. The back office and how easily it is to build document types to represent content and build solutions has been a key other feature.

Then the ease at which you could do “Something” extra in the backoffice and then go as far as making bigger and more powerful plugins.
Nearly Everything is harder now, takes longer and things like doing simple grid block view rendering has to have time spent away from actually building sites and online applications.
I spent way to much time doing a workflow for the current Umbraco Forms and Umbraco 16. The reason I had to build my own because even the official one from Umbraco does not actually work.

Everything is getting too complicated, too hard and cumbersome and too buggy.
Umbraco really needs to take this to heart and make a note on this and take a solid look internally and remember its core and its own heart and spend some time fixing, polishing and bring back some of its core fundamentals.

I wonder what the stats are on versions. How many things are not version 8. How many 13’s are there compared to 14 or 15.

I have faith but Umbraco needs to take some steps back and some time to get things back on track.

I wanted to come back to this thread, after I’ve dug into various resources in the help topics and online. I finally have figured out the client-side of my Custom URL property editor, although I don’t have the validation running automatically (it requires clicking a “Validate URL” button to trigger). My next step in this process is to set up server-side validation, which seems to be much easier from reading the documentation. I don’t know if this code will help anyone, and I am fairly new to TypeScript, so may not have the most idiomatic code. With that said, here is what I came up with for a Custom URL property editor:


import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { html, css, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbPropertyEditorUiElement, UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui';
import { UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UMB_NOTIFICATION_CONTEXT, UmbNotificationContext } from "@umbraco-cms/backoffice/notification";
import { UMB_AUTH_CONTEXT, UmbAuthContext } from '@umbraco-cms/backoffice/auth';
import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';

type UuiInputTypeType = typeof UUIInputElement.prototype.type;

type CustomUrlVerificationResult = {
    completed: boolean;
    message: string;
};

@customElement('custom-url')
export default class CustomUrl extends UmbFormControlMixin<string, typeof UmbLitElement, undefined>(UmbLitElement, undefined) implements UmbPropertyEditorUiElement {
    @property({ type: Boolean })
    public mandatory?: boolean;
    @property({ type: String })
    public mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY;

    @property({ type: String })
    name?: string;

    private defaultType: UuiInputTypeType = 'text';

    @state()
    private _type: UuiInputTypeType = this.defaultType;

    private _verificationUrl = '/umbraco/management/api/v1/custom-url';
    private _propertyContext?: UmbEntityUnique;
    private _notificationContext?: UmbNotificationContext;
    private _authContext?: UmbAuthContext;
    private _disallowedCharacters = /[^a-z0-9\-\/]+/gi;

    constructor() {
        super();

        this.consumeContext(UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT, (context) => {
            this._propertyContext = context?.getUnique();
        });

        this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => {
            this._notificationContext = context;
        });

        this.consumeContext(UMB_AUTH_CONTEXT, (context) => {
            this._authContext = context;
        });
    }

    public set config(config: UmbPropertyEditorConfigCollection | undefined) {
        this._type = config?.getValueByAlias<UuiInputTypeType>('inputType') ?? this.defaultType;
    }

    #onInput(e: InputEvent) {
        const newValue = (e.target as HTMLInputElement).value;
        if (newValue === this.value) {
            return;
        }

        this.value = newValue;

        this.dispatchEvent(new UmbChangeEvent());
    }

    protected override firstUpdated(): void {
        this.addFormControlElement(this.shadowRoot!.querySelector('uui-input')!);
    }

    override focus() {
        return this.shadowRoot?.querySelector<UUIInputElement>('uui-input')?.focus();
    }

    async #validate() {
        const value = this.value ?? '';

        if (!this.mandatory && value === '') {
            this._notificationContext?.peek('warning', {
                data: {
                    headline: 'Warning',
                    message: 'The Custom URL has no data to validate'
                }
            });

            return;
        }

        if (value === '' && this.mandatory?.valueOf() === true) {
            this._notificationContext?.peek('warning', {
                data: {
                    headline: 'Error',
                    message: 'The Custom URL is required'
                }
            });
        } else if (value.startsWith('/')) {
            this._notificationContext?.peek('warning', {
                data: {
                    headline: 'Error',
                    message: 'The Custom URL should not start with a forward slash'
                }
            });
        } else if (value.endsWith('/')) {
            this._notificationContext?.peek('warning', {
                data: {
                    headline: 'Error',
                    message: 'The Custom URL should not end with a forward slash'
                }
            });
        } else if (value.includes('--')) {
            this._notificationContext?.peek('warning', {
                data: {
                    headline: 'Error',
                    message: 'The Custom URL should not contain 2 or more consecutive hyphens'
                }
            });
        } else if (value.includes('//')) {
            this._notificationContext?.peek('warning', {
                data: {
                    headline: 'Error',
                    message: 'The Custom URL should not contain 2 or more consecutive forward slashes'
                }
            });
        } else if (this._disallowedCharacters.test(value)) {
            this._notificationContext?.peek('warning', {
                data: {
                    headline: 'Error',
                    message: 'The Custom URL should only contain letters, numbers, hyphens, or forward slashes'
                }
            });
        } else {
            const dataToValidate = {
                value: value,
                id: this._propertyContext || null
            };

            try {
                const token = await this._authContext?.getLatestToken();

                const response = await fetch(this._verificationUrl, {
                    method: 'POST',
                    headers: {
                        'Authorization': `Bearer ${token}`,
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(dataToValidate)
                });

                if (!response.ok) {
                    throw new Error(`Response status: ${response.status}`);
                }

                const result: CustomUrlVerificationResult = await response.json();

                let headline;
                if (result.completed) {
                    headline = 'Success';
                } else {
                    headline = 'Error';
                }

                this._notificationContext?.peek(result.completed ? 'positive' : 'danger', {
                    data: {
                        headline: headline,
                        message: result.message
                    }
                });
            } catch (error) {
                let message;
                if (error instanceof Error) {
                    message = error.message;
                } else {
                    message = JSON.stringify(error);
                }

                this._notificationContext?.peek('danger', {
                    data: {
                        headline: 'Server Error',
                        message: message
                    }
                });

                return;
            }
        }
    }

    override render() {
        return html`
            <uui-input id="custom-url-input"
                       label="Custom URL"
                       class="element"
                       autowidth=""
                       pristine=""
                       .type=${this._type}
                       .value=${this.value ?? ''}
                       @input=${this.#onInput}
                       ?required=${this.mandatory}
                       .requiredMessage=${this.mandatoryMessage}
            >
            </uui-input>
            <div id="wrapper">
                <uui-button id="custom-url-validate-button"
                            label="Validate Custom URL"
                            class="element"
                            look="primary"
                            color="warning"
                            @click=${this.#validate}
                >
                    Validate URL
                </uui-button>
            </div>
        `;
    }

    static override readonly styles = [
        UmbTextStyles,
        css`
            #wrapper {
                margin-top: 10px;
                display: flex;
                gap: 10px;
            }
            .element {
                width: 100%;
            }
        `
    ];
}


declare global {
    interface HTMLElementTagNameMap {
        'custom-url': CustomUrl;
    }
}

1 Like

Hi @crahauiser

Just skimmed your code and it seems you got it right.

You made your Property Editor Web Component a Form Control by using the Form Control Mixin.
This implements Validation features, and you have used the ability to bind a inner element to it. Meaning the validation state of your uui-input will propagate to your Property Editor. Thereby, if that is invalid, Publishing would fail.

And then you have the Validation of the URL. But this does not hook into the Validation State of your Property Editor. So this does not prevent the user from Publishing.
Instead, I would recommend bringing that feedback as part of the Property Editors Validation State.

A low effort way to change your code to bring feedback via the Validation State of your Property Editor would be to use the method this.setCustomValidity("not great URL!") in this way the message will be displayed below the editor and prevent the user from Publishing.

The right thing to do would be to implement a Validator, in this way:

constructor() {
		super();

		this.addValidator(
			'customError',
			() => 'This field must contain a "Unicorn"',
			() => !this.value.includes("unicorn"),
		);
	}

Also that would enable you to remove the check-button. Instead use the Validator Check to ask the server, with a debounce(delay) of 500ms to not overwhelm the server?

I have an Update to the Docs in the making, so soon the Docs will describe how to add Validation to your own Property Editor.

I will post it when the Article is online, and then I hope this information was useful for you and other readers

Best, Niels

2 Likes

The Documentation article has been merged and is now available here:

And the other article you found has been moved, as it was not specific to Property Editors — which could have led to some misunderstandings.

I hope you will read the article and check if it makes sense, and if you think something is missing I hope you will dedicate the time to make it better.

Thanks in advance

2 Likes

Thank you so much for this! I will give this a read and see if there is anything to add, or that I am still confused on. I really appreciate it!

I’m not clear if this is a TypeScript issue, or one for the addValidator method, but is it possible to use an async validation method for the validation check? Since I need to send my custom URL to the server in order to determine if it is already in use, with the fetch method, I need to await or return a Promise (since this triggers the message).

Since the error message changes, I have called the addValidator method as:

this.addValidator(
    'customError',
    () => this.validationMessage
    () => // unsure how to call async method
);

I use this.setCustomValidity() to set the appropriate error message, and then return true. I return false if no error has occurred. Not relying on the notification context to display errors has trimmed my code considerably, but I’m not sure if I can make use of addValidator with async code.

Any help is appreciated. Once I get things working, I will post the code for others to follow along with, as well as anything I can contribute to the documentation.

I have determined that I can use a private state field to check for validation, along with a debounce method on my own validation method. Once I did this, I started to get an exception from the form-control.mixin.ts file about setValidity() failing to execute on ElementInternals.

As I type, the exceptions continue to occur, so I imagine that there is something I’m doing wrong. This is the code that I currently have:

import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { html, css, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbPropertyEditorUiElement, UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui';
import { UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
import type { UmbAuthContext } from '@umbraco-cms/backoffice/auth';
import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';

type UuiInputTypeType = typeof UUIInputElement.prototype.type;

type CustomUrlVerificationResult = {
    completed: boolean;
    message: string;
};

type CustomUrlFormData = {
    value: String,
    id: UmbEntityUnique | undefined
};

@customElement('custom-url')
export default class CustomUrl extends UmbFormControlMixin<CustomUrlFormData, typeof UmbLitElement, undefined>(UmbLitElement, undefined) implements UmbPropertyEditorUiElement {
    @property({ type: Boolean })
    public mandatory?: boolean;
    @property({ type: String })
    public mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY;

    @property({ type: String })
    public name?: string;

    @property({ type: String})
    public textValue?: string;

    private defaultType: UuiInputTypeType = 'text';

    @state()
    private _type: UuiInputTypeType = this.defaultType;

    @state()
    private _isValid = true;

    private _verificationUrl = '/umbraco/management/api/v1/custom-url';
    private _propertyContext?: UmbEntityUnique;
    private _authContext?: UmbAuthContext | undefined;
    private _disallowedCharacters = /[^a-z0-9\-\/]+/gi;

    private _delayedValidation: () => void;

    constructor() {
        super();

        this.consumeContext(UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT, (context) => {
            this._propertyContext = context?.getUnique();
        });

        this.consumeContext(UMB_AUTH_CONTEXT, (context: UmbAuthContext | undefined) => {
            this._authContext = context;
        });

        this.addValidator(
            'customError',
            () => this.validationMessage,
            () => this._isValid
        );

        this._delayedValidation = this.#debounce(this.#validate, 500);
    }

    public set config(config: UmbPropertyEditorConfigCollection | undefined) {
        this._type = config?.getValueByAlias<UuiInputTypeType>('inputType') ?? this.defaultType;
    }

    #onInput(e: InputEvent) {
        const newValue = (e.target as HTMLInputElement).value;
        if (newValue === this.textValue) {
            return;
        }

        this.textValue = newValue;

        this.value = {
            value: this.textValue,
            id: this._propertyContext
        };

        this.dispatchEvent(new UmbChangeEvent());

        this._delayedValidation();
    }

    protected override firstUpdated(): void {
        this.addFormControlElement(this.shadowRoot!.querySelector('uui-input')!);
    }

    override focus() {
        return this.shadowRoot?.querySelector<UUIInputElement>('uui-input')?.focus();
    }

    #debounce(func: () => void, delay: number) {
        let timeout: number | undefined;
        return () => {
            clearTimeout(timeout);
            timeout = setTimeout(func, delay);
        };
    }

    #validate = async () => {
        this._isValid = false;

        const value = this.textValue ?? '';

        if (!this.mandatory && value === '') {
            this._isValid = true;

            return;
        }

        if (value === '' && this.mandatory?.valueOf() === true) {
            this.setCustomValidity('The Custom URL is required');
        } else if (value.startsWith('/')) {
            this.setCustomValidity('The Custom URL should not start with a forward slash');
        } else if (value.endsWith('/')) {
            this.setCustomValidity('The Custom URL should not end with a forward slash');
        } else if (value.includes('--')) {
            this.setCustomValidity('The Custom URL should not contain 2 or more consecutive hyphens');
        } else if (value.includes('//')) {
            this.setCustomValidity('The Custom URL should not contain 2 or more consecutive forward slashes');
        } else if (this._disallowedCharacters.test(value)) {
            this.setCustomValidity('The Custom URL should only contain letters, numbers, hyphens, or forward slashes');
        } else {
            try {
                const token = await this._authContext?.getLatestToken();

                const response = await fetch(this._verificationUrl, {
                    method: 'POST',
                    headers: {
                        'Authorization': `Bearer ${token}`,
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(this.value)
                });

                if (!response.ok) {
                    this.setCustomValidity(`Response status: ${response.status}`);

                    return;
                }

                const result: CustomUrlVerificationResult = await response.json();

                if (result.completed) {
                    this._isValid = true;

                    return;
                }

                this.setCustomValidity(result.message);
            } catch (error) {
                let message;
                if (error instanceof Error) {
                    message = error.message;
                } else {
                    message = JSON.stringify(error);
                }

                this.setCustomValidity(message);
            } finally {
                this.checkValidity();
            }
        }
    }

    override render() {
        return html`
            <uui-form-validation-message>
                <uui-input id="custom-url-input"
                           label="Custom URL"
                           class="element"
                           autowidth=""
                           pristine=""
                           .type=${this._type}
                           .value=${this.textValue ?? ''}
                           @input=${this.#onInput}
                           ?required=${this.mandatory}
                           .requiredMessage=${this.mandatoryMessage}
                >
                </uui-input>
            </uui-form-validation-message>
        `;
    }

    static override readonly styles = [
        UmbTextStyles,
        css`
            .element {
                width: 100%;
            }
        `
    ];
}


declare global {
    interface HTMLElementTagNameMap {
        'custom-url': CustomUrl;
    }
}

Hi @crahauiser

Just from a shift look, to give you something to work with now.

I would think the specific issue is cause by you not providing the * validationMessage* as part of this code. As in validationMessage is undefined:


        this.addValidator(
            'customError',
            () => this.validationMessage,
            () => this._isValid
        );

Also I would go with a addValidator or use setCustomValidity, not both. sorry if that was not clear earlier.

And good point about the async, I now remember that the Native Validation System for Form Controls does not support things being async, so you would need to work around that.
But in that light setCustomValidity is a perfect match, cause you can set it when it’s ready.

So get rid of _isValid and addValidator .

also no need to implement a uui-form-validation-message, as you are in a Property Editor, Property Editors already have that implemented for them.

1 Like

Thank you so much @nielslyngsoe for your guidance on this!

I have gotten my TypeScript to work properly, by removing the call to this.addValidator, and using this.setCustomValidity. I also removed the uui-form-validation-message since it was not needed.

I am currently dealing with the server-side validation, as it has slightly changed from v13, but feel that it is more of a matter of completely changing to Udi from int for document ID, as most of the logic seems to run exactly the same as it did in v13.

I am going to try and update the GitHub documentation, to add that calling:

this.setCustomValidity();

will clear any errors that are displaying in the UI (as well as remove any errors tracked in the custom element). Since I am using a custom data type for the custom element value (the uui-input text value, as well as the ID of the document - the workspace context unique ID), I had to override the set and get value properties, and make sure I was using super rather than this to prevent infinite recursion on the set logic.

I would be happy to answer any questions on the code, while I work through the last pieces of the server-side validation. I really appreciate your quick responses and thoughtful answers. I have been a happy user of Umbraco since 2013 with version 6, and was always able to read through the code to figure out how things work. I understand the reasoning behind moving to Lit and the UUI library, to have more control of the BackOffice, without being so reliant on a third-party, and appreciate it (even if it has given me some difficulties). I’m sure that the documentation will get to a point where the payoff will really show itself, and will try to contribute more as I learn and experiment with Umbraco v16, Lit, UUI, and TypeScript. This is the code that I came up with that is now working properly:

import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { html, css, customElement, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbPropertyEditorUiElement, UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui';
import { UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace';
import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
import type { UmbAuthContext } from '@umbraco-cms/backoffice/auth';
import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';

type UuiInputTypeType = typeof UUIInputElement.prototype.type;

type CustomUrlVerificationResult = {
    completed: boolean;
    message: string;
};

type CustomUrlFormData = {
    value: string,
    id: UmbEntityUnique | undefined
};

@customElement('custom-url')
export default class CustomUrl extends UmbFormControlMixin<CustomUrlFormData, typeof UmbLitElement, undefined>(UmbLitElement, undefined) implements UmbPropertyEditorUiElement {
    @property({ type: Boolean })
    public mandatory?: boolean;
    @property({ type: String })
    public mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY;

    @property({ type: String })
    public name?: string;

    @state()
    protected textValue: string;  // this is the text stored in the actual uui-input

    private _defaultType: UuiInputTypeType = 'text';

    @state()
    private _type: UuiInputTypeType = this._defaultType;

    private _verificationUrl = '/umbraco/management/api/v1/custom-url';
    private _propertyContext?: UmbEntityUnique;
    private _authContext?: UmbAuthContext | undefined;
    private _disallowedCharacters = /[^a-z0-9\-\/]+/gi;

    private _delayedValidation: () => void;

    constructor() {
        super();

        this.consumeContext(UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT, (context) => {
            this._propertyContext = context?.getUnique();
        });

        this.consumeContext(UMB_AUTH_CONTEXT, (context: UmbAuthContext | undefined) => {
            this._authContext = context;
        });

        // setup a debounce on our validation, since it calls server-side
        this._delayedValidation = this._debounce(this._validate, 500);

        // initialize properties and state
        super.value = {} as CustomUrlFormData;
        this.textValue = '';
    }

    @property({ type: Object })
    public override set value(newValue: CustomUrlFormData | undefined) {
        super.value = newValue;  // call to super.value so we don't set off an infinite "set" on value with this.value

        if (newValue && newValue.value && this.textValue !== newValue.value) {
            this.textValue = newValue.value;
        }
    }

    public override get value(): CustomUrlFormData | undefined {
        return super.value;
    }

    public set config(config: UmbPropertyEditorConfigCollection | undefined) {
        this._type = config?.getValueByAlias<UuiInputTypeType>('inputType') ?? this._defaultType;
    }

    private _onInput(e: InputEvent) {
        const newValue = (e.target as HTMLInputElement).value;
        if (newValue === this.textValue) {
            return;
        }

        this.textValue = newValue;

        super.value = {
            value: this.textValue,
            id: this._propertyContext
        };

        this.dispatchEvent(new UmbChangeEvent());

        this._delayedValidation();
    }

    protected override firstUpdated() {
        this.addFormControlElement(this.shadowRoot!.querySelector('uui-input')!);
    }

    override focus() {
        return this.shadowRoot?.querySelector<UUIInputElement>('uui-input')?.focus();
    }

    private _validate = async () => {
        this.setCustomValidity();  // call without an error to reset message

        const value = this.textValue ?? '';

        if (!this.mandatory && value === '') {
            return;  // no validation necessary
        }

        if (value === '' && this.mandatory?.valueOf() === true) {
            this.setCustomValidity('The Custom URL is required');
        } else if (value.includes('--')) {
            this.setCustomValidity('The Custom URL should not contain 2 or more consecutive hyphens');
        } else if (value.includes('//')) {
            this.setCustomValidity('The Custom URL should not contain 2 or more consecutive forward slashes');
        } else if (value.startsWith('/')) {
            this.setCustomValidity('The Custom URL should not start with a forward slash');
        } else if (value.endsWith('/')) {
            this.setCustomValidity('The Custom URL should not end with a forward slash');
        } else if (this._disallowedCharacters.test(value)) {
            this.setCustomValidity('The Custom URL should only contain letters, numbers, hyphens, or forward slashes');
        } else {
            try {
                const token = await this._authContext?.getLatestToken();

                const response = await fetch(this._verificationUrl, {
                    method: 'POST',
                    headers: {
                        'Authorization': `Bearer ${token}`,
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(super.value)
                });

                if (!response.ok) {
                    this.setCustomValidity(`Response status: ${response.status}`);

                    return;
                }

                const result: CustomUrlVerificationResult = await response.json();

                if (result.completed) {
                    return;
                }

                this.setCustomValidity(result.message);
            } catch (error) {
                let message;
                if (error instanceof Error) {
                    message = error.message;
                } else {
                    message = JSON.stringify(error);
                }

                this.setCustomValidity(message);
            }
        }
    }

    private _debounce(func: () => void, delay: number) {
        let timeout: number | undefined;
        return () => {
            clearTimeout(timeout);
            timeout = setTimeout(func, delay);
        };
    }

    override render() {
        return html`
            <uui-input id="custom-url-input"
                       label="Custom URL"
                       class="element"
                       autowidth=""
                       pristine=""
                       .type=${this._type}
                       .value=${this.textValue ?? ''}
                       @input=${this._onInput}
                       ?required=${this.mandatory}
                       .requiredMessage=${this.mandatoryMessage}
            >
            </uui-input>
        `;
    }

    static override readonly styles = [
        UmbTextStyles,
        css`
            .element {
                width: 100%;
            }
        `
    ];
}


declare global {
    interface HTMLElementTagNameMap {
        'custom-url': CustomUrl;
    }
}

Once I figure out the server side logic, I will post an example to follow to make things easier for other users.

1 Like

After getting the client-side validation working, I started working on the server-side validation. The namespace is just based on the agency I work for, and a project meant to hold BackOffice logic. You no longer use the UmbracoAuthorizedJsonController for BackOffice controllers, but instead make use of the ManagementApiControllerBase:

namespace AcsWeb.Logic.CustomUrl;

using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Asp.Versioning;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Management.Controllers;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Authorization;

[ApiVersion("1.0")]
[VersionedApiBackOfficeRoute("custom-url")]
[ApiExplorerSettings(GroupName = "Custom URL API")]
[MapToApi("custom-url")]
[Authorize(AuthorizationPolicies.SectionAccessContent)]
public class CustomUrlApiController :
    ManagementApiControllerBase
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IEntityService _entityService;
    private readonly IContentService _contentService;
    private readonly IUmbracoContextFactory _umbracoContextFactory;

    public CustomUrlApiController(
        IHttpContextAccessor httpContextAccessor,
        IEntityService entityService,
        IContentService contentService,
        IUmbracoContextFactory umbracoContextFactory)
    {
        _httpContextAccessor = httpContextAccessor;
        _entityService = entityService;
        _contentService = contentService;
        _umbracoContextFactory = umbracoContextFactory;
    }

    [HttpPost]
    [MapToApiVersion("1.0")]
    [ProducesResponseType<CustomUrlVerificationResult>(StatusCodes.Status200OK)]
    public CustomUrlVerificationResult Verify(
        CustomUrlAliasInput input)
    {
        string encodedUrl = _httpContextAccessor.HttpContext?.Request.GetEncodedUrl();

        var attempt = _entityService.GetId(input.Id ?? Guid.Empty, UmbracoObjectTypes.Document);
        int pageId = attempt.ResultOr(0);

        var aliasVerificationHelper = new AliasVerificationHelper(encodedUrl, _contentService, _umbracoContextFactory, input.Value, pageId);

        return aliasVerificationHelper.GetVerification();
    }
}

The CustomUrlVerificationResult just has a Completed boolean, and a Message string. The CustomUrlAliasInput has a value string (which is the Custom URL), and an id UmbEntityUnique property which maps to the document GUID (if the document already exists, otherwise this will be empty), and is the CustomUrl custom element value property.

The AliasVerificationHelper makes sure that a duplicate Custom URL hasn’t already been used. In addition to the Management API Controller, I had to create a DataEditor:

namespace AcsWeb.Logic.CustomUrl;

using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;

[DataEditor("AcsWeb.Logic.DataEditor.CustomUrl", ValueEditorIsReusable = false)]
public class CustomUrlDataEditor :
    DataEditor
{
    public CustomUrlDataEditor(IDataValueEditorFactory dataValueEditorFactory)
        : base(dataValueEditorFactory)
    {
    }

    protected override IDataValueEditor CreateValueEditor()
        => DataValueEditorFactory.Create<CustomUrlDataValueEditor>(Attribute!);
}

A CustomUrlDataValueEditor, which transforms the value coming from the custom element to a string value in the database/Umbraco document:

namespace AcsWeb.Logic.CustomUrl;

using System;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Core.Web;

public class CustomUrlDataValueEditor :
    DataValueEditor
{
    public CustomUrlDataValueEditor(
        IShortStringHelper shortStringHelper,
        IJsonSerializer jsonSerializer,
        IIOHelper ioHelper,
        DataEditorAttribute attribute,
        IHttpContextAccessor httpContextAccessor,
        IEntityService entityService,
        IContentService contentService,
        IUmbracoContextFactory umbracoContextFactory)
        : base(shortStringHelper, jsonSerializer, ioHelper, attribute)
            => Validators.Add(new CustomUrlValueValidator(httpContextAccessor, entityService, contentService, umbracoContextFactory));

    public override object FromEditor(
        ContentPropertyData editorValue,
        object currentValue)
    {
        if (editorValue.Value is not JsonObject json)
        {
            return null;
        }

        if (!json.ContainsKey("value"))
        {
            return null;
        }

        return json["value"]!.GetValue<string>();  // return the string value of the Custom URL
    }

    public override object ToEditor(
        IProperty property,
        string culture = null,
        string segment = null)
    {
        var value = property.GetValue(culture, segment);

        // the custom-url element's value is an object of { value: String, id: UmbEntityUnique }
        // the custom-url LitElement can retrieve the document id from the UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT
        // so we just return the same "shape" of the object to stay compatible and give the correct URL value (String)
        return new
        {
            value,
            id = Guid.Empty
        };
    }
}

And a CustomUrlValueValidator, which does the server-side validation on submit, and returns an array of ValidationResults (or an empty array if no errors have occurred):

namespace AcsWeb.Logic.CustomUrl;

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;

public class CustomUrlValueValidator :
    IValueValidator
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IEntityService _entityService;
    private readonly IContentService _contentService;
    private readonly IUmbracoContextFactory _umbracoContextFactory;
    private static readonly string[] ValidationResultInvalidMemberName = ["value"];

    public CustomUrlValueValidator(
        IHttpContextAccessor httpContextAccessor,
        IEntityService entityService,
        IContentService contentService,
        IUmbracoContextFactory umbracoContextFactory)
    {
        _httpContextAccessor = httpContextAccessor;
        _entityService = entityService;
        _contentService = contentService;
        _umbracoContextFactory = umbracoContextFactory;
    }
    
    public IEnumerable<ValidationResult> Validate(
        object value,
        string valueType,
        object dataTypeConfiguration,
        PropertyValidationContext validationContext)
    {
        if (value is not JsonObject json)
        {
            return [new ValidationResult("Value is invalid", ValidationResultInvalidMemberName)];
        }

        if (value.ToString()!.DetectIsEmptyJson())
        {
            return [new ValidationResult("Value cannot be empty", ValidationResultInvalidMemberName)];
        }

        if (!json.ContainsKey("value"))
        {
            return [new ValidationResult("Value is empty or contains an invalid value", ValidationResultInvalidMemberName)];
        }

        string customUrl = json["value"]!.GetValue<string>();
        Guid? id = null;
        if (json.ContainsKey("id"))
        {
            id = json["id"]!.GetValue<Guid>();
        }

        string encodedUrl = _httpContextAccessor.HttpContext?.Request.GetEncodedUrl();

        var attempt = _entityService.GetId(id ?? Guid.Empty, UmbracoObjectTypes.Document);
        int pageId = attempt.ResultOr(0);

        var aliasVerificationHelper = new AliasVerificationHelper(encodedUrl, _contentService, _umbracoContextFactory, customUrl, pageId);
        var result = aliasVerificationHelper.GetVerification();

        return result.Completed
            ? []
            : [new ValidationResult(result.Message)];
    }
}

Although not related to the custom element, in order to make a Custom URL work (rather than using the natural way Umbraco URLs are generated from their path in the content tree), I also needed to implement the IContentFinder and IUrlProvider interfaces.

This is the umbraco-package.json file I am using (AcsWeb.Web is my Umbraco UI project):

{
  "$schema": "../../umbraco-package-schema.json",
  "name": "AcsWeb.Web",
  "version": "0.0.1",
  "extensions": [
    {
      "type": "propertyEditorUi",
      "alias": "AcsWeb.Web.CustomUrl",
      "name": "ACS Edge CMS Custom URL Property Editor",
      "element": "/App_Plugins/customurl/customurl.js",
      "elementName": "custom-url",
      "meta": {
        "label": "Custom URL",
        "propertyEditorSchemaAlias": "AcsWeb.Logic.DataEditor.CustomUrl",
        "icon": "icon-code",
        "group": "common"
      }
    }
  ]
}

While using Lit and TypeScript to create the Custom URL custom element seemed like an infeasible task, now that I have completed it, I can see how using Web Components and a more web standards based API is a solution that will provide a solution that won’t hold back development of the rest of the Umbraco CMS as changes are made, and new additions created.

I truly want to thank @nielslyngsoe for his insight and help in solving this. I honestly should have posted my original ideas last year, but was overwhelmed as the only full-stack developer on staff. Going back through the Lit documentation, and the current Umbraco documentation and source code, filled in the blanks that I needed to get this to work.

I am looking into updating the documentation on GitHub for some parts that I believe could help others, and am thankful for the Umbraco community and open nature! If anyone has any questions that I could help with, I will do my best.

1 Like

Hi @crahauiser

WOW! Fantastic summary. First I am happy that you figured it out. And then I’m very pleased that you will incorporate your findings into the Docs so others can gain value from your research. It is surely a great help for us, especially as you are fully aware of the journey from an extension’s point of view.

Well done, let me know if you need any help with the Docs.

Thanks for the examples here, I implemented the client-side validation on a setup I have and it worked fine but once I used the property within a block list although a red message comes up saying it was invalid it did not stop the node from been published.

Has anyone else seen this behaviour?

Thanks

Have you only implemented client-side validation, or did you also build out server-side validation?

Thanks for the reply.

Only client-side.

I went a different way in the end, I used the RegEx validation on a in-build textstring property.

Slightly off topic but for a different property that I required some more complex validation on I used a ContentPublishingNotification and had it all server-side.