Loading a list of products with GraphQL in BigCommerce widgets

Before we begin, the completed solution is available on GitHub.

BigCommerce offers the ability to create reusable components in the page builder called “widgets”. It comes with a few out of the box but also comes with the ability to create your own. They can be created either by pushing them directly via their API or using their widget builder. For the purposes of this demo, I did everything in widget builder.

One of the features BigCommerce offers is the ability to define custom “schema” for widgets, which is a fancy way to say options that can be edited for each instance of the widget. They can be simple, like the title and background image to use for a banner, but can also get more complex and include things like arrays of properties. One notable type is the “product ID” type, which presents itself to your widget as the ID of a product but in the admin panel is a tool that lets you search products in your store, making it very easy to insert a product teaser or something like that.

Additionally, to fetch data from the store, you can include GraphQL in your widget. The GraphQL query will use data supplied from the admin panel (with the structure defined by the schema) and provide the necessary data for the template. For example specific example, you can include a product ID selector in the admin panel then use GraphQL to get the name, image, link, etc. for the product. BigCommerce provides an example of how to do that.

However, one thing they do not seem to document is how to make the same thing work for multiple products. Sure, you could just define a bunch of separate individual products, but that’s going to get really obnoxious to work with and maintain. One alternative is use an array of products, which is better, but it turns out there’s an even better way to do it: product set.

First, we need to define our schema. In widget builder, create the following schema.json file:

[
	{
		"type": "tab",
		"label": "Content",
		"sections": [
			{
				"label": "Products",
				"settings": [
					{
						"type": "productSet",
						"label": "Product Set",
						"id": "product",
						"entryLabel": "Product",
						"default": {
							"type": "manual",
							"value": []
						}
					}
				],
				"typeMeta": {
					"type": "setSection"
				}
			}
		]
	}
]

This defines an attribute of type “productSet” (i.e. a list of products”) with the ID “product”.

Next, we need to pass the values from that selection into GraphQL. We can further edit the schema and add another element of type “graphQl” that specifies the mapping from schema parameters to GraphQL parameters. We do that in the text highlighted below.

[
	{
		"type": "hidden",
		"settings": [
			{
				"type": "graphQl",
				"id": "graphQueries",
				"typeMeta": {
					"mappings": {
						"productIds": {
							"reads": "product.value.*.productId",
							"type": "Int!"
						}
					}
				}
			}
		]
	},
	{
		"type": "tab",
		"label": "Content",
		"sections": [
			{
				"label": "Products",
				"settings": [
					{
						"type": "productSet",
						"label": "Product Set",
						"id": "product",
						"entryLabel": "Product",
						"default": {
							"type": "manual",
							"value": []
						}
					}
				],
				"typeMeta": {
					"type": "setSection"
				}
			}
		]
	}
]

Next, we need to write the GraphQL query. To do that, create the file query.graphql:

query productsById($productIds: [Int!]) { 
  site { 
    products(entityIds: $productIds) { 
      edges {
        node {
          entityId
          sku
          name
          path
        }
      }
    } 
  } 
}

This query looks up the ID, SKU, URL path, and name of each product for any product in the $productIds parameter (which we passed in via GraphQL mapping). Important to note here is the type: $productIds is an array of non-nullable ints ([Int!]), while in the mapping, we specified the type for each individual element to be a non-nullable int (Int!), which was automatically aggregated to be an array by the widget utility.

Finally, we can actually use this data in the template. In this very limited example, you can see a list of the product names by creating widget.html with the following content:

