Home
Atom Feed

Full customization of XForms with EPiServer 7 and MVC

By default ASP.Net MVC uses conventions for locating the correct view for a controller action. But MVC comes with a nifty feature that lets you register custom view location rules, and this blog post will show you how to utilize that to completely customize the XForm html.

UPDATE: The below step for forcing EPiServer to look in ~/Views/Shared/DisplayTemplates and ~/Views/Shared/EditorTemplates for templates is not needed from version 7.5 and upwards.

First we need to register a custom view locator. To do that you need to hook into the application startup events, and I will use the excellent WebActivator package for this:

using EPiServer.Demo.TwitterBootstrap;

[assembly: WebActivator.PreApplicationStartMethod(typeof(HtmlFragmentViewEngineBoostrapper), "PreStart")]

namespace EPiServer.Demo.TwitterBootstrap
{
    using System.Web.Mvc;

    /// <summary>
    /// Bootstrapper for configuring the razor view engine for HtmlFragment views
    /// </summary>
    public class HtmlFragmentViewEngineBoostrapper
    {
        /// <summary>
        /// Occurs before the application starts
        /// </summary>
        public static void PreStart()
        {
            var engine = new RazorViewEngine()
                             {
                                 PartialViewLocationFormats =
                                     new[]
                                         {
                                             "~/Views/Shared/DisplayTemplates/{0}.cshtml",
                                             "~/Views/Shared/DisplayTemplates/{0}.vbhtml",
                                             "~/Views/Shared/DisplayTemplates/{1}/{0}.cshtml",
                                             "~/Views/Shared/DisplayTemplates/{1}/{0}.vbhtml",
                                             "~/Views/Shared/EditorTemplates/{0}.cshtml",
                                             "~/Views/Shared/EditorTemplates/{0}.vbhtml",
                                             "~/Views/Shared/EditorTemplates/{1}/{0}.cshtml",
                                             "~/Views/Shared/EditorTemplates/{1}/{0}.vbhtml"
                                         }
                             };

            ViewEngines.Engines.Add(engine);
        }
    }
}

Note: HtmlFragment is the XForm "fragments" (table rows, input boxes, etc.)
Note2: {1} in the location format is the Controller name and {0} is the name of the view. 

UPDATE: Some people have been asking me why the need for the below extension methods. 
The answer is that XForm does not support having different editor templates for different forms out of the box. The class below lets us specify a folder where it should look for the editor templates before falling back on the built-in ones. You will find examples on how to use it later in this article.

We also need to create an extension to replace EPiServers own RenderXForm html helpers:

/// <summary>
/// Extensions for the <see cref="HtmlHelper"/> class
/// </summary>
public static class HtmlFragmentExtensions
{
    /// <summary>
    /// Writes the XForm to the view context's output steam.
    /// </summary>
    /// <param name="htmlHelper">The HTML helper instance that this method extends.</param>
    /// <param name="xform">The XForm to write.</param>
    public static void RenderCustomXForm(this HtmlHelper htmlHelper, XForm xform)
    {
        if (xform == null)
        {
            return;
        }

        htmlHelper.RenderCustomXForm(xform, new XFormParameters());
    }

    /// <summary>
    /// Writes the XForm to the view context's output steam.
    /// </summary>
    /// <param name="htmlHelper">The HTML helper instance that this method extends.</param>
    /// <param name="xform">The XForm to write.</param>
    /// <param name="parameters">The parameters to be used by the XForm.</param>
    public static void RenderCustomXForm(this HtmlHelper htmlHelper, XForm xform, XFormParameters parameters)
    {
        if (xform == null)
        {
            return;
        }

        htmlHelper.ViewContext.ViewData["XFormParameters"] = parameters;

        htmlHelper.RenderPartial(typeof(XForm).Name, xform);
    }

    /// <summary>
    /// Renders the html fragment.
    /// </summary>
    /// <param name="htmlHelper">The HTML helper.</param>
    /// <param name="fragment">The fragment.</param>
    /// <param name="renderFragmentsWithoutView">if set to <c>true</c> [render fragments without view].</param>
    /// <returns>A HTML-encoded string.</returns>
    public static MvcHtmlString RenderCustomHtmlFragment(this HtmlHelper htmlHelper, HtmlFragment fragment, bool renderFragmentsWithoutView = true)
    {
        var value = new StringBuilder();

        using (var writer = new StringWriter(value))
        {
            // Try to find a matching partial view
            var viewEngineResult = ViewEngines.Engines.FindPartialView(htmlHelper.ViewContext, fragment.GetType().Name);

            if (viewEngineResult.View != null)
            {
                viewEngineResult.View.Render(viewEngineResult, htmlHelper.ViewContext, writer, fragment);
            }
            else if (renderFragmentsWithoutView)
            {
                // Render fragment directly unless otherwise specified
                writer.Write(fragment.ToString());
            }
        }

        return value.Length <= 0 ? MvcHtmlString.Empty : MvcHtmlString.Create(value.ToString());
    }

