EPiServer Forms 3 - Custom Actors

Introduction

Actors by definition perform some server-side action after a user submits a form. Standard Forms installation comes with two pre-defined Actors which can be found in EPiServer.Forms.Implementation.Actors namespace: CallWebhookAfterSubmissionActor and SendEmailAfterSubmissionActor.

 episerver cms forms

In this article, you will see how to create a custom Actor that executes some server-side logic when a user submits a Form.

Form

Before we get started with Actors, we need to create a Form.

I've created a very simple contact form with standard fields (First name, Last name, Email and Message) and a submit button.

This is how the form looks in On-Page Editing view:

episerver cms forms

And this is how it looks in All Properties view:

episerver cms forms

Actor

Next thing we want to do is to create a custom actor that saves form data into a CRM system.

To create an Actor, we need to create a class that inherits PostSubmissionActorBase base class and override the Run method:

public class CustomActor : PostSubmissionActorBase
{
    public override object Run(object input)
    {
        var submittedData = SubmissionData.Data;

        return string.Empty;
    }
}

Next, we need to submit a form with some test data:

epserver cms forms

If we inspect submittedData in debug mode, we will get this:

episerver cms forms

As we can see, our "First Name" "Last Name" "Email" and "Message" fields have been submitted as __field_283, __field_284, __field_285 and __field_286.

So how do we get developer-friendly names?

We can use SubmissionFirendlyNameInfos property that contains a list of ElementIds and field names that are defined in edit mode. If we inspect this property in debug mode, we will get something like this:

episerver cms forms

To get submitted data in a human-friendly format, we have to combine data from SubmissionData.Data and SubmissionFriendlyNameInfos properties. We can do this either manually or call TransformSubmissionDataWithFriendlyName method from IFormDataRepository.

Since Actors don't support constructor injection, we need to use property injection for IFormDataRepository.

Here's the code:

public class CustomActor : PostSubmissionActorBase
{
    private readonly Injected<IFormDataRepository> _formDataRepository;

    public override object Run(object input)
    {
            var submittedData = _formDataRepository.Service.TransformSubmissionDataWithFriendlyName(
                SubmissionData.Data, SubmissionFriendlyNameInfos, true).ToList();

            return string.Empty;
    }
}

 If we now inspect submittedData in debug mode, we will get something like this:

episerver cms forms

CRM integration

Making integration with 3rd party systems can be quite challenging. Developers usually need to send data as strongly-typed objects, while editors have a possibility to make forms as dynamic as possible.

As a developer, I would use custom forms when implementing integration with 3rd party systems (CRMs, payment providers, etc.). How those forms should look like, is usually "set in stone". We need to make sure that everything is covered by unit tests, etc. If developers expect to get a field called FirstName and editors rename that field to First Name, integration will stop working, etc.

On the other hand, if need to make something very dynamic and not business critical, like surveys, for example, Forms 3 can be an excellent choice.

Back to the integration...

Let's say this is the object we need to send to the CRM system.

public class MessageDto
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string Message { get; set; }
}

We can use IFormDataRepository to map submitted data to MessageDto. But to make things a bit more fun, I've decorated my properties with a custom attribute and used some reflection for mapping.

DTO that we want to send to the CRM:

public class MessageDto
{
    [FriendlyName("First name")]
    public string FirstName { get; set; }

    [FriendlyName("Last name")]
    public string LastName { get; set; }

    public string Email { get; set; }

    public string Message { get; set; }
}

Our properties are decorated with FriendlyName attribute where Name is a field name from edit mode:

[AttributeUsage(AttributeTargets.Property)]
public class FriendlyNameAttribute : Attribute
{
    public string Name { get; }

    public FriendlyNameAttribute(string name)
    {
        Name = name;
    }
}

ObjectMapper will check if properties are decorated with FriendlyName attribute. If not, it will match values by property name:

public static class ObjectMapper
{
    public static T ToObject<T>(this List<KeyValuePair<string, object>> source)
        where T : class, new()
    {
        var newObject = new T();
        var newObjectType = newObject.GetType();

        var properties = newObjectType.GetProperties();
        foreach (var property in properties)
        {
            string friendlyName = GetFriendlyName(property);
            string key = !string.IsNullOrWhiteSpace(friendlyName)
                ? friendlyName
                : property.Name;

            SetPropertyValue(newObject, property, key, source);
        }

        return newObject;
    }

    private static string GetFriendlyName(PropertyInfo property)
    {
        var customAttributes = property.GetCustomAttributes(true);

        foreach (var customAttribute in customAttributes)
        {
            var formFieldNameAttribute = customAttribute as FriendlyNameAttribute;
            if (formFieldNameAttribute != null)
            {
                return formFieldNameAttribute.Name;
            }
        }

        return string.Empty;
    }

    private static void SetPropertyValue<T>(
        T targetObject,
        PropertyInfo property,
        string key,
        IReadOnlyCollection<KeyValuePair<string, object>> source)
    {
        if (source.Any(x => x.Key == key))
        {
            property?.SetValue(targetObject, source.First(x => x.Key == key).Value, null);
        }
    }
}

And finally, our CustomActor:

public class CustomActor : PostSubmissionActorBase
{
    private readonly Injected<IFormDataRepository> _formDataRepository;

    public override object Run(object input)
    {
            var submittedData = _formDataRepository.Service.TransformSubmissionDataWithFriendlyName(
                SubmissionData.Data, SubmissionFriendlyNameInfos, true).ToList();

            var messageDto = submittedData.ToObject<MessageDto>();

            // TODO: send messageDto to the CRM


            return string.Empty;
    }
}

Note: I haven't tested ObjectMapper in production, use it at your own risk.

If we now inspect messageDto in debug mode, we should get something like this:

episerver cms forms

The UI

The code inside CustomActor / Run method will be executed on form submit for every single form. But what if editor creates another form type (survey form, for example) that shouldn't send data to the CRM system?

If we again take a look at the built-in Actors, CallWebhookAfterSubmissionActor and SendEmailAfterSubmissionActor, we will notice that each one of them has a UI which allows editors to make certain configurations.

We will create a KeyValuePair property for CustomActor so that editors can control the execution of CustomActor / Run method.

Model for CustomActor:

[Serializable]
public class KeyValuePairModel : IPostSubmissionActorModel, ICloneable
{
    [Display(Order = 100)]
    public virtual string Key { get; set; }

    [Display(Order = 200)]
    public virtual string Value { get; set; }

    public object Clone()
    {
        return new KeyValuePairModel
        {
            Key = Key,
            Value = Value
        };
    }
}

Custom property:

[EditorHint(nameof(KeyValuePairProperty))]
[PropertyDefinitionTypePlugIn]
public class KeyValuePairProperty : PropertyGenericList<KeyValuePairModel>
{
}

Editor descriptor for edit mode:

[EditorDescriptorRegistration(
    TargetType = typeof(IEnumerable<KeyValuePairModel>),
    UIHint = nameof(KeyValuePairProperty))]
public class KeyValuePairEditorDescriptor : CollectionEditorDescriptor<KeyValuePairModel>
{
    public KeyValuePairEditorDescriptor()
    {
        ClientEditingClass = "epi-forms/contentediting/editors/CollectionEditor";
    }
}

To assign KeyValuePairModel to CustomActor, we need to implement IUIPropertyCustomCollection interface:

public class CustomActor : PostSubmissionActorBase, IUIPropertyCustomCollection
{
    private readonly Injected<IFormDataRepository> _formDataRepository;

    public override object Run(object input)
    {
            var submittedData = _formDataRepository.Service.TransformSubmissionDataWithFriendlyName(
                SubmissionData.Data, SubmissionFriendlyNameInfos, true).ToList();

            var messageDto = submittedData.ToObject<MessageDto>();

            // TODO: send messageDto to the CRM


            return string.Empty;
    }

    public virtual Type PropertyType => typeof(KeyValuePairProperty);
}

If we now open the form in edit mode, All Properties views, and navigate to Settings tab, we should get a KeyValuePairProperty where we can set custom values:

episerver cms forms

This is how we can read the values from CustomActor:

public class CustomActor : PostSubmissionActorBase, IUIPropertyCustomCollection
{
    private readonly Injected<IFormDataRepository> _formDataRepository;

    public override object Run(object input)
    {
        // skip execution if KeyValuePair is empty
        var model = Model as List<KeyValuePairModel>;
        if (model == null || !model.Any())
        {
            return string.Empty;
        }

        // skip execution if "runCustomActor" is not set to "true"
        var pair = model.FirstOrDefault(x => x.Key == "runCustomActor");
        if (pair == null || pair.Value != "true")
        {
            return string.Empty;
        }

        var submittedData = _formDataRepository.Service.TransformSubmissionDataWithFriendlyName(
            SubmissionData.Data, SubmissionFriendlyNameInfos, true).ToList();

        var messageDto = submittedData.ToObject<MessageDto>();

        // TODO: send messageDto to the CRM


        return string.Empty;
    }

    public virtual Type PropertyType => typeof(KeyValuePairProperty);
}

Localization

If you take a closer look at CustomActor in edit mode, you'll notice that Display text is set to [Missing text ' / episerver / forms / submissionactors / AlloyTech.Forms3.Actors.CustomActor / displayname' for 'English (United States)']

episerver cms forms

To change that text, I tried to decorate CustomActor with DisplayName attribute and override Name and EditViewFriendlyTitle properties, but nothing seemed to work. The only thing that worked was to create an XML file like this:

<?xml version="1.0" encoding="utf-8" ?>
<languages>
  <language name="English" id="en">
    <episerver>
      <forms>
        <submissionactors>
          <AlloyTech.Forms3.Actors.CustomActor>
            <displayname>Custom Actor</displayname>
          </AlloyTech.Forms3.Actors.CustomActor>
        </submissionactors>
      </forms>
    </episerver>
  </language>
</languages>

I don't like the idea of having a namespace as a part of XPath, so I'm hoping this is something EPiServer will fix soon.

Refactoring

Another thing I found problematic when working with custom Actors is refactoring. Let's say we want to rename CustomActor to MyCustomActor. If we open edit mode, we should get something like this:

episerver cms forms

If we now open admin mode / Content Type / Block Types / [Forms] Form Container, we will notice that every Actor (built-in ones, and custom ones) have From code value set to false.

episerver cms forms

That's bad because we don't know which Actors don't match our codebase anymore. And what's more important, we cannot create a MigrationStep to rename Actors from the code:

public class MyMigrationStep : MigrationStep
{
    public override void AddChanges()
    {
        ContentType(nameof(FormContainerBlock))
            .Property("MyCustomActor")
            .UsedToBeNamed("CustomActor");
    }
}

If we then try to delete the old property from the admin interface, we will be prompted to update Sort index first.

episerver cms forms

Summary

EpiServer Forms3 allows editors to create dynamic forms. Developers can create custom Actors that execute some server-side logic when a user submits a Form. Actors can be configured with a custom property.

At the time of writing this article, there are some small glitches in Forms 3 (Localization and Refactoring) that should be fixed by Episerver.

Source code used in this article can be found on Github: https://github.com/dejancaric/EPiServer-Forms-3---Custom-Actor

comments powered by Disqus