Property Editor: How do I use valdiation?

Hi there :waving_hand:
Feeling a bit overwhelmed and struggling to find the approach on how to use and set custom validation messages on a property editor.

I am building a date range property editor and need to set a couple of custom validation messages:

  • End Date is required
  • Start Date is required
  • End Date can not be before the start date

Screenshot

The image shows a website interface with fields for start and end dates for meeting dates and dates with times, along with value settings in JSON format. (Captioned by AI)

Current Code

import { UmbInputDateElement } from '@umbraco-cms/backoffice/components';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';

interface DateRangeValue {
    StartDate: string | null;
    EndDate: string | null;
}

@customElement('date-range-picker')
export default class DateRangePickerElement extends UmbLitElement implements UmbPropertyEditorUiElement {
	
    @property({ type: Boolean, reflect: true })
	readonly = false;

    @property({ type: Boolean, reflect: true })
    mandatory?: boolean;

    @property()
    mandatoryMessage?: string;
    
    /*
    {
        "StartDate": "2021-04-15 00:00:00", // Can I parse this to be a Date object in JS (Technically missing the T part)
        "EndDate": null
    }
    */
    @property({ type: Object })
    value: Partial<DateRangeValue> = {};

    /*
    [
        {
            "alias": "endDateIsRequired",
            "value": true
        },
        {
            "alias": "enableTimePicker",
            "value": false
        }
    ]
    */
    set config(value: UmbPropertyEditorConfigCollection | undefined) {
        this._endDateIsRequired = value?.getValueByAlias('endDateIsRequired') ?? false;
        this._enableTimePicker = value?.getValueByAlias('enableTimePicker') ?? false;
    }

    // Add validation method
    #validateDateRange(): void {
        const startDate = this.value?.StartDate ? new Date(this.value.StartDate) : null;
        const endDate = this.value?.EndDate ? new Date(this.value.EndDate) : null;
        
        // Check if end date comes before start date
        this._isDateRangeInvalid = !!(startDate && endDate && endDate < startDate);

        // TODO: How do I use some native validation ?!
    }

    @state()
    private _isDateRangeInvalid: boolean = false;

    @state()
    private _endDateIsRequired: boolean = false;

    @state()
    private _enableTimePicker: boolean = false;


	#onInputStart(e: InputEvent) {
        // 2025-06-03
        // 2025-06-08T19:00
        const startDateValue = (e.target as UmbInputDateElement).value.toString();
        console.log('Start Date Value:', startDateValue);

        const parsedDate = startDateValue ? new Date(startDateValue) : null;
        console.log('Parsed Start Date:', parsedDate);

        // Set date as this format '2025-06-08T19:00'
        const formattedDate = this.formatDateForInput(parsedDate);

        this.value = {
            ...this.value,
            StartDate: formattedDate
        };

        this.dispatchEvent(new UmbChangeEvent());
        this.#validateDateRange();
    }

    #onInputEnd(e: InputEvent) {
        // 2025-06-03
        // 2025-06-08T19:00
        const endDateValue = (e.target as UmbInputDateElement).value.toString();
        console.log('END Date Value:', endDateValue);

        const parsedDate = endDateValue ? new Date(endDateValue) : null;
        console.log('Parsed END Date:', parsedDate);

        // Set date as this format '2025-06-08T19:00'
        const formattedDate = this.formatDateForInput(parsedDate);

        this.value = {
            ...this.value,
            EndDate: formattedDate
        };
        this.dispatchEvent(new UmbChangeEvent());
        this.#validateDateRange();
    }

    private formatDateForInput(date: Date | null): string {
        if (!date) return '';
        
        // Get local date/time components
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, '0');
        const day = String(date.getDate()).padStart(2, '0');
        const hours = String(date.getHours()).padStart(2, '0');
        const minutes = String(date.getMinutes()).padStart(2, '0');
        
        if(!this._enableTimePicker) {
            return `${year}-${month}-${day}`; // Format as 'YYYY-MM-DD' for date input
        }

        // Format as 'YYYY-MM-DDTHH:MM' for datetime-local input
        return `${year}-${month}-${day}T${hours}:${minutes}`;
    }

    constructor() {
        super();
        // If we need to observe or consume contexts
    }

	override render() {
		return html`
            <div class="date-inputs">
                <div class="date-field">
                    <uui-label>Start Date</uui-label>
                    <umb-input-date 
                        @input=${this.#onInputStart}
                        .value=${this.value?.StartDate ?? ''}
                        .type=${this._enableTimePicker ? 'datetime-local' : 'date'}></umb-input-date>
                </div>
                <div class="date-field">
                    <uui-label>End Date ${this._endDateIsRequired ? html `<span class="required">*</span>`: nothing}</uui-label>
                    <umb-input-date
                        @input=${this.#onInputEnd}
                        .value=${this.value?.EndDate ?? ''}
                        .min=${this.value?.StartDate ?? ''}
                        .type=${this._enableTimePicker ? 'datetime-local' : 'date'}
                        ?required=${this._endDateIsRequired}></umb-input-date>
                </div>
            </div>

            ${this._isDateRangeInvalid ? 
                html`
                    <div class="validation-message">
                        <uui-icon name="icon-alert" style="color: var(--uui-color-danger);"></uui-icon>
                        End date cannot be before start date
                    </div>` 
                : nothing
            }

            <umb-code-block language="value">
                ${JSON.stringify(this.value, null, 2)}
            </umb-code-block>

            <pre>
                End date is required: ${this._endDateIsRequired}
                Enable Time Picker: ${this._enableTimePicker}

                Value.StartDate: ${this.value?.StartDate}
                Value.EndDate: ${this.value?.EndDate}

                Readonly: ${this.readonly}
                Mandatory: ${this.mandatory}
                Mandatory Message: ${this.mandatoryMessage}
            </pre>
        `;
	}

	static override readonly styles = [
		UmbTextStyles,
		css`
			uui-input {
				width: 100%;
			}

            .date-inputs {
                display: flex;
                gap: 1rem;
                align-items: flex-start
            }

            uui-label {
                display:block;
            }

            .required {
                color: var(--uui-color-danger);
                font-weight: 900;
            }
		`,
	];
}