{{#each _.data.site.products.edges}}
<h3><a href="{{node.path}}">{{node.name}}</a></h3>
<div>SKU: {{node.sku}}</div>
{{/each}}

With that, we have rendered a list of products! See the final result below.

The final result

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.

Saving modified DOM state in a form with the back button

A common problem encountered with building forms in JS is that when leaving a page and coming back, form state is preserved… sort of. If you have a fixed form with no dynamic behavior, everything will be fine. However, if you have something like this (please excuse my barebones JS and markup, this is to simplify the demo):

<script type="text/javascript">
function addRow() {	
	const newRow = document.createElement('ul');
	const newInput = document.createElement('input');
	newInput.type = 'text';
	newInput.name = 'input' + document.getElementById('inputs').childNodes.length;
	newRow.appendChild(newInput);
	inputs.appendChild(newRow);
}
</script>

<input type="text" name="static" />

<ul id="inputs">
</ul>

<button onclick="addRow();">Add Row</button>

…then if you add some rows, leave the page, and then come back, the static input will still have the value you typed in, but the rows you added are gone.

Why do these rows disappear? The reason is because they were added with JS after the page was created. Any DOM manipulation that happens after a page is created will be lost when you leave the page and then come back with the back button.

So, now, what can we do about it? The answer is simple. We can use a simplified version of the memento design pattern. The fundamental idea of the memento pattern is that we store the state in some form that can be retrieved later. There are three important questions to use the memento design pattern effectively, which will all be taken care of in the next paragraphs: 1. How do we convert the state into a storeable object?, 2. Where do we store the state?, and 3. How do we restore the state?

Let’s take these one at a time:

1. How do we convert the state into a storeable object? To store the state effectively, we need a format to store it in and a way to trigger storing it. JavaScript conveniently provides JSON, which is all we need in this case. Since all we have is a list of strings, in this example we can just store it as a JSON-encoded array of strings. For example, if we have two inputs with “Value 1” and “Value 2”, we can just store ["Value 1", "Value 2"].
Now that we have a format to store it in, we need a way to trigger storing it. Thankfully there is a window event, onbeforeunload, that we can leverage. Basically the event fires when the user leaves the page but before actually leaving. In this case, we can add the following JS to generate JS of the state:

window.onbeforeunload = function() {
	const inputWrapper = document.getElementById('inputs');
	const values = [...inputWrapper.querySelectorAll('input')].map(input => input.value)
	const inputListMemento = JSON.stringify(values);
	console.log(inputListMemento);
	
	// TODO: save this somewhere we can see again when reloading the page
}

Now, if we leave the page after entering some data, we will see an array with all the input values logged to the console (make sure to set console logging to persist between pages). Now, this brings us to the next step to solve that TODO:

2. Where do we store the state? At the beginning of the post I explained that input values in inputs present when the page was first loaded will persist. Thankfully, this also includes input values dynamically populated, even to hidden inputs. We can leverage this to store the state. All we have to do is add a hidden input and store the value there.

<script type="text/javascript">
function addRow() {	
	const newRow = document.createElement('ul');
	const newInput = document.createElement('input');
	newInput.type = 'text';
	newInput.name = 'input' + document.getElementById('inputs').childNodes.length;
	newRow.appendChild(newInput);
	inputs.appendChild(newRow);
}

window.onbeforeunload = function() {
	const inputWrapper = document.getElementById('inputs');
	const values = [...inputWrapper.querySelectorAll('input')].map(input => input.value)
	const inputListMemento = JSON.stringify(values);
	
	document.getElementById('inputListMementoHolder').value = inputListMemento;
}
</script>

<input type="text" name="static" />
<input type="hidden" id="inputListMementoHolder" />

<ul id="inputs">
</ul>

<button onclick="addRow();">Add Row</button>

Then if we leave the page and come back, we can verify in your console that the hidden input you just created does indeed store the previous value of the inputs in the list by running the following code:
document.getElementById('inputListMementoHolder').value
Now that we have the value stored in the memento, we finally need to restore it, which brings us to the final part.

3. How do we restore the state? This we can do at page load by reading the value of the input. JS thankfully provides the window.onload event we can use. First, however, we need to modify our addRow function to take an argument for the value:

function addRow(value = '') {	
	const newRow = document.createElement('ul');
	const newInput = document.createElement('input');
	newInput.type = 'text';
	newInput.name = 'input' + document.getElementById('inputs').childNodes.length;
	newInput.value = value;
	newRow.appendChild(newInput);
	inputs.appendChild(newRow);
}

Now, we can reload the state when reloading the page by adding the event to read the memento:

window.onload = function() {
	const inputListMemento = document.getElementById('inputListMementoHolder').value;
	if (inputListMemento) {
		inputListItems = JSON.parse(inputListMemento);
		inputListItems.forEach(item => addRow(item));
	}
}

With this, you should see that when leaving the page and coming back via the back button, we do not lose any data from our list input. For a recap, here’s the final piece of code:

<script type="text/javascript">
function addRow(value = '') {	
	const newRow = document.createElement('ul');
	const newInput = document.createElement('input');
	newInput.type = 'text';
	newInput.name = 'input' + document.getElementById('inputs').childNodes.length;
	newInput.value = value;
	newRow.appendChild(newInput);
	inputs.appendChild(newRow);
}

window.onbeforeunload = function() {
	const inputWrapper = document.getElementById('inputs');
	const values = [...inputWrapper.querySelectorAll('input')].map(input => input.value)
	const inputListMemento = JSON.stringify(values);
	
	document.getElementById('inputListMementoHolder').value = inputListMemento;
}

window.onload = function() {
	const inputListMemento = document.getElementById('inputListMementoHolder').value;
	if (inputListMemento) {
		inputListItems = JSON.parse(inputListMemento);
		inputListItems.forEach(item => addRow(item));
	}
}
</script>

<input type="text" name="static" />
<input type="hidden" id="inputListMementoHolder" />

<ul id="inputs">
</ul>

<button onclick="addRow();">Add Row</button>

Now we can preserve the state of a dynamic form even when leaving the page and returning later.