    /// <summary>
    /// Renders the HTML fragment.
    /// </summary>
    /// <param name="htmlHelper">The HTML helper.</param>
    /// <param name="fragment">The fragment.</param>
    /// <param name="partialViewName">The partial view name.</param>
    /// <param name="useFallback">if set to <c>true</c> [use fallback].</param>
    /// <param name="renderFragmentsWithoutView">if set to <c>true</c> [render fragments without view].</param>
    /// <returns>A HTML-encoded string.</returns>
    public static MvcHtmlString RenderCustomHtmlFragment(this HtmlHelper htmlHelper, HtmlFragment fragment, string partialViewName, bool useFallback = true, bool renderFragmentsWithoutView = true)
    {
        var value = new StringBuilder();

        using (var writer = new StringWriter(value))
        {
            // Try to find a matching partial view
            var viewEngineResult = ViewEngines.Engines.FindPartialView(htmlHelper.ViewContext, partialViewName);

            if (viewEngineResult.View != null)
            {
                viewEngineResult.View.Render(viewEngineResult, htmlHelper.ViewContext, writer, fragment);
            }
            else
            {
                // Try to fall back to default view
                if (useFallback)
                {
                    return htmlHelper.RenderCustomHtmlFragment(fragment, renderFragmentsWithoutView);
                }

                // Render fragment directly unless otherwise specified
                if (renderFragmentsWithoutView)
                {
                    writer.Write(fragment.ToString());
                }
            }
        }

        return value.Length <= 0 ? MvcHtmlString.Empty : MvcHtmlString.Create(value.ToString());
    }

    /// <summary>
    /// Renders the HTML fragment.
    /// </summary>
    /// <param name="view">The view.</param>
    /// <param name="viewEngineResult">The view engine result.</param>
    /// <param name="context">The context.</param>
    /// <param name="writer">The writer.</param>
    /// <param name="fragment">The fragment.</param>
    private static void Render(this IView view, ViewEngineResult viewEngineResult, ViewContext context, TextWriter writer, HtmlFragment fragment)
    {
        var viewDataDictionaries = new ViewDataDictionary(fragment);

        foreach (var viewDatum in context.ViewData)
        {
            viewDataDictionaries[viewDatum.Key] = viewDatum.Value;
        }

        var viewContext = new ViewContext(context, viewEngineResult.View, viewDataDictionaries, context.TempData, writer);

        foreach (var modelState in context.ViewData.ModelState)
        {
            viewContext.ViewData.ModelState.Add(modelState.Key, modelState.Value);
        }

        viewEngineResult.View.Render(viewContext, writer);
        viewEngineResult.ViewEngine.ReleaseView(context.Controller.ControllerContext, viewEngineResult.View);
    }
}

What this allows us is to override the renderer for XForm in general. We can now use the following code to render an XForm:

(view code taken from previous blog post about XForms and MVC)

@using EPiServer.Web.Mvc.Html
@model EPiServer.Demo.TwitterBootstrap.ViewModels.Partials.XFormBlockViewModel

<div class="xformpage" @Html.EditAttributes(x => x.Schema)>
    @using (Html.BeginXForm(Model.Schema, new { Action = Model.ActionUrl, @class = "form xform" }))
    {
        Html.RenderCustomXForm(Model.Schema);
    }
        
    if (Model.Schema.Id == ViewData["XFormID"])
    {
        @Html.ValidationSummary()
    }
</div>

Right now this just renders the XForm with EPiServers default view, as we haven't implemented a custom view yet.
Create a file called XForm.cshtml in ~/Views/Shared/DisplayTemplates and insert the following code in it:

@using EPiServer.HtmlParsing
@model EPiServer.XForms.XForm

<h3>Custom XForm view</h3>
@if (Model != null)
{
    var fragments = Model.CreateHtmlFragments();

    // Check if this is a postback, and if it is get the posted fragments for this instance
    if (ViewData["XFormInstanceID"] != null && Model.Id == ViewData["XFormInstanceID"])
    {
        fragments = (IEnumerable<HtmlFragment>)ViewData["XFormFragments"];
    }
    
    // Render all fragments
    foreach (var fragment in fragments)
    {
        @Html.RenderCustomHtmlFragment(fragment, "XForm/" + Model.Id + "/" + fragment.GetType().Name)
    }
}

If you load a page with a XForm on now, you'll se a header with your xform id above your form.

Now, lets say we want to utilize some bootstrap styling on our forms. That means we need to get rid of those hideous tables and replace them with our own stuff.

Replace the RenderCustomHtmlFragment code in the view above with the following code:

@Html.RenderCustomHtmlFragment(fragment, "XForm/" + Model.Id + "/" + fragment.GetType().Name)

This code tells our html helper extension to look in the "~/Views/Shared/DisplayTemplates/XForm/{xform-id}/" folder for views for each html fragment.

Create the following files in the folder mentioned above (replace {xform-id} with the id of your xform):

InputFragment.cshtml

@model EPiServer.XForms.Parsing.InputFragment

<div class="form-group @Model.Reference @Model.Class">
    <label for="@Model.Reference">@Model.Label</label>
    @if (Model.Required)
    {
        @Html.TextBox(Model.Reference, Server.HtmlDecode(Model.Value) ?? string.Empty, new { @class = "form-control", size = Model.Size, placeholder = Model.Title, required = "required" })
    }
    else
    {
        @Html.TextBox(Model.Reference, Server.HtmlDecode(Model.Value) ?? string.Empty, new { @class = "form-control", size = Model.Size, placeholder = Model.Title })
    }
    @Html.ValidationMessage(Model.Reference) // EPiServer 7.0 to 7.1
    @Html.ValidationMessage(Model.ValidationReference) // EPiServer 7.5 and upwards
</div>

ElementFragment.cshtml

@model EPiServer.HtmlParsing.ElementFragment

EndElementFragment.cshtml

@model EPiServer.HtmlParsing.EndElementFragment

SubmitFragment.cshtml

@model EPiServer.XForms.Parsing.SubmitFragment

<div class="@Model.Class">
    <input type="submit" class="btn btn-default" name="@Html.Raw(Model.UniqueName)" title="@Html.Raw(Model.Title)" value="@Html.Raw(Model.Value)" />
</div>

TextareaFragment.cshtml

@model EPiServer.XForms.Parsing.TextareaFragment

<div class="form-group @Model.Reference @Model.Class">
    <label for="@Model.Reference">@Model.Label</label>
    @if (Model.Required)
    {
        @Html.TextArea(Model.Reference, Model.Value, Model.Rows, Model.Columns, new { @class = "form-control", placeholder = Model.Title, required = "required" })
    }
    else
    {
        @Html.TextArea(Model.Reference, Model.Value, Model.Rows, Model.Columns, new { @class="form-control", placeholder = Model.Title })
    }
    @Html.ValidationMessage(Model.Reference) // EPiServer 7.0 to 7.1
    @Html.ValidationMessage(Model.ValidationReference) // EPiServer 7.5 and upwards
</div>

Select1tAsDropdownListFragment.cshtml

@model EPiServer.XForms.Parsing.Select1Fragment

<div class="form-group @Model.Reference @Model.Class">
    <label for="@Model.Reference">@Model.Label</label>
    @if (Model.Required)
    {
        @Html.DropDownList(Model.Reference, Model.Options.Select(o => new SelectListItem() { Text = Server.HtmlDecode(o.Text), Value = Server.HtmlDecode(o.Value), Selected = o.SelectedItem }), new { @class = "form-control", @title = Model.Title, @required = "required" })
    }
    else
    {
        @Html.DropDownList(Model.Reference, Model.Options.Select(o => new SelectListItem() { Text = Server.HtmlDecode(o.Text), Value = Server.HtmlDecode(o.Value), Selected = o.SelectedItem }), new { @class = "form-control", @title = Model.Title })
    }
    @Html.ValidationMessage(Model.Reference) // EPiServer 7.0 to 7.1
    @Html.ValidationMessage(Model.ValidationReference) // EPiServer 7.5 and upwards
</div>

 

If you render your form now, you will see that it has very different html output.
This output is also compatible with Twitter Bootstrap and jQuery Validation, meaning you get (partial) client-side validation. Yay! :-)

All this allows us to go from the following form configuration
 

And it's default styling

To this beautiful form

 

What more can you do? 

You can override the style of all the form fields. Here is a list of file names and models of all field types:

File nameModelDescription
ElementFragment.cshtml EPiServer.HtmlParsing.ElementFragment Inserted before each "fragment" (row, label, textbox, etc.), and template for horizontal rules.
EndElementFragment.cshtml EPiServer.HtmlParsing.EndElementFragment Inserted after each "fragment" (row, label, textbox, etc.)
InputFragment.cshtml EPiServer.XForms.Parsing.InputFragment Template for every textbox field
Select1tAsDropdownListFragment.cshtml EPiServer.XForms.Parsing.Select1Fragment Template for every dropdown field
SubmitFragment.cshtml EPiServer.XForms.Parsing.SubmitFragment Template for every submit button
TextareaFragment.cshtml EPiServer.XForms.Parsing.TextareaFragment Template for every textarea field
TextFragment.cshtml EPiServer.HtmlParsing.TextFragment Template for every heading field
SelectFragment.cshtml EPiServer.XForms.Parsing.SelectFragment Template for every checkbox field
Select1AsRadiobuttonFragment.cshtml EPiServer.XForms.Parsing.Select1Fragment Template for every radio button field

 

NOTE: You can find the default templates for some of these at "C:\Program Files (x86)\EPiServer\CMS\7.0.586.1\Application\Util\Views\Shared\EditorTemplates"