Using custom forms in an EPiServer MVC block template

November 19, 2013

With EPiServer 7 came the introduction of two new features; the ability to divide a page into blocks or smaller reusable components and support for the ASP.NET MVC framework in templates.

If we want to combine these two features together and create a custom form placed in an MVC block template, there are a few things that we need to solve to get a properly working solution and this is what we will explore in this post.

The typical MVC form

When creating a form for a bespoke ASP.NET MVC site we would normally create a Controller with one action responsible for displaying the form, one for receiving the form data and depending on the form one for a confirmation screen. As we are keeping these actions within the same controller, it is easy to display the form again after a validation has failed.

Controllers\CustomFormController.cs

The Index action will simply display the empty form. Submit will receive the posted data as an argument, check the validation state of the posted model and re-display the form using the Index view in case the data was not valid. If the data is valid, some action takes place before redirecting the user to the Success action that will display a friendly confirmation message.

public class CustomFormController : Controller
{
    public ActionResult Index()
    {
        // Return the default view
        return View();
    }

    [HttpPost]
    public ActionResult Submit(CustomFormModel postedData)
    {
        // Check the validation state of the posted model
        if (!ModelState.IsValid)
        {
            // Return the index view with the posted data if not valid.
            return View("Index", postedData);
        }

        // Do something with the posted data
        // ...

        // Redirect to confirmation action
        return RedirectToAction("Success");
    }

    public ActionResult Success()
    {
        // Return the default view
        return View();
    }
}

Views\CustomForm\Index.cshtml

The Index view uses standard MVC code for displaying the form and validation messages. Client side validation has been excluded for the sake of brevity.

@model EPiServerSample.Models.CustomFormModel
<html>
<head>
    <title>Custom form</title>
</head>
<body>
    <h1>Custom form</h1>
    @using (Html.BeginForm("Submit", null))
    {
        @Html.ValidationSummary(true)
        <div>
            @Html.LabelFor(x => x.Name)
            @Html.EditorFor(x => x.Name)
            @Html.ValidationMessageFor(x => x.Name)
        </div>
        <div>
            @Html.LabelFor(x => x.Email)
            @Html.EditorFor(x => x.Email)
            @Html.ValidationMessageFor(x => x.Email)
        </div>
        <input type="submit" value="submit" />
    }
</body>
</html>

A first attempt at a Block Template

As a first attempt to get our form into an EPiServer context we will take the bespoke code above and convert it to a form block by changing the base class to inherit the EPiServer BlockController<T> base class instead. We will also change our Index action to return a partial view instead of a full view and remove all the mark-up surrounding the actual form from our view.

Controllers\CustomFormBlockController.cs

public class CustomFormController : BlockController<T>
{
    [ChildActionOnly]
    public override ActionResult Index(CustomFormBlock currentBlock)
    {
        // Return the default view as a Partial
        return PartialView();
    }

    [HttpPost]
    public ActionResult Submit(CustomFormBlock postedData)
    {
        // Check the validation state of the posted model
        if (!ModelState.IsValid)
        {
            // Return the index view with the posted data if not valid.
            return View("Index", postedData);
        }

        // Do something with the posted data
        // ...

        // Redirect to confirmation action
        return RedirectToAction("Success");
    }

    public ActionResult Success()
    {
        // Return the default view
        return View();
    }
}

Views\CustomFormBlock\Index.cshtml

@model EPiServerSample.Models.CustomFormBlock
<h1>@Model.Heading</h1>
@using (Html.BeginForm("Submit", null))
{
    @Html.ValidationSummary(true)
    <div>
        @Html.LabelFor(x => x.Name)
        @Html.EditorFor(x => x.Name)
        @Html.ValidationMessageFor(x => x.Name)
    </div>
    <div>
        @Html.LabelFor(x => x.Email)
        @Html.EditorFor(x => x.Email)
        @Html.ValidationMessageFor(x => x.Email)
    </div>
    <input type="submit" value="submit" />
}

When trying to run this code we will soon see that even though our form displays properly, we are no longer able to submit it properly.

A suggested solution

There are a few different ways to approach and get around this problem and to keep things simple, we’ll only go into one solution in more depth, but some other options are discussed at the end of the post.

Submit routing

First of all we need to be able to reach our Submit action. Even though the Index method is accessible and will be executed when displayed in the context of our page, the Submit action will act as an end point separate from the page context.

If you have a background in WebForms this may seem strange at first, but with MVC you want to avoid recreating the whole page and its state after a form post and instead only doing the work necessary.

So to expose our Submit action to the world, we need to ensure that there is a route registered to our controller action in the application route tables. Depending on the circumstances you can choose if you want to make this specific to this controller or rely on a generic one. We will simply register a generic route as seen below.

public class Global : EPiServer.Global
{
    // ...

    protected override void RegisterRoutes(System.Web.Routing.RouteCollection routes)
    {
        base.RegisterRoutes(routes);
        routes.MapRoute("default", "{controller}/{action}", new { action = "index" });
    }
}

Validating the data

