Friday, June 15, 2018

FIFA World Cup part 2 of 4 - Sitecore Forms

Part 2 in a series of 4
  1. Introduction
  2. Sitecore Forms
  3. Marketing Automation
  4. XConnect
Call me crazy, but for a successful office pool, having contestants is among my top priorities.
I made the signup form using Sitecore Forms. The signup itself could easily be made by the built-in components, just create "Radio button list" with [1, x, 2] options for each match.
However, being a developer, I'll be damned if I spend around 20 minutes setting up this individually for each of the 48 matches - Let's spend hours programming it instead!



Creating a custom world cup group field

Following Sitecore's excellent documentation I made a field to display an entire group at a time, using the setup from part 1 as datasource. And with inspiration from Tomsz Juranek's blog I made it look a bit nicer.

The implementation of the field looks like this.

C#
using Newtonsoft.Json;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.ExperienceForms.Mvc.Models.Fields;
using System;
using System.Collections.Generic;
using System.Linq;

namespace WorldCup.Models.Fields
{
    [Serializable]
    public class WorldCupGroupModel : InputViewModel<List<WorldCupMatch>>
    {
        private const string WorldCupFolder = "WorldCupGroupFolder";
        private const string MatchTemplate = "Match";
        private List<WorldCupMatch> _value;

        /// <summary>
        /// The chosen world cup group to be used for finding matches
        /// </summary>
        public string FolderId { get; set; }
        
        /// <summary>
        /// The possible outcomes of a match for iteration in the view
        /// </summary>
        public List<string> PossibleOutcomes = new List<string> { "1", "x", "2" };

        protected override void InitItemProperties(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            base.InitItemProperties(item);
            var folder = item.Database.GetItem(item[WorldCupFolder]);
            FolderId = folder.ID.ToString();
            this.Value = GetMatches(folder).ToList();
        }

        protected override void UpdateItemFields(Item item)
        {
            Assert.ArgumentNotNull(item, "item");
            base.UpdateItemFields(item);

            item.Fields[WorldCupFolder].SetValue(FolderId, true);
        }

        /// <summary>
        /// Gets the matches from the group, or returns a default "not set" match if a group isn't chosen
        /// </summary>
        private IEnumerable<WorldCupMatch> GetMatches(Item folder)
        {
            if (folder != null)
            {
                foreach (Item match in folder.Children)
                {
                    if (match.TemplateName == MatchTemplate)
                    {
                        yield return new WorldCupMatch { MatchName = match.Name, Result = PossibleOutcomes[1] };
                    }
                }
            }
            else
            {
                yield return new WorldCupMatch { MatchName = "Group Folder not set", Result = PossibleOutcomes[1] };
            }
        }
    }

    [Serializable]
    public class WorldCupMatch
    {
        public string MatchName { get; set; }
        public string Result { get; set; }

        /// <summary>
        /// This is made to enable the normal Save Action in Sitecore forms to write down the data
        /// </summary>
        public override string ToString()
        {
            return JsonConvert.SerializeObject(this);
        }
    }
}

View:
@using Sitecore.ExperienceForms.Mvc.Html
@model WorldCup.Models.Fields.WorldCupGroupModel

<h2 class="col-lg-12">@Model.Title</h2>
@for (int i = 0;i < Model.Value.Count;i++)
{
    var match = Model.Value[i];
    <div class="form-group col-md-4 col-sm-6 col-xs-12">
        <label class="@Model.LabelCssClass">@match.MatchName</label><br />
        <input type="hidden" name="@Html.NameFor(m => m.Value[i].MatchName)" value="@match.MatchName" />
        <div class="btn-group btn-group-toggle" data-toggle="buttons">
        @foreach (var possibleOutcome in Model.PossibleOutcomes)
        {
            <label class="btn btn-primary @if (match.Result == possibleOutcome) {<text>active</text>}">
                <input type="radio" autocomplete="off" name="@Html.NameFor(m => m.Value[i].Result)" id="@Html.NameFor(m => m.Value[i].Result)@possibleOutcome" @if (match.Result == possibleOutcome) { <text> checked</text>} value="@possibleOutcome" data-sc-tracking="@Model.IsTrackingEnabled" data-sc-field-name="@match.MatchName" />@possibleOutcome
            </label>
        }
        </div>
    </div>
}