declare global {
	interface HTMLElementTagNameMap {
		'date-range-picker': DateRangePickerElement;
	}
}

Existing Docs

The existing documentation for validation is quite detailed and I am not sure I understand how to use it and nor does it talk about setting any custom validation messages. What am I missing?

In addition to this the tutorial for creating a property editor, seems to encourage server side validation approach and does not cover the scenario of using client side validation

Perhaps one for the docs team to add/improve on?

Any pointers or advice

So do you have any pointers or advice on what I need to be doing to set custom validation messages for this property editor?

:umbraco-heart:

Hi Warren,

This might help a lot! I managed to get ‘custom’ validation working and in my case even conditionally:

1 Like

Thanks Luuk
Do you mind sharing your code, would be interesting to see please :slight_smile:

Let’s see. My editor is a videoplayer where you can just enter the URL of a video in multiple formats of certain video platforms and you’ll get to see the preview of the video in the backoffice.

When a plaform supports oEmbed (Youtube, Vimeo), I can just get the title of the video from that and you optionally override it. However, if I can’t get the title, the title field is no longer an override, but required.

// You need the UmbFormControlMixin to make it a form with validation
export default class ProudNerdsVideoPropertyEditorUIElement extends UmbFormControlMixin<VideoPlayerPropertyEditorModel | undefined, typeof UmbLitElement>(UmbLitElement, undefined) implements UmbPropertyEditorUiElement {

//When a video URL is entered and the details of the video are loaded, check if the field is required  
async #setVideoDetails(videoParameters: VideoPlayerPropertyEditorModel): Promise<boolean> {
  //Some API call to get video details here
  ...

  // Wait for videoDetails to cause a re-render
  await this.updateComplete;
  
  // Now DOM will include #videoTitleInput
  this.#setRequiredFields();
  }

//Make the title field required or not
   async #setRequiredFields(forceRemove: boolean = false) {
		const videoInput = this.shadowRoot?.querySelector('#videoTitleInput') as UUIInputElement | null;

		if (videoInput === null)
			return;

		if (videoInput && this.#titleIsRequired() && !forceRemove) {
			this.addFormControlElement(videoInput);
		} else if (videoInput) {
			this.removeFormControlElement(videoInput);
		}
	}
}

This prevents the page to be published if the validation fails. Just use the required property on whatever you want to validate.

Thanks @LuukPeters that has helped seeing how you done it along with Niels post on that other thread.

Summary

So to summarize for anyone reading along:

  • Extend from UmbFormControlMixin<T> where T is the shape of the data being saved/stored for the property editor
extends UmbFormControlMixin<Partial<DateRangeValue>>(UmbLitElement)
  • Add the Form control from UUI or Umb components with addFormControlElement