The next thing that you may find is that your model doesn’t validate as expected. This will happen if you are using the BlockData as a model for your form as the custom EPiServer ModelBinder use for all ContentData objects doesn’t support binding posted form values to properties. This limitation can easily be sidestepped if we create a dedicated view model that that contains each of our form fields.

namespace EPiServerSample.Controllers
{
    public class CustomFormBlockController : BlockController<CustomFormBlock>
    {
        [ChildActionOnly]
        public override ActionResult Index(CustomFormBlock currentBlock)
        {
            var viewModel = new CustomFormBlockModel { Heading = currentBlock.Heading };

            return PartialView(viewModel);
        }

        [HttpPost]
        public virtual ActionResult Submit(CustomFormBlockModel postedData)
        {
            // ...
        }
    }
}

Note that although all client side validation has been omitted from the examples in this post, there should be no issues using one of the usual methods available for the ASP.NET MVC framework.

Redirecting back to the page

Since our Submit method now is executed on request level outside of the page context we cannot simply return the Index view from our Submit action in case the posted model fails the validation and we can’t just redirect to another action on our controller after a successful post.

Instead we will use an HTTP redirect to get the user back to the original page. By passing the id and language of the current page as hidden fields in our form we are able to retrieve the return page URL.

The model state will be preserved across the redirect using the TempData dictionary, a dictionary built primarily for this scenario. In its default implementation it is using a short lived session state maintained across subsequent requests so please consider this if you are using a load balanced environment where requests are distributed between machines.

Unless we want to use a separate page to display a confirmation screen following a successful post we will also add a query string parameter to distinguish between the success and the failed validation state. We could use the TempData dictionary but by using a query string we ensure that the different displays are served by different URLs, which will be more in line with general HTTP guidelines.

Supporting multiple blocks on the same page

If we want to be able to support the use of multiple blocks of the same type on the same page we need to add something that differentiates each block. Otherwise all blocks would display the same values after a post.

By posting the block id as a hidden field next to the page id we can identify our TempData and success query string and by this ensure that only the posted block form will display the validation errors or success screen.

public class CustomFormBlockController : BlockController<CustomFormBlock>
{
    private const string SuccessKey = "customFormPosted";
    private readonly UrlResolver _urlResolver;

    public CustomFormBlockController(UrlResolver urlResolver)
    {
        _urlResolver = urlResolver;
    }

    [ChildActionOnly]
    public override ActionResult Index(CustomFormBlock currentBlock)
    {
        // Get references from the current context
        var currentPageLink = ControllerContext.RequestContext.GetContentLink();
        var currentBlockLink = ((IContent)currentBlock).ContentLink;

        // Load model state from TempData
        LoadModelState(currentBlockLink);

        // Create ViewModel from our BlockData
        var viewModel = new CustomFormBlockModel
        {
            Heading = currentBlock.Heading,
            CurrentPageLink = currentPageLink,
            CurrentLanguage = ContentLanguage.PreferredCulture.Name,
            CurrentBlockLink = currentBlockLink
        };

        ContentReference postedBlock;
        // Check any reference passed to indicate success for match against the current block id
        if (ContentReference.TryParse(Request.QueryString[SuccessKey], out postedBlock) && postedBlock.CompareToIgnoreWorkID(currentBlockLink))
        {
            // If this block was posted, return Success view.
            return PartialView("Success");
        }

        // Return the default view with our view model
        return PartialView(viewModel);
    }

    [HttpPost]
    public virtual ActionResult Submit(CustomFormBlockModel postedData)
    {
        // Get the url of the current page
        var returnUrl = _urlResolver.GetUrl(postedData.CurrentPageLink);

        if (ModelState.IsValid)
        {
            // Send our posted values to some service class
            // ...

            // Append querystring with our block id to differentiate URL from default view and allow confirmation note to be displayed.
            returnUrl = UriSupport.AddQueryString(returnUrl, SuccessKey, postedData.CurrentBlockLink.ID.ToString());
        }

        // Save the model state to TempData
        SaveModelState(postedData.CurrentBlockLink);

        // Redirect back to the main page
        return Redirect(returnUrl);
    }

    /// <summary>
    /// Save ModelState to a TempData unique for our block
    /// </summary>
    protected virtual void SaveModelState(ContentReference currentBlockLink)
    {
        TempData[StateKey(currentBlockLink)] = ViewData.ModelState;
    }

    /// <summary>
    /// Load the ModelState for our block from TempData
    /// </summary>
    protected virtual void LoadModelState(ContentReference currentBlockLink)
    {
        var key = StateKey(currentBlockLink);
        var modelState = TempData[key] as ModelStateDictionary;
        if (modelState != null)
        {
            ViewData.ModelState.Merge(modelState);
            TempData.Remove(key);
        }
    }

    /// <summary>
    /// Create a StateKey unique for this block
    /// </summary>
    private static string StateKey(ContentReference currentBlockLink)
    {
        return "CustomFormBlock_" + currentBlockLink.ID;
    }
}

Security

One final caveat that may not be apparent at first is that the normal security checks will no longer be performed as the Submit end point isn’t located under a page route. This could lead to unauthorized users being able to post data to our end point.

