Creating a XForm Block in EPiServer 7 MVC with Working Validation (Updated)
I initially posted this solution on the Nansen blog, but since then the code has been updated for both bug fixes and to reduce unnecessary layers (at least, unnecessary for my situation).
With the flexibility that block types offer in EPiServer 7, it would make sense that one common feature that people would want is an XForm in a block, so editors can freely add, move, and reuse blocks with forms around their website. In a recent project, this was one of my requirements.
Although a solution for making XForms to work inside a block type has been partially solved, it doesn't fully support validation, which is a common requirement. Also, the documentation is still lacking when it comes to explaining how to integrate XForms in EPiServer 7 MVC, both within a page type and a block type.
This solution was developed using the initial release of EPiServer 7. The EPiServer support team has stated that they may implement a solution for fully working XForms in a block on a future release.
The Solution
The solution to this revolved heavily around making sure the action URL on the form was correct (so we stay on the page the the block is located) and sharing the controller's ViewData
with all controllers that needed it (both page controllers and block controllers).
/Models/Blocks/XFormBlock.cs
This is the block class with the XForm block. By decorating the ActionUri
property with the [Ignore]
attribute, we can treat this class also as the view model for the view. The [Ignore]
attribute prevents EPiServer from registering the property as an editable block type property.
[ContentType(GUID = "00000000-0000-0000-0000-000000000000")]
public class XFormBlock : BlockData
{
[Display(GroupName = SystemTabNames.Content)]
public virtual XForm Form { get; set; }
[Ignore]
public virtual string ActionUri { get; set; }
}
/Controllers/BasePageController.cs
This is the most important piece of the solution, since it handles all the XForms actions, as well as sets the ViewData
that's used in the block controller. The controller for any page that could potentially have an XForm block should inherit this base controller class.
The first main method that we override is the OnResultExecuting()
method, which sets the ViewData
in the TempData
collection after a page controller sets it. Without this, the block controller will have a different ViewData
, which makes all the validation information go away.
The second main method we override is OnActionExecuting()
, which handles the transfer of the ViewData
between the XForms methods Success()
and Failed()
to the page controllers they are redirecting to through the RedirectToAction("Index")
call.
When the ViewData
is set, we use a unique key for the form in the TempData
collection. The unique key uses the ContentLink
ID of the block, which is built into the ActionUri
and passed in as a parameter to the XFormPost
method. This unique key allows us to use multiple XForms blocks on the same page without the ViewData
of one block interfering with the ViewData
of another block. The only exception to this is when a page has two instances of the same XForms block (which really doesn't happen very often). One side benefit of this is that we only transfer the ViewData
when we have the _contentId
, which prevents unnecessary saving and fetching of the ViewData
.
public class BasePageController<T> : PageController<T> where T : PageData
{
private readonly XFormPageUnknownActionHandler _xformHandler;
private string _contentId;
private readonly string _viewDataKeyFormat = "ViewData_{0}";
private string ViewDataKey
{
get
{
return string.Format(_viewDataKeyFormat, _contentId);
}
}
public BasePageController()
{
_xformHandler = new XFormPageUnknownActionHandler();
_contentId = string.Empty;
}
protected override void OnActionExecuting(ActionExecutingContext filterContext)
{
if (!string.IsNullOrEmpty(_contentId))
{
if (TempData[ViewDataKey] != null)
{
ViewData = (ViewDataDictionary)TempData[ViewDataKey];
}
}
base.OnActionExecuting(filterContext);
}
protected override void OnResultExecuting(ResultExecutingContext filterContext)
{
if (!string.IsNullOrEmpty(_contentId))
{
TempData[ViewDataKey] = ViewData;
}
base.OnResultExecuting(filterContext);
}
[AcceptVerbs(HttpVerbs.Post)]
public virtual ActionResult Success(XFormPostedData xFormPostedData)
{
return RedirectToAction("Index", new { language = PageContext.LanguageID });
}
[AcceptVerbs(HttpVerbs.Post)]
public virtual ActionResult Failed(XFormPostedData xFormPostedData)
{
return RedirectToAction("Index", new { language = PageContext.LanguageID });
}
[AcceptVerbs(HttpVerbs.Post)]
public virtual ActionResult XFormPost(XFormPostedData xFormPostedData, string contentId)
{
_contentId = contentId;
return _xformHandler.HandleAction(this);
}
}
/Controllers/XFormBlockController.cs
In the block's controller, we grab the ViewData
that we saved in the BasePageController
using the same unique key. This allows us to maintain the validation data for the XForm.
The important part of this is how we build out the ActionUrl
. We can get the currentPage
data from the PageRouteHelper
, then find the virtual path to the page we are viewing using a UrlResolver
, so we always POST to the page that the block is on. We also put the block's ContentLink
ID in the ActionUri
so the BasePageController
knows how to save the ViewData
. The rest of the values in the query string are to set the actions that the XForms handler uses if the submission was successful or unsuccessful.
One things to note is that if we are using on-page editing for the block, we won't have a currentPage
. In this situation, we just borrow the Start Page. This won't matter anyway, because if you try to click the "Submit" button, rather than the form being submitted, the form selection modal will pop up instead.
public class XFormBlockController : BlockController<XFormBlock>
{
public override ActionResult Index(XFormBlock currentBlock)
{
var currentBlockID = (currentBlock as IContent).ContentLink.ID;
var viewDataKey = string.Format("ViewData_{0}", currentBlockID);
if (TempData[viewDataKey] != null)
{
ViewData = (ViewDataDictionary)TempData[viewDataKey];
}
var pageRouteHelper = ServiceLocator.Current.GetInstance<PageRouteHelper>();
PageData currentPage = pageRouteHelper.Page;
// For block preview mode, we need to have a current page,
// but since preview isn't really a page, we'll use the start page
// This won't matter anyway. If you try to submit the form,
// the form selection window will pop up.
if (currentPage == null)
{
var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
currentPage = contentLoader.Get<StartPage>(ContentReference.StartPage);
}
if (currentBlock.Form != null && currentPage != null)
{
var urlResolver = ServiceLocator.Current.GetInstance<UrlResolver>();
var pageUrl = urlResolver.GetVirtualPath(currentPage.ContentLink);
var actionUri = string.Format("{0}XFormPost/", pageUrl);
actionUri = UriSupport.AddQueryString(actionUri, "XFormId", currentBlock.Form.Id.ToString());
actionUri = UriSupport.AddQueryString(actionUri, "failedAction", "Failed");
actionUri = UriSupport.AddQueryString(actionUri, "successAction", "Success");
actionUri = UriSupport.AddQueryString(actionUri, "contentId", currentBlockID.ToString());
currentBlock.ActionUri = actionUri;
}
return PartialView(currentBlock);
}
}
/Views/XFormBlock/Index.cshtml
This is just a simple view for the block. The primary thing that is different compared to how we normally output page properties is that we need to set the action on the form.
@model XFormBlock
<div @Html.EditAttributes(m=> m.Form)>
@Html.ValidationSummary()
@using (Html.BeginXForm(Model.Form, new { Action = Model.ActionUri }))
{
Html.RenderXForm(Model.Form);
}
</div>
And that's it. With this solution, you don't need to worry handling the XForm in any specific page controllers that the block lives in, although you could easily just override the XFormPost()
method in the BasePageController
if needed. This also supports the built-in 'Save to database' and/or 'Send e-mail' submit options.