Monday, February 9, 2015

Creating multi-step forms using a SurfaceController

So you're building a form with a few steps in Umbraco?

In regular MVC you’d post to a different Action for each step, that way you can return a different view for each step, however with SurfaceControllers you can't really do this since Umbraco controls a lot of the routing/rendering process. In this blog post we'll show you a way around this though.

Summary:

  • Our model will contain fields for the different steps in the form and a bit of metadata to keep track of the steps
  • Our view will contain all the steps and shows them based on the step metadata
  • Our controller updates the step we're on and does validation only for the current step

In this example, we'll make a very simple ticket ordering form with 3 steps. Starting with the model, we'll make sure that all the fields that we want to use are on there. So here's the basics of the model, where we initialize the step counter (starting at 0) and the classes for each step:

public class TicketOrderModel
{
public TicketOrderModel()
{
StepIndex = 0;
PersonalInfoStep = new PersonalInfoStep();
TicketOrderStep = new TicketOrderStep();
TermsAgreementStep = new TOSAgreementStep();
}

public bool Previous { get; set; }
public bool Next { get; set; }
public int StepIndex { get; set; }

public PersonalInfoStep PersonalInfoStep { get; set; }
public TicketOrderStep TicketOrderStep { get; set; }
public TOSAgreementStep TermsAgreementStep { get; set; }
}

Then each step has it's own class with a bit of validation on some items, for example the PersonalInfoStep:

public class PersonalInfoStep
{
[Required]
public string Name { get; set; }

[Required, EmailAddress]
public string Email { get; set; }
}

The full model with all the steps can be found in a gist so that this post doesn't get too long.

Next, we can set up a SurfaceController that will expect a TicketOrderModel. If the model is null (first step of the form), it will be initialized and we'll see if the "previous" or "next" buttons have been clicked so that the StepIndex can be updated.

public class TicketOrderController : SurfaceController
{
[ChildActionOnly]
public ActionResult ShowOrderForm(TicketOrderModel model)
{
model = model ?? new TicketOrderModel();

if (model.Previous)
model.StepIndex--;

if (model.Next)
model.StepIndex++;

return View(model);
}
}

This is called as a ChildAction of one of your templates. Again, this is not normally what you would do in MVC but we have to work around some of the routing that Umbraco does.
This is a very basic, almost empty template:

@inherits UmbracoTemplatePage
@{
Layout = "Layout.cshtml";
}
<div class="orderform">
@Html.Action("ShowOrderForm", "TicketOrder")
</div>

So here we're calling the Action "ShowOrderForm" on the controller "TicketOrder". The ShowOrderForm action returns a partial view and sends the model into it. By convention the method name is the name of the view it will return and the folder to put this view in is the controller name, so in this case it will look for: ~/Views/TicketOrder/ShowOrderForm.cshtml.

In this view we need to determine which step we're on and show the appropriate form fields for that step, so it starts a little but like this:

@using Awesome.FormDemo.Controllers
@model Awesome.FormDemo.Models.TicketOrderModel

@using (Html.BeginUmbracoForm<TicketOrderController>("FormSubmit"))
{
switch (Model.StepIndex)
{
case 0:
@Html.LabelFor(m => Model.PersonalInfoStep.Name)
@Html.EditorFor(m => Model.PersonalInfoStep.Name)
@Html.ValidationMessageFor(m => Model.PersonalInfoStep.Name)<br/>
<!-- etc.. -->
break;
case 1:
@Html.LabelFor(m => Model.TicketOrderStep.EventId)
@Html.EditorFor(m => Model.TicketOrderStep.EventId)
@Html.ValidationMessageFor(m => Model.TicketOrderStep.EventId)<br/>
<!-- etc.. -->

break;
case 2:
@Html.LabelFor(m => Model.TermsAgreementStep.Agreed)
@Html.EditorFor(m => Model.TermsAgreementStep.Agreed)
@Html.ValidationMessageFor(m => Model.TermsAgreementStep.Agreed)

break;
}
}

I left some fields out, again to not make this post too long but of course the full view is available in a gist again.

If you have a look at that gist you'll also see that all of the fields that are not on the current step are being rendered as hidden fields. So for each step you'll need to include the fields that are on the other steps as hidden fields to be able to go to the Next or Previous step. Example, on the second step we render the fields of PersonalInfoStep and TermsAgreementStep as hidden fields, but not the fields of TicketOrderStep, they aren't hidden because people need to actually fill them in:

@Html.EditorFor(x => Model.PersonalInfoStep, "HiddenForAll", "PersonalInfoStep")
@Html.EditorFor(x => Model.TermsAgreementStep, "HiddenForAll", "TermsAgreementStep")

This uses an EditorTemplate to render all of the fields and their values. So in ~/Views/TicketOrder/EditorTemplates we have the following HiddenForAll.cshtml file:

@model dynamic

@foreach (var prop in ViewData.ModelMetadata.Properties
.Where(pm => pm.ShowForDisplay && !ViewData.TemplateInfo.Visited(pm)))
{
@Html.Hidden(prop.PropertyName, prop.Model)
}

It's beyond the scope of this blog post to explain exactly how this works, take it from us that it does work and you can prove it by looking at the source of your page to find the hidden fields.

Finally, we need to do something with the data we receive on each step and after all of the values have been validated then we need to actually do something with the data we received from the user.
For each step the form will post to a "FormSubmit" method on the TickerOrderController and clear all errors for steps that are not the current step. If all of the steps have gone through the error checks then we know we're done and can process the data and show a message to the user saying that their order was received successfully.

[HttpPost]
public ActionResult FormSubmit(TicketOrderModel model)
{
//ignore validation or saving data when going backwards
if (model.Previous)
return CurrentUmbracoPage();

var validationStep = string.Empty;
switch (model.StepIndex)
{
case 0:
validationStep = "PersonalInfoStep";
break;
case 1:
validationStep = "TicketOrderStep";
break;
case 2:
validationStep = "TermsAgreementStep";
break;
}

//remove all errors except for the current step
foreach (var key in ModelState.Keys.Where(k => k.StartsWith(string.Format("{0}.", validationStep)) == false))
{
ModelState[key].Errors.Clear();
}

if (!ModelState.IsValid)
{
return CurrentUmbracoPage();
}

//Its the final step, do some saving
if (model.StepIndex == 2)
{
//TODO: Do something with the form data

TempData.Add("CustomMessage", "Your form was successfully submitted at " + DateTime.Now);
return RedirectToCurrentUmbracoPage();
}

return CurrentUmbracoPage();
}

The complete controller code is available in a gist.

This is a simple example and surely it can use some improvements but the intention of this post is to show you how to start building multi-step forms and that there's no "dark magic" involved in it. The one thing you do need to realize is that it works a little differently from regular MVC multi-step forms as Umbraco is more responsible for routing and rendering as opposed to non-Umbraco MVC sites.

Credit where credit is due: Shannon helped a lot on this blog post, creating some code to start off with. I wouldn't have been able to completely figure it out myself without his invalueable help, #h5yr!

Want to be updated on everything Umbraco?

Sign up for the Umbraco newsletter and get the latest news and special offers send directly to your inbox

Are you sure, that's your real e-mail?