override firstUpdated() {
    this.addFormControlElement(this.shadowRoot!.querySelector('#startDate')!);
    this.addFormControlElement(this.shadowRoot!.querySelector('#endDate')!);
}
  • The UUI-input or UMB-input web components own validation from these controls will make it visible and prevent saving etc on the node

Validation in action

Full code of date range property editor

import { UmbInputDateElement } from '@umbraco-cms/backoffice/components';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';

interface DateRangeValue {
    StartDate: string | null | undefined;
    EndDate: string | null | undefined;
}

@customElement('date-range-picker')
export default class DateRangePickerElement 
    extends UmbFormControlMixin<Partial<DateRangeValue>>(UmbLitElement)
    implements UmbPropertyEditorUiElement {
	
    @property({ type: Boolean, reflect: true })
	readonly = false;

    @property({ type: Boolean, reflect: true })
    mandatory?: boolean;

    @property()
    mandatoryMessage?: string;
    
    // Stored JSON value example
    /*
    {
        "StartDate": "2021-04-15 00:00:00"
        "EndDate": null
    }
    */

    /*
    [
        {
            "alias": "endDateIsRequired",
            "value": true
        },
        {
            "alias": "enableTimePicker",
            "value": false
        }
    ]
    */
    set config(value: UmbPropertyEditorConfigCollection | undefined) {
        this._endDateIsRequired = value?.getValueByAlias('endDateIsRequired') ?? false;
        this._enableTimePicker = value?.getValueByAlias('enableTimePicker') ?? false;
    }

    @state()
    private _endDateIsRequired: boolean = false;

    @state()
    private _enableTimePicker: boolean = false;

    // Just a simple bool flag to help debugging
     #debug: boolean = false;

	#onInputStart(e: InputEvent) {
        // 2025-06-03
        // 2025-06-08T19:00
        const startDateValue = (e.target as UmbInputDateElement).value.toString();
        console.log('Start Date Value:', startDateValue);

        const parsedDate = startDateValue ? new Date(startDateValue) : null;
        console.log('Parsed Start Date:', parsedDate);

        // Set date as this format '2025-06-08T19:00' or '2025-06-08' if time picker is disabled
        const formattedDate = this.#formatDateForInput(parsedDate);

        this.value = {
            ...this.value,
            StartDate: formattedDate
        };

        this.dispatchEvent(new UmbChangeEvent());
    }

    #onInputEnd(e: InputEvent) {
        // 2025-06-03
        // 2025-06-08T19:00
        const endDateValue = (e.target as UmbInputDateElement).value.toString();
        //console.log('END Date Value:', endDateValue);

        const parsedDate = endDateValue ? new Date(endDateValue) : null;
        //console.log('Parsed END Date:', parsedDate);

        // Set date as this format '2025-06-08T19:00' or '2025-06-08' if time picker is disabled
        const formattedDate = this.#formatDateForInput(parsedDate);

        this.value = {
            ...this.value,
            EndDate: formattedDate
        };
        this.dispatchEvent(new UmbChangeEvent());
    }

    #formatDateForInput(date: Date | null): string {
        if (!date) return '';
        
        // Get local date/time components
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, '0');
        const day = String(date.getDate()).padStart(2, '0');
        const hours = String(date.getHours()).padStart(2, '0');
        const minutes = String(date.getMinutes()).padStart(2, '0');
        
        if(!this._enableTimePicker) {
            return `${year}-${month}-${day}`; // Format as 'YYYY-MM-DD' for date input
        }

        // Format as 'YYYY-MM-DDTHH:MM' for datetime-local input
        return `${year}-${month}-${day}T${hours}:${minutes}`;
    }

    #parse(dateToParse:string | null | undefined): string {
        // The property editor may have existing data in format of
        // 2021-05-12T00:00:00 even when no time was set for property editor

        // To make existing stored values display in the start or end date 
        // then they need to have the time removed 

        if (!dateToParse){
            return '';
        }

        const parsedDate = new Date(dateToParse);
        return this.#formatDateForInput(parsedDate);
    }

    constructor() {
        super();
        // If we need to observe or consume contexts
    }

    override firstUpdated() {
		this.addFormControlElement(this.shadowRoot!.querySelector('#startDate')!);
        this.addFormControlElement(this.shadowRoot!.querySelector('#endDate')!);
	}

	override render() {
		return html`
            <div class="date-inputs">
                <div class="date-field">
                    <uui-label>Start Date</uui-label>
                    <umb-input-date id="startDate"
                        @input=${this.#onInputStart}
                        .value=${this.#parse(this.value?.StartDate) ?? ''}
                        .type=${this._enableTimePicker ? 'datetime-local' : 'date'}
                        ?required=${this.mandatory || this._endDateIsRequired}
                        required-message="${this.mandatoryMessage ? this.mandatoryMessage : 'Please enter a start date'}"></umb-input-date>
                </div>
                <div class="date-field">
                    <uui-label>End Date ${this._endDateIsRequired ? html `<span class="required">*</span>`: nothing}</uui-label>
                    <umb-input-date id="endDate"
                        @input=${this.#onInputEnd}
                        .value=${this.#parse(this.value?.EndDate) ?? ''}
                        .min=${this.value?.StartDate ?? ''}
                        .type=${this._enableTimePicker ? 'datetime-local' : 'date'}
                        ?required=${this._endDateIsRequired}
                        required-message="Please enter an end date"></umb-input-date>
                </div>
            </div>

            ${this.#debug ? this.#renderDebug() : nothing }
        `;
	}

    #renderDebug() {
        return html`
            <umb-code-block language="Stored Value">${JSON.stringify(this.value, null, 2)}</umb-code-block>

            <h4>Config</h4>
            <ul>
                <li>Readonly: ${this.readonly} ${this.readonly ? '✅' : '❌'}</li>
                <li>Mandatory: ${this.mandatory} ${this.mandatory ? '✅' : '❌'}</li>
                <li>Mandatory Message: ${this.mandatoryMessage}</li>
                <li>End date is required: ${this._endDateIsRequired} ${this._endDateIsRequired ? '✅' : '❌'}</li>
                <li>Enable Time Picker: ${this._enableTimePicker} ${this._enableTimePicker ? '✅' : '❌'}</li>
            </ul>
        `;
    }

     

	static readonly styles = [
		UmbTextStyles,
		css`
			uui-input {
				width: 100%;
			}

            .date-inputs {
                display: flex;
                gap: 1rem;
                align-items: flex-start
            }

            uui-label {
                display:block;
            }

            .required {
                color: var(--uui-color-danger);
                font-weight: 900;
            }
		`,
	];
}

declare global {
	interface HTMLElementTagNameMap {
		'date-range-picker': DateRangePickerElement;
	}
}

Further questions

  • How do I control where the message is displayed in the UI?
    • Currently this seems to show at the end/after the HTML of the property editor
    • In your example Luuk it shows at the bottom, surely it would be nice to put the message nearer to the red input box?
  • If I had another property editor like the suggestions from the tutorial, how would I write custom validation message that would prevent you from writing the words Sitecore or Wordpress

@warren this might interest you:

<div class="hours-row" id="hours-${dayIndex}-${hoursIndex}">
  <div class="time-range">
    <uui-input
        type="time"
        .value=${this._formatTime(hours.opensAt)}
        @change=${(e: Event) => {
          const value = this._parseTimeInput((e.target as HTMLInputElement).value);
          this._updateTime(dayIndex, hoursIndex, 'opensAt', value);
        }}
        placeholder="Open time"
        label="Open time"
        ?required=${!this._config.excludeTimes && !this._config.hoursOptional}
        ${umbBindToValidation(this, `opensAt_${dayIndex}_${hoursIndex}`)}
      >
      </uui-input>
     <span>to</span>
     <uui-input
       type="time"
        .value=${this._formatTime(hours.closesAt)}
        @change=${(e: Event) => {
        const value = this._parseTimeInput((e.target as HTMLInputElement).value);
          this._updateTime(dayIndex, hoursIndex, 'closesAt', value);
        }}
        placeholder="Close time"
        label="Close time"
        ?error=${this._hasValidationError(dayIndex, hoursIndex, 'closesAt')}
        .errorMessage=${this._validateTimeField(dayIndex, hoursIndex, 'closesAt')}
        ?required=${!this._config.excludeTimes && !this._config.hoursOptional && !this._config.closedHoursOptional}
        ${umbBindToValidation(this, `closesAt_${dayIndex}_${hoursIndex}`)}>
      >
       </uui-input>
   </div>
</div>

<uui-form-validation-message for="hours-${dayIndex}-${hoursIndex}">
</uui-form-validation-message>

Essentially, you can use uui-form-validation-message with for to target the element you want to monitor for validation, and can place that anywhere you want.

You can also use errorMessage and error for custom errors, and change the default required message with requiredMessage

Take a look at Storybook - Form Validation Message Docs for more details, and also Storybook - input element docs