Creating a save action to save WorldCupGroupModels into a custom contact facet.

Once we start getting data, we'll need to save it in an actionable format. I decided to create a custom contact facet.
Since the data is basically just key/value pairs of matchnames and predicted outcomes, a Dictionary should do fine for this. I implemented this as described in Sitecore's documentation. Then I created the custom save action as follows:

using Sitecore.Analytics;
using Sitecore.ExperienceForms.Models;
using Sitecore.ExperienceForms.Processing;
using Sitecore.ExperienceForms.Processing.Actions;
using Sitecore.XConnect;
using Sitecore.XConnect.Client;
using Sitecore.XConnect.Client.Configuration;
using System;
using System.Linq;
using WorldCup.Models.Fields;
using WorldCup.XConnect.Facets;

namespace WorldCup.Forms.Actions
{
    public class SaveBets : SubmitActionBase<string>
    {
        public SaveBets(ISubmitActionData submitActionData) : base(submitActionData)
        {
        }

        protected override bool Execute(string data, FormSubmitContext formSubmitContext)
        {
            try
            {
                if (!Tracker.IsActive)
                {
                    Tracker.StartTracking();
                }
                
                using (XConnectClient client = SitecoreXConnectClientConfiguration.GetClient())
                {
                    var reference = Tracker.Current.Contact.Identifiers.FirstOrDefault(i => i.Source == "WorldCupForm");
                    var contact = client.Get(new IdentifiedContactReference("WorldCupForm", reference.Identifier), new ExpandOptions(WorldCupBets.DefaultFacetKey));

                    var bets = contact.GetFacet<WorldCupBets>() ?? new WorldCupBets();

                    foreach (var group in formSubmitContext.Fields.OfType<WorldCupGroupModel>())
                    {
                        var value = group.Value;
                        if (value != null)
                        {
                            foreach (var match in value)
                            {
                                if (bets.Matches.ContainsKey(match.MatchName))
                                {
                                    bets.Matches[match.MatchName] = match.Result;
                                }
                                else
                                {
                                    bets.Matches.Add(match.MatchName, match.Result);
                                }
                            }
                        }
                    }

                    client.SetFacet(contact, WorldCupBets.DefaultFacetKey, bets);
                    client.Submit();
                }
            }
            catch (Exception)
            {
                return false;
            }
            return true;
        }

        protected override bool TryParse(string value, out string target)
        {
            target = string.Empty;
            return true;
        }
    }
}

Since I'm using my own custom field value, it's easy for me to just work on all the fields of the type WorldCupGroupModel.

Creating a save action to identify the contact

We want our contacts to be identified. For this we need to trigger visitor identification once we get their email. Again, Sitecore wrote an great guide on how to do this. I won't bore you with further details.

Putting it all together

Now that we have all the components, we just need to drag and drop it all into the form. The first page is the identification. After writing your personal details, there's a next button which immediatly identifies the contact, and leads you on in the flow.

Next comes the 8 group pages. Each with their own WorldCupGroup field, and a previous and next button for navigating the flow.

Inserting a WorldCupGroup field
The last group page has a submit button in place of the next. This button implements 4 save actions:

  1. Trigger Goal
    Adding a goal to the contact allows for me to do some easy segmentation on the relevant contacts.
  2. Save Data
    Saving the data isn't needed for my purposes, but it adds some nice traceability for little effort.
    Note that the built in Save Data works fine with field value models that inherit from IList, however it just uses the ToString() method on each child element, which is why I overloaded this in the WorldCupMatch class above.
  3. Save the predictions to the contact using my custom save handler.
  4. Redirect to simple "form completed" page, to end the flow.
And that's it - now I just place the form onto a page and alert my colleagues of the new World Cup office pool signup, using Sitecore 9 Forms.