Creating custom Optimizely Forms elements, the simple way

There are several guides out there on how to create an Optimizely Forms element, including a set of samples provided by the people at Optimizely themselves. However, these examples are often pretty hard to follow, so I figured it would be helpful to write a quickstart guide that covers just what you need.

Before anything else, you can find the code for this on GitHub.

For this example, we’re going to build out a very simple form element. For the purposes of this demonstration, we will create a form element that allows you to enter a minimum and maximum range. This is of course a very simple example, but serves to get the point across. The steps will be: 1. Create the backend model for the form element, 2. Create the backend validators, 3. Create the frontend JavaScript validation and value binding logic, and 4. Create the view.

0. Prerequisites
For this to work, you need an existing Optimizely website with the Optimizely Forms and Newtonsoft.Json packages already installed.

1. Create the backend model
Like any Optimizely content type, we need to define a model. Form elements are pretty similar to blocks thankfully, so you can use the same type of thinking. We will first start out with the skeleton class for the model:

[ContentType(DisplayName = "Min-Max Range", GUID = "80ABAC02-ACE3-4828-81FF-F7F58D322ACD", Description = "A mininum and maximum number")]
public class MinMaxRangeFormElementBlock : InputElementBlockBase, IElementCustomFormatValue, IElementRequireClientResources
{
    public override ElementInfo GetElementInfo()
    {
        var baseInfo = base.GetElementInfo();
        baseInfo.CustomBinding = true;
        return baseInfo;
    }
}

Let’s break down each part. The ContentType notation is the same as used for pages and blocks, so I won’t bother explaining that. InputElementBlockBase is the base class for form elements, similiar to PageData or BlockData. IElementCustomFormatValue means that we will be applying some sort of custom logic to extract the value. IElementRequireClientResources means there will be frontend JavaScript. We also in GetElementInfo indicate that custom binding logic will be applied.

Now, we need to attach data to the model. Like any page or block type, we should define what types of properties we want. For this one, we can set a lower and upper bound (i.e. min must be above the lower bound and max must be below the upper bound).

[ContentType(DisplayName = "Min-Max Range", GUID = "80ABAC02-ACE3-4828-81FF-F7F58D322ACD", Description = "A mininum and maximum number")]
public class MinMaxRangeFormElementBlock : InputElementBlockBase, IElementCustomFormatValue, IElementRequireClientResources
{
    [Display(Name = "Lower Bound", Order = 100)]
    public int? LowerBound { get; set; }
    
    [Display(Name = "Upper Bound", Order = 200)]
    public int? UpperBound { get; set; }

    public override ElementInfo GetElementInfo()
    {
        var baseInfo = base.GetElementInfo();
        baseInfo.CustomBinding = true;
        return baseInfo;
    }
}