Luckily this is easily added by utilizing the AuthorizeContent action filter already applied on the base controller that ensures that a visitor has read access to any content instances passed as parameters to an action method. By adding our BlockData and/or PageData as a parameter using the already posted content references in our form, we can just let the content model binder create the instances and the action filter authorize the content for us.

Our final solution

Below is an overview of all files in the final solution. Please note that argument and service call validations have been omitted to keep the code as simple as possible.

Models\CustomFormBlockModel.cs

public class CustomFormBlockModel
{
    public string Heading { get; set; }
    public ContentReference CurrentPageLink { get; set; }
    public string CurrentLanguage { get; set; }
    public ContentReference CurrentBlockLink { get; set; }

    // Form fields
    [Required]
    public string Name { get; set; }
    public string Email { get; set; }
}

Controllers\CustomFormBlockController.cs

public class CustomFormBlockController : BlockController<CustomFormBlock>
{
    private const string SuccessKey = "customFormPosted";
    private readonly UrlResolver _urlResolver;

    public CustomFormBlockController(UrlResolver urlResolver)
    {
        _urlResolver = urlResolver;
    }

    [ChildActionOnly]
    public override ActionResult Index(CustomFormBlock currentBlock)
    {
        // Get references from the current context
        var currentPageLink = ControllerContext.RequestContext.GetContentLink();
        var currentBlockLink = ((IContent)currentBlock).ContentLink;

        // Load model state from TempData
        LoadModelState(currentBlockLink);

        // Create ViewModel from our BlockData
        var viewModel = new CustomFormBlockModel
        {
            Heading = currentBlock.Heading,
            CurrentPageLink = currentPageLink,
            CurrentLanguage = ContentLanguage.PreferredCulture.Name,
            CurrentBlockLink = currentBlockLink
        };

        ContentReference postedBlock;
        // Check any reference passed to indicate success for match against the current block id
        if (ContentReference.TryParse(Request.QueryString[SuccessKey], out postedBlock) && postedBlock.CompareToIgnoreWorkID(currentBlockLink))
        {
            // If this block was posted, return Success view.
            return PartialView("Success");
        }

        // Return the default view with our view model
        return PartialView(viewModel);
    }

    [HttpPost]
    public virtual ActionResult Submit(CustomFormBlockModel postedData, CustomFormBlock currentBlockLink, PageData currentPageLink)
    {
        // Get the url of the current page
        var returnUrl = _urlResolver.GetUrl(postedData.CurrentPageLink);

        if (ModelState.IsValid)
        {
            // Send our posted values to some service class
            // ...

            // Append querystring with our block id to differentiate URL from default view and allow confirmation note to be displayed.
            returnUrl = UriSupport.AddQueryString(returnUrl, SuccessKey, postedData.CurrentBlockLink.ID.ToString());
        }

        // Save the model state to TempData
        SaveModelState(postedData.CurrentBlockLink);

        // Redirect back to the main page
        return Redirect(returnUrl);
    }

    /// <summary>
    /// Save ModelState to a TempData unique for our block
    /// </summary>
    protected virtual void SaveModelState(ContentReference currentBlockLink)
    {
        TempData[StateKey(currentBlockLink)] = ViewData.ModelState;
    }

    /// <summary>
    /// Load the ModelState for our block from TempData
    /// </summary>
    protected virtual void LoadModelState(ContentReference currentBlockLink)
    {
        var key = StateKey(currentBlockLink);
        var modelState = TempData[key] as ModelStateDictionary;
        if (modelState != null)
        {
            ViewData.ModelState.Merge(modelState);
            TempData.Remove(key);
        }
    }

    /// <summary>
    /// Create a StateKey unique for this block
    /// </summary>
    private static string StateKey(ContentReference currentBlockLink)
    {
        return "CustomFormBlock_" + currentBlockLink.ID;
    }
}

Views\CustomFormBlock\Index.cshtml

@model EPiServerSample.CustomFormBlockModel

<h2>@Model.Heading</h2>
@using (Html.BeginForm("Submit", null))
{
    @Html.HiddenFor(x => x.CurrentPageLink)
    @Html.HiddenFor(x => x.CurrentLanguage)
    @Html.HiddenFor(x => x.CurrentBlockLink)
    @Html.ValidationSummary()
   
    <div>
        @Html.LabelFor(x => x.Name)
        @Html.EditorFor(x => x.Name)
        @Html.ValidationMessageFor(x => x.Name)
    </div>
    <div>
        @Html.LabelFor(x => x.Email)
        @Html.EditorFor(x => x.Email)
        @Html.ValidationMessageFor(x => x.Email)
    </div>

    <input type="submit" value="submit" />
}

Other solution options

XMLHttpRequest

A different approach to the problem is to use an XMLHttpRequest (AJAX) to post the data. The end point we are posting to could still be an action on our block controller unless a separate API is preferred. If you cannot rely on client side scripting to be available, you can always combine the client side call with another method for a more robust solution.

Base Controller Action

Another option is to place the Submit action in a base controller class that all our page controllers inherit from. Using this approach we can maintain our URL and would not have to pass the page information in our form. This approach would also not require explicit security checks as this would already be enforced by the EPiServer CMS framework.

Advertisements
%d bloggers like this: