Umbraco Forms – Creating Repeating Fields

On a recent project I needed to make a form field that would take a group of Umbraco Form fields and repeat them as many times as needed. Unfortunately after some serious Googling I wasn’t able to find a solution, so after two days of trying every combination of “ConvertToRecord” and “ProcessSubmittedValue” here’s my solution to building a group of repeating Umbraco Form fields.

Creating a new field type

So let’s start by creating a custom Umbraco Forms Field Type which will add a button that can be pressed to duplicate your field group. We’re going to be storing and submitting all our data in a hidden text field.

The first step is to create a class that inherits Umbraco.Forms.Core.FieldType. I’m calling my custom field “RepeatableFields” but feel free to call it whatever you wish.

The first method in the class is pretty standard, we’re just setting up the details of our new field. I had to make my field the DataType of Longstring, because the value we’ll be passing in will easily get too long for the standard String type.

The second method will get the data we’ve created with our repeated fields, deserialize it, and store it in the Umbraco Backoffice so you can view the form data through the CMS. I’m storing the name of the field with the number of repetitions appended to the end and the actual data for that field.

    public class RepeatableFields : Umbraco.Forms.Core.FieldType
    {
        public RepeatableFields()
        {
            this.Id = new Guid("19adc2ed-ed66-4a4c-8fe2-30d897a0ee30");
            this.Name = "RepeatableFields";
            this.Description = "Creates a button that will repeat the form group and it's fields";
            this.Icon = "icon-indent";
            this.DataType = FieldDataType.LongString;
            this.SortOrder = 12;
            this.SupportsRegex = true;
        }

        public override IEnumerable<object> ProcessSubmittedValue(Field field, IEnumerable<object> postedValues, HttpContextBase context)
        {
            List<object> vals = new List<object>();

            if (postedValues.Count() > 0)
            {
                dynamic repeatedFields = JsonConvert.DeserializeObject(postedValues.First().ToString());

                if (repeatedFields != null)
                {
                    int itemCount = 1;

                    foreach (var currField in repeatedFields)
                    {
                        string currentFields = currField.ToString();

                        vals.Add("\n " + field.Caption + " " + itemCount);
                        vals.Add(currentFields);

                        itemCount++;
                    }
                }
            }

            return vals;
        }
    }

Now set up your fields partial view in Views\Partials\Forms\Fieldtypes\FieldType.RepeatableFields.cshtml:

@model Umbraco.Forms.Mvc.Models.FieldViewModel
<input type="hidden" name="@Model.Name" id="@Model.Id" class="hidden" value="@Model.ValueAsHtmlString"/>

And finally the backoffice view in App_Plugins\UmbracoForms\Backoffice\Common\FieldTypes\RepeatableFields.html:

<div class="umb-forms-hidden">
    {{field.caption}}
</div>

The repeatable field view

The next thing on our list of things to do is create a template to render the frontend of our new Repeatable Fields field, this will just be a button that will trigger some Javascript and a hidden text field which we will put all of our repeated values into in the form of a serialized array. I’ve created this view in the default theme folder: Views\Partials\Forms\Themes\default\FieldTypes\FieldType.RepeatableFields.cshtml

@model Umbraco.Forms.Mvc.Models.FieldViewModel

<div class="js-repeatable-field-container">
<a href="#" class="btn btn-primary js-repeat-fields-trigger" data-duplicate-target=".js-form-container">@Model.Caption</a>
<input type="hidden" name="@Model.Name" id="@Model.Id" data-field-name="null" class="hidden js-repeat-fields-input" value="@Model.ValueAsHtmlString" />
</div>

Adding the all important class

The act of duplicating the fields is handled by Javascript so in order for it to know what to duplicate we’re going to need to add a class to tell us. This class should go on the div that wraps around a group of fields. The class I’m adding is “js-form-container” and it goes on the same div as the “umbraco-forms-container” class. Here’s a quick look inside my form.cshtml file so you can see where I’ve added the class.

@foreach (var c in fs.Containers)
{
    <div class="umbraco-forms-container js-form-container @("col-12 col-md-" + c.Width)">

    @foreach (FieldViewModel f in c.Fields)
    {
        bool hidden = f.HasCondition && f.ConditionActionType == FieldConditionActionType.Show;
        var field = form.AllFields.Where(currField => currField.Id == Guid.Parse(f.Id));

        <div class="@Html.GetFormFieldWrapperClass(f.FieldTypeName) @f.CssClass" @{ if (hidden) { <text> style="display: none" </text> } }>

        @if (!f.HideLabel && f.FieldTypeName != "RepeatableFields")
        {
            <label for="@f.Id" class="umbraco-forms-label">
            @f.Caption @if (f.ShowIndicator)
            {
                <span class="umbraco-forms-indicator">@Model.Indicator</span>
            }
            </label>
        }

        @if (!string.IsNullOrEmpty(f.ToolTip))
        {
            <span class="umbraco-forms-tooltip help-block">@f.ToolTip</span>
        }

        <div class="form-group js-form-group" data-field-name="@field.First().Alias">
        @Html.Partial(FormThemeResolver.GetFieldView(Model, f), f)

        @if (Model.ShowFieldValidaton)
        {
            @Html.ValidationMessage(f.Id)
        }
        </div>

    </div>
    }

</div>
}

Data Attributes

The next thing we need to do is to add the following data attribute to any field you’re going to want to duplicate:

data-field-name="@Model.Caption"

This data attribute is used to get the name of the field when we’re storing our data in the CMS so make sure you add it in to any inputs you wish to duplicate for example my text field template looks like this once I’ve added in the data attribute:

<input type="text" 
       name="@Model.Name" 
       id="@Model.Id" 
       class="@Html.GetFormFieldClass(Model.FieldTypeName) form-control custom-text-input" 
       value="@Model.ValueAsHtmlString" 
       data-field-name="@Model.Caption"
       maxlength="500"
       @{if(string.IsNullOrEmpty(Model.PlaceholderText) == false){<text>placeholder="@Model.PlaceholderText"</text>}}
       @{if(Model.Mandatory || Model.Validate){<text>data-val="true"</text>}}
       @{if (Model.Mandatory) {<text> data-val-required="@Model.RequiredErrorMessage"</text>}}
       @{if (Model.Validate) {<text> data-val-regex="@Model.InvalidErrorMessage" data-regex="@Html.Raw(Model.Regex)"</text>}}
/>

The JS

We’re almost there! All that’s left to do is work a bit of Javascript magic. Our JS will create a duplicate set of fields, empty them out, then watch for any changes to those new fields. When a change is detected it will serialize all the data and put it into the hidden text field.

let repeatableTrigger = '.js-repeat-fields-trigger';
let fieldWrapper = '.js-repeatable-field-container';
let classToWatch = 'js-repeat-form-container';
let inputField = '.js-repeat-fields-input'
let updateCount = 0;

$(document).on("click", repeatableTrigger, function (e) {
    e.preventDefault();

    // Duplicate container
    let toDuplicate = $(this).data('duplicate-target');
    toDuplicate = $(this).parents(toDuplicate);

    let newContainer = toDuplicate.clone().insertAfter(toDuplicate);

    // Remove original button and hidden field
    $(this).parents(fieldWrapper).remove();

    // Add class to watch for input updates
    newContainer.addClass(classToWatch);

    // Clear new inputs
    newContainer.find('input').val('');

    // Stop radio button unsetting on duplicate
    let activeButtons = newContainer.find('.js-custom-radio-buttons input:checked');
    if (activeButtons.length > 0) {
        activeButtons.each(function () {
            // get id of the active radio button input
            let activeId = $(this).attr('id');
            // find the first one of this id and set it to active
            let firstInput = $('#' + activeId).first();
            firstInput.prop("checked", true);
        });
    }

    // Update radio buttons IDs
    let radioBtnContainers = newContainer.find('.js-custom-radio-buttons');
    if (radioBtnContainers.length > 0) {
        radioBtnContainers.each(function () {
        // Update labels and input ids
        let currLabels = $(this).find('label');
        if (currLabels.length > 0) {
            currLabels.each(function () {
                let currId = $(this).attr('for');
                let newId = currId + "_" + updateCount;
                let associatedInput = newContainer.find('#' + currId);

                $(this).attr('for', newId);
                associatedInput.attr('id', newId);
                updateCount++;
            });
        }
        // Update input names
        let currName = $(this).attr('id');
        let newName = currName + "_" + updateCount;
        let inputsToUpdate = newContainer.find('#' + currName).find('input');
        inputsToUpdate.attr('name', newName);
        $(this).attr('id', newName);
    });
    }
});

$(document).on("change", '.' + classToWatch + " input", function (e) {
    //create json object
    var val = [];

    // Foreach container
    $('.' + classToWatch).each(function (index) {
    let currentInputs = $(this).find('input');

    let row = {};
    // Foreach input inside the container
    currentInputs.each(function () {
    let currName = $(this).data('field-name');
    let currValue = $(this).val();

    if (currName != null) {
        row[currName] = currValue;
    }
});

val.push(row);
});

$(inputField).val(JSON.stringify(val));
});

Related posts