Now that we have the model, we need to implement the interface. First, we need to add the value binding by overridding GetSubmittedValue. We will use a tuple of two integers (Tuple<int, int>) as our backend storage format. Interestingly, because we already invoke frontend JS, we can serialize it to JSON on the frontend and just deserialize that. However, if the user’s browser does not support JavaScript, we do need to apply binding logic. Additionally, we have to have a way to get the value as a string for display to users and such, so for that we need to override GetFormattedValue which will just return “Min to Max” (for example, “1 to 4”.

public override object GetSubmittedValue()
{
	var rawSubmittedData = HttpContext.Current.Request.Form;

	var strValue = base.GetSubmittedValue() as string ?? string.Empty;

	var isJavaScriptSupport = rawSubmittedData.Get(EPiServer.Forms.Constants.FormWithJavaScriptSupport);
	if (isJavaScriptSupport == "true") //if the user's browser support JS, then deserialize the value provided by the frontend
	{
		var values = JsonConvert.DeserializeObject<List<int>>(strValue);
		if ((values?.Count ?? 0) != 2)
			return null;

		return Tuple.Create(values[0], values[1]);
	}

	//if the user's browser does not support JS, we need to extract the value ourselves from the HTML raw form fields
	var minName = $"{FormElement.ElementName}_min";
	var maxName = $"{FormElement.ElementName}_max";

	if (!int.TryParse(rawSubmittedData[minName], out var min) || !int.TryParse(rawSubmittedData[maxName], out var max))
		return null;

	return Tuple.Create(min, max);
}

public object GetFormattedValue()
{
	var submittedVal = GetSubmittedValue() as Tuple<int, int>;

	if (submittedVal is null)
		return string.Empty;

	return $"{submittedVal.Item1} to {submittedVal.Item2}";
}

Finally, we need to add the client resources for the JavaScript, which we will create in a subsequent step for all the frontend logic:

public IEnumerable<Tuple<string, string>> GetExtraResources()
{
	return new List<Tuple<string, string>>
	{
		new Tuple<string, string>("script", "/Static/js/rangeForm.js")
	};
}

With that, we have a complete class:

[ContentType(DisplayName = "Min-Max Range", GUID = "80ABAC02-ACE3-4828-81FF-F7F58D322ACD", Description = "A mininum and maximum number")]
public class MinMaxRangeFormElementBlock : InputElementBlockBase, IElementCustomFormatValue, IElementRequireClientResources
{
	[Display(Name = "Lower Bound", Order = 100)]
        public virtual int? LowerBound { get; set; }
        
        [Display(Name = "Upper Bound", Order = 200)]
        public virtual int? UpperBound { get; set; }

        public override ElementInfo GetElementInfo()
        {
            var baseInfo = base.GetElementInfo();
            baseInfo.CustomBinding = true;
            return baseInfo;
        }

        public IEnumerable<Tuple<string, string>> GetExtraResources()
        {
            return new List<Tuple<string, string>>
            {
                new Tuple<string, string>("script", "/Static/js/rangeForm.js")
            };
        }

        public override object GetSubmittedValue()
        {
            var rawSubmittedData = HttpContext.Current.Request.Form;

            var strValue = base.GetSubmittedValue() as string ?? string.Empty;

            var isJavaScriptSupport = rawSubmittedData.Get(EPiServer.Forms.Constants.FormWithJavaScriptSupport);
            if (isJavaScriptSupport == "true") //if the user's browser support JS, then deserialize the value provided by the frontend
            {
                var values = JsonConvert.DeserializeObject<List<int>>(strValue);
                if ((values?.Count ?? 0) != 2)
                    return null;

                return Tuple.Create(values[0], values[1]);
            }

            //if the user's browser does not support JS, we need to extract the value ourselves from the HTML raw form fields
            var minName = $"{FormElement.ElementName}_min";
            var maxName = $"{FormElement.ElementName}_max";

            if (!int.TryParse(rawSubmittedData[minName], out var min) || !int.TryParse(rawSubmittedData[maxName], out var max))
                return null;

            return Tuple.Create(min, max);
        }

        public object GetFormattedValue()
        {
            var submittedVal = GetSubmittedValue() as Tuple<int, int>;

            if (submittedVal is null)
                return string.Empty;

            return $"{submittedVal.Item1} to {submittedVal.Item2}";
        }
}

2. Create backend validators
We need to validate that users submit valid data to our form. We will validate in two places: frontend and backend. Backend validators are very important to ensure that a user doesn’t bypass frontend validation and also for users without JavaScript support. First, we need to add a validator class:

public class MinMaxRangeValidator : ElementValidatorBase
{
	public override bool? Validate(IElementValidatable targetElement)
	{
		var submittedValue = targetElement.GetSubmittedValue() as Tuple<int, int>;

		return submittedValue is null || submittedValue.Item1 < submittedValue.Item2;
	}

	public override bool AvailableInEditView
	{
		get {
			return false;
		}
	}

	public override IValidationModel BuildValidationModel(IElementValidatable targetElement)
	{
		var model = base.BuildValidationModel(targetElement);
		if (model != null)
		{
			model.Message = LocalizationService.Current.GetString("Form.Error.MinMaxRangeError");
		}
		return model;
	}
}

Let’s break down what each part does. The Validate function is simple enough: it checks the submitted value and determines whether or not it is valid. In this case, we can accept either a null submitted value or a value where the low bound of the range is less than the high bound of the range. Next, we override AvailableInEditView to be false. If it’s true, this validator can optionally be applied to a form element, but by making it false we don’t even give the choice to the editor. Finally, in BuildValidationModel we apply an error message if there is an error.

The next step is to link this validator to our form element. We first need to decorate the form element class:

[AvailableValidatorTypes(Include = new[] { typeof(MinMaxRangeValidator) })]

Then we need to force the validator to always be present. This is a bit tricky due to how the model works. What we have to do is override the list of applied validators and tack on the one we just created. We can do this by adding this property to our form element class:

public override string Validators
{
	get {
		var customValidator = string.Concat(typeof(MinMaxRangeValidator).FullName);

		var validators = this.GetPropertyValue(content => content.Validators);

		return string.IsNullOrEmpty(validators) ? customValidator : string.Concat(validators, EPiServer.Forms.Constants.RecordSeparator, customValidator);
	}

	set {
		this.SetPropertyValue(content => content.Validators, value);
	}
}

In this, we look for any existing validators specified by the editor and then concatenate our own validator to the end of the list, and make sure to include the record separator.

3. Create the frontend JavaScript validation and frontend binding logic
As stated earlier, we perform validation both on the frontend and backend. Now we need to create a JS file to do all this logic. We need to create the file “Static/js/rangeForm.js” (or whatever name/location you chose earlier in step 1). In that, we need to build out a skeleton file:

(function ($) {
    const originalGetCustomElementValue = epi.EPiServer.Forms.Extension.getCustomElementValue;
    const originalBindCustomElementValue = epi.EPiServer.Forms.Extension.bindCustomElementValue;

    $.extend(true, epi.EPiServer.Forms, {
        Extension: {
            getCustomElementValue: function ($element) {
                //TODO: implement
            },
            bindCustomElementValue: function ($element, val) {
                return originalBindCustomElementValue.apply(this, [$element, val]);
            },
        },
        Validators: {
            'FormsTest.Models.FormElements.MinMaxRangeValidator': function (fieldName, fieldValue, validatorMetaData) {
                // TODO: implement
            }
        },
    });
})($$epiforms);

Now, let’s get into what each of these functions does. First, we need to write the custom binding in getCustomElementValue. The basic idea is it will attempt to call this function for every custom form element (not just ours), so we need to do two things: 1. check if it’s a min-max range, and 2. if it is, apply our own value binding (to a two-element JSON-formatted array).

getCustomElementValue: function ($element) {
	if ($element.hasClass('Form__Element__MinMaxRange')) {
		return JSON.stringify([$element.find('[data-rangepart=min]').val(), $element.find('[data-rangepart=max]').val()])
	}

	return originalGetCustomElementValue.apply(this, [$element]);
},

In this case, we check if it’s our custom element by checking the CSS class, and if it is, we find the min and max inputs within the element using data-rangepart attributes (the CSS class and dataset attributes will be fleshed out when we build the view in the next step).

Next, we need to build out the validator. In it, we are already passed the value (formatted with our JS custom binding we just made as a JSON array), so we just need to see if it’s valid.

Validators: {
	'FormsTest.Models.FormElements.MinMaxRangeValidator': function (fieldName, fieldValue, validatorMetaData) {
		const value = JSON.parse(fieldValue);

		return { isValid: value[0] < value[1] };
	}
},

Notice that the object key has to be the fully qualified name of the validator class, and we return an object in the format { isValid: true }. This code will be run for any element with this validator class (in our case, all instances of the min-max range element because we force-applied this validator in step 2).

4. Build the view
This is the last step, thankfully. We need to build out the frontend view for how this element actually renders. Step 1 is to create a view in your block viewss folder called “MinMaxRangeFormElementBlock.cshtml” (make sure the filename matches the class you just created, of course). In it, we start with the outer wrapping.

@model FormsTest.Models.FormElements.MinMaxRangeFormElementBlock

<fieldset class="Form__Element Form__CustomElement Form__Element__MinMaxRange" data-epiforms-element-name="@formElement.ElementName">
</fieldset>

This is critically important to get right. All three CSS classes are important. Form__Element indicates this is a form element, Form__CustomElement indicates we are performing custom binding logic on it (so it knows to invoke the JS from the previous step), and Form__Element__MinMaxRange is so we can actually tell what type of form element this is. We also need data-epiforms-element-name to properly perform binding.

With that out of the way, let’s build out the view.

@model FormsTest.Models.FormElements.MinMaxRangeFormElementBlock

@{
    var formElement = Model.FormElement;
    var errorMessage = Model.GetErrorMessage();
    var submittedValue = Model.GetSubmittedValue() as Tuple<int, int>;
}

<fieldset class="Form__Element Form__CustomElement Form__Element__MinMaxRange" data-epiforms-element-name="@formElement.ElementName">
    <legend class="Form__Element__Caption">@Model.Label</legend>

    <table border="0">
        <tbody>
            <tr>
                <td><label for="@(formElement.Guid)_min">@Html.Translate("Form.Element.MinMaxRange.Min")</label> <input id="@(formElement.Guid)_min" type="number" data-rangepart="min" name="@(formElement.ElementName)_min" value="@(submittedValue == null ? string.Empty : submittedValue.Item1.ToString())" /></td>
                <td><label for="@(formElement.Guid)_max">@Html.Translate("Form.Element.MinMaxRange.Min")</label> <input id="@(formElement.Guid)_max" type="number" data-rangepart="max" name="@(formElement.ElementName)_max" value="@(submittedValue == null ? string.Empty : submittedValue.Item2.ToString())" /></td>
            </tr>
        </tbody>
    </table>

    <div role="alert" aria-live="polite" data-epiforms-linked-name="@formElement.ElementName" class="Form__Element__ValidationError" style="@(string.IsNullOrEmpty(errorMessage) ? " display:none" : "")">@errorMessage</div>
</fieldset>

In this, we have a number of components. We have the label, we have the min and max inputs (notice how the name attribute lines up with what we look for when doing model binding for non-JS clients and the data-rangepart lines up with what we look for when doing JS model binding), and we have an area to show the error message.

With all these components, we now have a fully working custom form element.

If you want to put this all together, you can see it on GitHub.