multi-step registration process issues in asp.net mvc (split viewmodels, single model)
Asked Answered
P

7

124

I have a multi-step registration process, backed by a single object in domain layer, which have validation rules defined on properties.

How should I validate the domain object when the domain is split across many views, and I have to save the object partially in the first view when posted?

I thought about using Sessions but that's not possible cause the process is lengthy and amount of data is high, So I don't want to use session.

I thought about saving all the data in an relational in-memory db (with the same schema as main db) and then flushing that data to main db but issues arisen cause I should route between services (requested in the views) who work with the main db and in-memory db.

I'm looking for an elegant and clean solution (more precisely a best practice).

UPDATE AND Clarification:

@Darin Thank you for your thoughtful reply, That was exactly what I've done till now. But incidentally I've got a request which have many attachments in it, I design a Step2View e.g. which user can upload documents in it asynchronously , but those attachments should be saved in a table with referential relation to another table that should have been saved before in Step1View.

Thus I should save the domain object in Step1 (partially), But I can't, cause the backed Core Domain object which is mapped partially to a Step1's ViewModel can't be saved without props that come from converted Step2ViewModel.

Peltate answered 19/6, 2011 at 13:31 Comment(5)
@Jani, Did you ever figure out the upload piece of this? I'd like to pick your brain. I am working on this exact issue.Raul
The solution in this blog is quite simple and straight forward. It uses divs as "steps" by swithing their visibility and unobtrusive jquery validation.Burdensome
possible duplicate of Any sample MVC3 Wizard Apps (multi-step) (NO JQUERY)Hemline
@Hemline that is the duplicate not this one since this question is posted earlier.Oxidation
Thanks for the feedback 10 years later ;)Hemline
T
238

First you shouldn't be using any domain objects in your views. You should be using view models. Each view model will contain only the properties that are required by the given view as well as the validation attributes specific to this given view. So if you have 3 steps wizard this means that you will have 3 view models, one for each step:

public class Step1ViewModel
{
    [Required]
    public string SomeProperty { get; set; }

    ...
}

public class Step2ViewModel
{
    [Required]
    public string SomeOtherProperty { get; set; }

    ...
}

and so on. All those view models could be backed by a main wizard view model:

public class WizardViewModel
{
    public Step1ViewModel Step1 { get; set; }
    public Step2ViewModel Step2 { get; set; }
    ...
}

then you could have controller actions rendering each step of the wizard process and passing the main WizardViewModel to the view. When you are on the first step inside the controller action you could initialize the Step1 property. Then inside the view you would generate the form allowing the user to fill the properties about step 1. When the form is submitted the controller action will apply the validation rules for step 1 only:

[HttpPost]
public ActionResult Step1(Step1ViewModel step1)
{
    var model = new WizardViewModel 
    {
        Step1 = step1
    };

    if (!ModelState.IsValid)
    {
        return View(model);
    }
    return View("Step2", model);
}

Now inside the step 2 view you could use the Html.Serialize helper from MVC futures in order to serialize step 1 into a hidden field inside the form (sort of a ViewState if you wish):

@using (Html.BeginForm("Step2", "Wizard"))
{
    @Html.Serialize("Step1", Model.Step1)
    @Html.EditorFor(x => x.Step2)
    ...
}

and inside the POST action of step2:

[HttpPost]
public ActionResult Step2(Step2ViewModel step2, [Deserialize] Step1ViewModel step1)
{
    var model = new WizardViewModel 
    {
        Step1 = step1,
        Step2 = step2
    }

    if (!ModelState.IsValid)
    {
        return View(model);
    }
    return View("Step3", model);
}

And so on until you get to the last step where you will have the WizardViewModel filled with all the data. Then you will map the view model to your domain model and pass it to the service layer for processing. The service layer might perform any validation rules itself and so on ...

There is also another alternative: using javascript and putting all on the same page. There are many jquery plugins out there that provide wizard functionality (Stepy is a nice one). It's basically a matter of showing and hiding divs on the client in which case you no longer need to worry about persisting state between the steps.

But no matter what solution you choose always use view models and perform the validation on those view models. As long you are sticking data annotation validation attributes on your domain models you will struggle very hard as domain models are not adapted to views.


UPDATE:

OK, due to the numerous comments I draw the conclusion that my answer was not clear. And I must agree. So let me try to further elaborate my example.

We could define an interface which all step view models should implement (it's just a marker interface):

public interface IStepViewModel
{
}

then we would define 3 steps for the wizard where each step would of course contain only the properties that it requires as well as the relevant validation attributes:

[Serializable]
public class Step1ViewModel: IStepViewModel
{
    [Required]
    public string Foo { get; set; }
}

[Serializable]
public class Step2ViewModel : IStepViewModel
{
    public string Bar { get; set; }
}

[Serializable]
public class Step3ViewModel : IStepViewModel
{
    [Required]
    public string Baz { get; set; }
}

next we define the main wizard view model which consists of a list of steps and a current step index:

[Serializable]
public class WizardViewModel
{
    public int CurrentStepIndex { get; set; }
    public IList<IStepViewModel> Steps { get; set; }

    public void Initialize()
    {
        Steps = typeof(IStepViewModel)
            .Assembly
            .GetTypes()
            .Where(t => !t.IsAbstract && typeof(IStepViewModel).IsAssignableFrom(t))
            .Select(t => (IStepViewModel)Activator.CreateInstance(t))
            .ToList();
    }
}

Then we move on to the controller:

public class WizardController : Controller
{
    public ActionResult Index()
    {
        var wizard = new WizardViewModel();
        wizard.Initialize();
        return View(wizard);
    }

    [HttpPost]
    public ActionResult Index(
        [Deserialize] WizardViewModel wizard, 
        IStepViewModel step
    )
    {
        wizard.Steps[wizard.CurrentStepIndex] = step;
        if (ModelState.IsValid)
        {
            if (!string.IsNullOrEmpty(Request["next"]))
            {
                wizard.CurrentStepIndex++;
            }
            else if (!string.IsNullOrEmpty(Request["prev"]))
            {
                wizard.CurrentStepIndex--;
            }
            else
            {
                // TODO: we have finished: all the step partial
                // view models have passed validation => map them
                // back to the domain model and do some processing with
                // the results

                return Content("thanks for filling this form", "text/plain");
            }
        }
        else if (!string.IsNullOrEmpty(Request["prev"]))
        {
            // Even if validation failed we allow the user to
            // navigate to previous steps
            wizard.CurrentStepIndex--;
        }
        return View(wizard);
    }
}

Couple of remarks about this controller:

  • The Index POST action uses the [Deserialize] attributes from the Microsoft Futures library so make sure you have installed the MvcContrib NuGet. That's the reason why view models should be decorated with the [Serializable] attribute
  • The Index POST action takes as argument an IStepViewModel interface so for this to make sense we need a custom model binder.

Here's the associated model binder:

public class StepViewModelBinder : DefaultModelBinder
{
    protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
    {
        var stepTypeValue = bindingContext.ValueProvider.GetValue("StepType");
        var stepType = Type.GetType((string)stepTypeValue.ConvertTo(typeof(string)), true);
        var step = Activator.CreateInstance(stepType);
        bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => step, stepType);
        return step;
    }
}

This binder uses a special hidden field called StepType which will contain the concrete type of each step and which we will send on each request.

This model binder will be registered in Application_Start:

ModelBinders.Binders.Add(typeof(IStepViewModel), new StepViewModelBinder());

The last missing bit of the puzzle are the views. Here's the main ~/Views/Wizard/Index.cshtml view:

@using Microsoft.Web.Mvc
@model WizardViewModel

@{
    var currentStep = Model.Steps[Model.CurrentStepIndex];
}

<h3>Step @(Model.CurrentStepIndex + 1) out of @Model.Steps.Count</h3>

@using (Html.BeginForm())
{
    @Html.Serialize("wizard", Model)

    @Html.Hidden("StepType", Model.Steps[Model.CurrentStepIndex].GetType())
    @Html.EditorFor(x => currentStep, null, "")

    if (Model.CurrentStepIndex > 0)
    {
        <input type="submit" value="Previous" name="prev" />
    }

    if (Model.CurrentStepIndex < Model.Steps.Count - 1)
    {
        <input type="submit" value="Next" name="next" />
    }
    else
    {
        <input type="submit" value="Finish" name="finish" />
    }
}

And that's all you need to make this working. Of course if you wanted you could personalize the look and feel of some or all steps of the wizard by defining a custom editor template. For example let's do it for step 2. So we define a ~/Views/Wizard/EditorTemplates/Step2ViewModel.cshtml partial:

@model Step2ViewModel

Special Step 2
@Html.TextBoxFor(x => x.Bar)

Here's how the structure looks like:

enter image description here

Of course there is room for improvement. The Index POST action looks like s..t. There's too much code in it. A further simplification would involve into moving all the infrastructure stuff like index, current index management, copying of the current step into the wizard, ... into another model binder. So that finally we end up with:

[HttpPost]
public ActionResult Index(WizardViewModel wizard)
{
    if (ModelState.IsValid)
    {
        // TODO: we have finished: all the step partial
        // view models have passed validation => map them
        // back to the domain model and do some processing with
        // the results
        return Content("thanks for filling this form", "text/plain");
    }
    return View(wizard);
}

which is more how POST actions should look like. I am leaving this improvement for the next time :-)

Tutto answered 19/6, 2011 at 15:59 Comment(36)
@darin - How does your example then link up with the domain objects? How does Step1ViewModel know about the actual model where the data lives?Raul
@Doug Chamberlain, I use AutoMapper to convert between my view models and domain models.Tutto
@Darin, from your Step1 View you would need an action link to step2 is that correct? If so, why do you return View Step2 from Step1?Raul
@Doug Chamberlain, no, no action link. Form POST. With links I cannot send hidden fields which will contained the serialized state of previous steps.Tutto
@Darin, I see now...you explain everything ver well.Raul
@Darin, If you had 10 steps, would you expect the last step to take in 9 parameters, One parameter for each model to deserialize? or do you only have to worry about the previous step?Raul
@Doug Chamberlain, oh no, if I had so many steps I would redesign this in order to make it more generic. Having a method taking 9 arguments would be insane.Tutto
@Doug Chamberlain, I guess I will blog any time soon about this as I see many people struggle with it. I just have to find some free time :-)Tutto
@Doug Chamberlain, please see my updated answer. I hope it makes things a little more clear than my initial post.Tutto
@Darin, Great thanks so much. I was headed down the right path, I even was using Activator.CreateInstance(). I feel smart now. I new I would probably need reflection to get this to work. Thanks for confirming my suspicions.Raul
+1 @Jani: you really need to give Darin the 50 points for this answer. It's very comprehensive. And he managed to reiterate the need for using ViewModel and not Domain models ;-)Lagena
I can't find Deserialize attribute anywhere... Also in mvccontrib's codeplex page I find this 94fa6078a115 by Jeremy Skinner Aug 1 2010 at 5:55 PM 0 Remove the deprecated Deserialize binder What you suggest me to do?Cardiovascular
darin how exactly would you redesign this in order to make it more generic & can you explain a bit more about the second modelbinder you added at the end for the wizard currentindex.Endoskeleton
This is gold. Just got this working with an inline wizard (hide/show divs using javascript). Just needed to tweak your model binder a little bit, but all good. Nice. +1Shortage
Awesome work, trying to get this going but I'm having a issue with the wizard variable not being populated, I posted a question if anyone can take a lookWittenburg
I found an issue whereas I didn't name my views Step1, Step2, etc... Mine are named something more meaningful, but not alphabetical. So, I ended up getting my models in the wrong order. I added a StepNumber property to the IStepViewModel interface. Now I can sort by this in the Initialize method of WizardViewModel.Ashlar
I spent a few hours trying to get this to work, but it doesn't seem to in my application. Going to get rid of the serialization stuff that fails on every level and move it to the session. I wish there was a place to post the source I come up with in case someone else needs to use this code.Ashlar
so what would the domain model look like for this example?Burtie
@JackNull, why care? This doesn't really matter. The important thing is that no matter how your domain model looks like you map it to the view model shown here. So you could have absolutely any domain model you like (or already have).Tutto
ah, I guess I was asking more how this would be accomplished, but I missed the automapper comment. One last question, what if you wanted to add in a 'Steps Left Design Pattern' like this: ui-patterns.com/patterns/StepsLeft and allow the user who was on step 3 to go back to step 1?Burtie
Just a word of warning. My Index action now has over 100 lines of code to support this. I wish I would have never attempted this, as my models have become too complex for this solution. I would consider other options in the future.Ashlar
Is this answer still valid for MVC4? I tried to use it but get an error because 'wizard.Steps' is null after a postbackUrion
@amaters, yes it's still valid for MVC4.Tutto
Strange. any idea why the steps are not kept in the serialized object? when I click on Next on the first step the Steps property of the wizard object is nullUrion
> "So that finally we end up with" - how I can do it?Androgyne
Is it possible to stay on the same page? and showing the step you are currently on? Do I have to look for options with jquery and ajax??Vulture
I appreciate the detailed description but I'm leaning in another direction. This approach seems kind of complex to me. Why wouldn't it be better to put all wizard steps in a single view, and use client-side script to display or hide each step. You could then submit all steps at once when the user is done. This seems easier, avoids having to install additional components, and it seems like you could avoid potential problems such as the user skipping steps by editing the URL in the address bar.Featherbedding
@Darin Thanks for a such detailed explanation, how do you map the wizard back to domain model in the last step of your update? I cannot seem to find the values in each stepviewmodelVulture
Thanks @Darin this is exactly what I need. But what is the best approach to populate data in the view model? I have created a new question for this. #31744863Ellga
Sir have you created any blog for this multi stage registration process as you have mention in your comment regarding this.if yes then can you please share a link of that with me as i badly need itGalba
Note that in MVC 5 the Html.Serialize helper isn't available - #25829053Mailman
What if you had a field public List<int> Students { get; set; } in one of your steps, what would @Html.EditorFor show on this step?Mailman
@DarinDimitrov - I really like your approach above. One question: is there any reason to prefer using a Hidden Form filed to store the serialised data vs. placing inside the session?Depolarize
@DarinDimitrov I followed this answer two years ago and my wizard is still working great, a team member of mine is implementing a new ASP.NET MVC Wizard following this same pattern today!Blim
Use MvcContrib.Mvc5 and Mvc.Futures package for @Html.Serialize() and [Deserialize] attribute to work.Coraciiform
One doubt what if you have multiple properties for each step , check this answer , suggest whether its correct ?Coraciiform
M
13

To supplement on Amit Bagga's answer you will find below what I did. Even if less elegant I find this way simpler than Darin's answer.

Controller :

public ActionResult Step1()
{
    if (Session["wizard"] != null)
    {
        WizardProductViewModel wiz = (WizardProductViewModel)Session["wizard"];
        return View(wiz.Step1);
    }
    return View();
}

[HttpPost]
public ActionResult Step1(Step1ViewModel step1)
{
    if (ModelState.IsValid)
    {
        WizardProductViewModel wiz = new WizardProductViewModel();
        wiz.Step1 = step1;
        //Store the wizard in session
        Session["wizard"] = wiz;
        return RedirectToAction("Step2");
    }
    return View(step1);
}

public ActionResult Step2()
{
    if (Session["wizard"] != null)
    {
        WizardProductViewModel wiz = (WizardProductViewModel)Session["wizard"];
        return View(wiz.Step2);
    }
    return View();
}

[HttpPost]
public ActionResult Step2(Step2ViewModel step2)
{
    if (ModelState.IsValid)
    {
        //Pull the wizard from session
        WizardProductViewModel wiz = (WizardProductViewModel)Session["wizard"];
        wiz.Step2 = step2;
        //Store the wizard in session
        Session["wizard"] = wiz;
        //return View("Step3");
        return RedirectToAction("Step3");
    }
    return View(step2);
}

public ActionResult Step3()
{
    WizardProductViewModel wiz = (WizardProductViewModel)Session["wizard"];
    return View(wiz.Step3);
}

[HttpPost]
public ActionResult Step3(Step3ViewModel step3)
{
    if (ModelState.IsValid)
    {
        //Pull the wizard from session
        WizardProductViewModel wiz = (WizardProductViewModel)Session["wizard"];
        wiz.Step3 = step3;
        //Save the data
        Product product = new Product
        {
            //Binding with view models
            Name = wiz.Step1.Name,
            ListPrice = wiz.Step2.ListPrice,
            DiscontinuedDate = wiz.Step3.DiscontinuedDate
        };

        db.Products.Add(product);
        db.SaveChanges();
        return RedirectToAction("Index", "Product");
    }
    return View(step3);
}

Models :

 [Serializable]
    public class Step1ViewModel 
    {
        [Required]
        [MaxLength(20, ErrorMessage="Longueur max de 20 caractères")]
        public string Name { get; set; }

    }

    [Serializable]
    public class Step2ViewModel
    {
        public Decimal ListPrice { get; set; }

    }

    [Serializable]
    public class Step3ViewModel
    {
        public DateTime? DiscontinuedDate { get; set; }
    }

    [Serializable]
    public class WizardProductViewModel
    {
        public Step1ViewModel Step1  { get; set; }
        public Step2ViewModel Step2  { get; set; }
        public Step3ViewModel Step3  { get; set; }
    }
Marshy answered 10/8, 2012 at 10:34 Comment(0)
L
11

I would suggest you to maintain the state of Complete Process on the client using Jquery.

For Example we have a Three Step Wizard process.

  1. The user in presented with the Step1 on which has a button Labeled "Next"
  2. On Clicking Next We make an Ajax Request and Create a DIV called Step2 and load the HTML into that DIV.
  3. On the Step3 we have a Button labeled "Finished" on Clicking on the button post the data using $.post call.

This way you can easily build your domain object directly from the form post data and in case the data has errors return valid JSON holding all the error message and display them in a div.

Please split the Steps

public class Wizard 
{
  public Step1 Step1 {get;set;}
  public Step2 Step2 {get;set;}
  public Step3 Step3 {get;set;}
}

public ActionResult Step1(Step1 step)
{
  if(Model.IsValid)
 {
   Wizard wiz = new Wizard();
   wiz.Step1 = step;
  //Store the Wizard in Session;
  //Return the action
 }
}

public ActionResult Step2(Step2 step)
{
 if(Model.IsValid)
 {
   //Pull the Wizard From Session
   wiz.Step2=step;
 }
}

The Above is just a demonstration which will help you achieve the end result. On the Final Step you have to create the Domain Object and populate the correct values from the Wizard Object and Store into the database.

Lightness answered 23/6, 2011 at 20:58 Comment(3)
Yes, That's an interesting solution, but we have a poor internet connection on the client side unfortunately, and he/she should send us a bunch of files. so we rejected that solution earlier.Peltate
Can you please let me know the volume of data that client is going to upload.Lightness
Several files, almost ten, each one nearly 1 MB.Peltate
B
7

I wanted to share my own way of handling these requirements. I did not want to use SessionState at all, nor did I want it handled client side, and the serialize method requires MVC Futures which I did not want to have to include in my project.

Instead I built an HTML Helper that will iterate through all of the properties of the model and generate a custom hidden element for each one. If it is a complex property then it will run recursively on it.

In your form they will be posted to the controller along with the new model data at each "wizard" step.

I wrote this for MVC 5.

using System;
using System.Text;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Web;
using System.Web.Routing;
using System.Web.Mvc;
using System.Web.Mvc.Html;
using System.Reflection;

namespace YourNamespace
{
    public static class CHTML
    {
        public static MvcHtmlString HiddenClassFor<TModel, TProperty>(this HtmlHelper<TModel> html, Expression<Func<TModel, TProperty>> expression)
        {
            return HiddenClassFor(html, expression, null);
        }

        public static MvcHtmlString HiddenClassFor<TModel, TProperty>(this HtmlHelper<TModel> html, Expression<Func<TModel, TProperty>> expression, object htmlAttributes)
        {
            ModelMetadata _metaData = ModelMetadata.FromLambdaExpression(expression, html.ViewData);

            if (_metaData.Model == null)
                return MvcHtmlString.Empty;

            RouteValueDictionary _dict = htmlAttributes != null ? new RouteValueDictionary(htmlAttributes) : null;

            return MvcHtmlString.Create(HiddenClassFor(html, expression, _metaData, _dict).ToString());
        }

        private static StringBuilder HiddenClassFor<TModel>(HtmlHelper<TModel> html, LambdaExpression expression, ModelMetadata metaData, IDictionary<string, object> htmlAttributes)
        {
            StringBuilder _sb = new StringBuilder();

            foreach (ModelMetadata _prop in metaData.Properties)
            {
                Type _type = typeof(Func<,>).MakeGenericType(typeof(TModel), _prop.ModelType);
                var _body = Expression.Property(expression.Body, _prop.PropertyName);
                LambdaExpression _propExp = Expression.Lambda(_type, _body, expression.Parameters);

                if (!_prop.IsComplexType)
                {
                    string _id = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(ExpressionHelper.GetExpressionText(_propExp));
                    string _name = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(ExpressionHelper.GetExpressionText(_propExp));
                    object _value = _prop.Model;

                    _sb.Append(MinHiddenFor(_id, _name, _value, htmlAttributes));
                }
                else
                {
                    if (_prop.ModelType.IsArray)
                        _sb.Append(HiddenArrayFor(html, _propExp, _prop, htmlAttributes));
                    else if (_prop.ModelType.IsClass)
                        _sb.Append(HiddenClassFor(html, _propExp, _prop, htmlAttributes));
                    else
                        throw new Exception(string.Format("Cannot handle complex property, {0}, of type, {1}.", _prop.PropertyName, _prop.ModelType));
                }
            }

            return _sb;
        }

        public static MvcHtmlString HiddenArrayFor<TModel, TProperty>(this HtmlHelper<TModel> html, Expression<Func<TModel, TProperty>> expression)
        {
            return HiddenArrayFor(html, expression, null);
        }

        public static MvcHtmlString HiddenArrayFor<TModel, TProperty>(this HtmlHelper<TModel> html, Expression<Func<TModel, TProperty>> expression, object htmlAttributes)
        {
            ModelMetadata _metaData = ModelMetadata.FromLambdaExpression(expression, html.ViewData);

            if (_metaData.Model == null)
                return MvcHtmlString.Empty;

            RouteValueDictionary _dict = htmlAttributes != null ? new RouteValueDictionary(htmlAttributes) : null;

            return MvcHtmlString.Create(HiddenArrayFor(html, expression, _metaData, _dict).ToString());
        }

        private static StringBuilder HiddenArrayFor<TModel>(HtmlHelper<TModel> html, LambdaExpression expression, ModelMetadata metaData, IDictionary<string, object> htmlAttributes)
        {
            Type _eleType = metaData.ModelType.GetElementType();
            Type _type = typeof(Func<,>).MakeGenericType(typeof(TModel), _eleType);

            object[] _array = (object[])metaData.Model;

            StringBuilder _sb = new StringBuilder();

            for (int i = 0; i < _array.Length; i++)
            {
                var _body = Expression.ArrayIndex(expression.Body, Expression.Constant(i));
                LambdaExpression _arrayExp = Expression.Lambda(_type, _body, expression.Parameters);
                ModelMetadata _valueMeta = ModelMetadata.FromLambdaExpression((dynamic)_arrayExp, html.ViewData);

                if (_eleType.IsClass)
                {
                    _sb.Append(HiddenClassFor(html, _arrayExp, _valueMeta, htmlAttributes));
                }
                else
                {
                    string _id = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(ExpressionHelper.GetExpressionText(_arrayExp));
                    string _name = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(ExpressionHelper.GetExpressionText(_arrayExp));
                    object _value = _valueMeta.Model;

                    _sb.Append(MinHiddenFor(_id, _name, _value, htmlAttributes));
                }
            }

            return _sb;
        }

        public static MvcHtmlString MinHiddenFor<TModel, TProperty>(this HtmlHelper<TModel> html, Expression<Func<TModel, TProperty>> expression)
        {
            return MinHiddenFor(html, expression, null);
        }

        public static MvcHtmlString MinHiddenFor<TModel, TProperty>(this HtmlHelper<TModel> html, Expression<Func<TModel, TProperty>> expression, object htmlAttributes)
        {
            string _id = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(ExpressionHelper.GetExpressionText(expression));
            string _name = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(ExpressionHelper.GetExpressionText(expression));
            object _value = ModelMetadata.FromLambdaExpression(expression, html.ViewData).Model;
            RouteValueDictionary _dict = htmlAttributes != null ? new RouteValueDictionary(htmlAttributes) : null;

            return MinHiddenFor(_id, _name, _value, _dict);
        }

        public static MvcHtmlString MinHiddenFor(string id, string name, object value, IDictionary<string, object> htmlAttributes)
        {
            TagBuilder _input = new TagBuilder("input");
            _input.Attributes.Add("id", id);
            _input.Attributes.Add("name", name);
            _input.Attributes.Add("type", "hidden");

            if (value != null)
            {
                _input.Attributes.Add("value", value.ToString());
            }

            if (htmlAttributes != null)
            {
                foreach (KeyValuePair<string, object> _pair in htmlAttributes)
                {
                    _input.MergeAttribute(_pair.Key, _pair.Value.ToString(), true);
                }
            }

            return new MvcHtmlString(_input.ToString(TagRenderMode.SelfClosing));
        }
    }
}

Now for all steps of your "wizard" you can use the same base model and pass the "Step 1,2,3" model properties into the @Html.HiddenClassFor helper using a lambda expression.

You can even have a back button at each step if you want to. Just have a back button in your form that will post it to a StepNBack action on the controller using the formaction attribute. Not included in the below example but just an idea for you.

Anyways here is a basic example:

Here is your MODEL

public class WizardModel
{
    // you can store additional properties for your "wizard" / parent model here
    // these properties can be saved between pages by storing them in the form using @Html.MinHiddenFor(m => m.WizardID)
    public int? WizardID { get; set; }

    public string WizardType { get; set; }

    [Required]
    public Step1 Step1 { get; set; }

    [Required]
    public Step2 Step2 { get; set; }

    [Required]
    public Step3 Step3 { get; set; }

    // if you want to use the same model / view / controller for EDITING existing data as well as submitting NEW data here is an example of how to handle it
    public bool IsNew
    {
        get
        {
            return WizardID.HasValue;
        }
    }
}

public class Step1
{
    [Required]
    [MaxLength(32)]
    [Display(Name = "First Name")]
    public string FirstName { get; set; }

    [Required]
    [MaxLength(32)]
    [Display(Name = "Last Name")]
    public string LastName { get; set; }
}

public class Step2
{
    [Required]
    [MaxLength(512)]
    [Display(Name = "Biography")]
    public string Biography { get; set; }
}

public class Step3
{        
    // lets have an array of strings here to shake things up
    [Required]
    [Display(Name = "Your Favorite Foods")]
    public string[] FavoriteFoods { get; set; }
}

Here is your CONTROLLER

public class WizardController : Controller
{
    [HttpGet]
    [Route("wizard/new")]
    public ActionResult New()
    {
        WizardModel _model = new WizardModel()
        {
            WizardID = null,
            WizardType = "UserInfo"
        };

        return View("Step1", _model);
    }

    [HttpGet]
    [Route("wizard/edit/{wizardID:int}")]
    public ActionResult Edit(int wizardID)
    {
        WizardModel _model = database.GetData(wizardID);

        return View("Step1", _model);
    }

    [HttpPost]
    [Route("wizard/step1")]
    public ActionResult Step1(WizardModel model)
    {
        // just check if the values in the step1 model are valid
        // shouldn't use ModelState.IsValid here because that would check step2 & step3.
        // which isn't entered yet
        if (ModelState.IsValidField("Step1"))
        {
            return View("Step2", model);
        }

        return View("Step1", model);
    }

    [HttpPost]
    [Route("wizard/step2")]
    public ActionResult Step2(WizardModel model)
    {
        if (ModelState.IsValidField("Step2"))
        {
            return View("Step3", model);
        }

        return View("Step2", model);
    }

    [HttpPost]
    [Route("wizard/step3")]
    public ActionResult Step3(WizardModel model)
    {
        // all of the data for the wizard model is complete.
        // so now we check the entire model state
        if (ModelState.IsValid)
        {
            // validation succeeded. save the data from the model.
            // the model.IsNew is just if you want users to be able to
            // edit their existing data.
            if (model.IsNew)
                database.NewData(model);
            else
                database.EditData(model);

            return RedirectToAction("Success");
        }

        return View("Step3", model);
    }
}

Here are your VIEWS

Step 1

@model WizardModel

@{
    ViewBag.Title = "Step 1";
}

@using (Html.BeginForm("Step1", "Wizard", FormMethod.Post))
{
    @Html.MinHiddenFor(m => m.WizardID)
    @Html.MinHiddenFor(m => m.WizardType)

    @Html.LabelFor(m => m.Step1.FirstName)
    @Html.TextBoxFor(m => m.Step1.FirstName)

    @Html.LabelFor(m => m.Step1.LastName)
    @Html.TextBoxFor(m => m.Step1.LastName)

    <button type="submit">Submit</button>
}

Step 2

@model WizardModel

@{
    ViewBag.Title = "Step 2";
}

@using (Html.BeginForm("Step2", "Wizard", FormMethod.Post))
{
    @Html.MinHiddenFor(m => m.WizardID)
    @Html.MinHiddenFor(m => m.WizardType)
    @Html.HiddenClassFor(m => m.Step1)

    @Html.LabelFor(m => m.Step2.Biography)
    @Html.TextAreaFor(m => m.Step2.Biography)

    <button type="submit">Submit</button>
}

Step 3

@model WizardModel

@{
    ViewBag.Title = "Step 3";
}

@using (Html.BeginForm("Step3", "Wizard", FormMethod.Post))
{
    @Html.MinHiddenFor(m => m.WizardID)
    @Html.MinHiddenFor(m => m.WizardType)
    @Html.HiddenClassFor(m => m.Step1)
    @Html.HiddenClassFor(m => m.Step2)

    @Html.LabelFor(m => m.Step3.FavoriteFoods)
    @Html.ListBoxFor(m => m.Step3.FavoriteFoods,
        new SelectListItem[]
        {
            new SelectListItem() { Value = "Pizza", Text = "Pizza" },
            new SelectListItem() { Value = "Sandwiches", Text = "Sandwiches" },
            new SelectListItem() { Value = "Burgers", Text = "Burgers" },
        });

    <button type="submit">Submit</button>
}
Briarwood answered 27/6, 2015 at 1:34 Comment(1)
Could you further clarify your solution by providing the view model and the controller?Assembled
A
5

Wizards are just simple steps in processing a simple model. There is no reason to create multiple models for a wizard. All you would do is create a single model and pass it between actions in a single controller.

public class MyModel
{
     [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
     public Guid Id { get; set };
     public string StepOneData { get; set; }
     public string StepTwoData { get; set; }
}

The above coed is stupid simple so replace your fields in there. Next we start with a simple action that initiates our wizard.

    public ActionResult WizardStep1()
    {
        return View(new MyModel());
    }

This calls the view "WizardStep1.cshtml (if using razor that is). You can use the create template wizard if you want. We will just be redirecting the post to a different action.

<WizardStep1.cshtml>
@using (Html.BeginForm("WizardStep2", "MyWizard")) {

The thing of note is that we will be posting this to a different action; the WizardStep2 action

    [HttpPost]
    public ActionResult WizardStep2(MyModel myModel)
    {
        return ModelState.IsValid ? View(myModel) : View("WizardStep1", myModel);
    }

In this action we check if our model is valid, and if so we send it to our WizardStep2.cshtml view else we send it back to step one with the validation errors. In each step we send it to the next step, validate that step and move on. Now some savvy developers might say well we can't move between steps such as this if we use [Required] attributes or other data annotations between steps. And you would be right, so remove the errors on items that are yet to be checked. like below.

    [HttpPost]
    public ActionResult WizardStep3(MyModel myModel)
    {
        foreach (var error in ModelState["StepTwoData"].Errors)
        {
            ModelState["StepTwoData"].Errors.Remove(error);
        }

Finally we would save the model once to the data store. This also prevents a user that starts a wizard but doesn't finish it not to save incomplete data to the database.

I hope you find this method of implementing a wizard much easier to use and maintain than any of the previously mentioned methods.

Thanks for reading.

Anzio answered 20/9, 2012 at 14:8 Comment(1)
do you have this in a complete solution I can try out? ThanksRunning
C
2

Adding more info from @Darin's answer.

What if you have separate design style for each steps and wanted maintain each in separate partial view or what if you have multiple properties for each step ?

While using Html.EditorFor we have limitation to use partial view.

Create 3 Partial Views under Shared folder named : Step1ViewModel.cshtml , Step3ViewModel.cshtml , Step3ViewModel.cshtml

For brevity I just posting 1st patial view, other steps are same as Darin's answer.

Step1ViewModel.cs

[Serializable]
public class Step1ViewModel : IStepViewModel
{
  [Required]
  public string FirstName { get; set; }

  public string LastName { get; set; }

  public string PhoneNo { get; set; }

  public string EmailId { get; set; }

  public int Age { get; set; }

 }

Step1ViewModel.cshtml

 @model WizardPages.ViewModels.Step1ViewModel

<div class="container">
    <h2>Personal Details</h2>

    <div class="form-group">
        <label class="control-label col-sm-2" for="email">First Name:</label>
        <div class="col-sm-10">
            @Html.TextBoxFor(x => x.FirstName)
        </div>
    </div>
    <div class="form-group">
        <label class="control-label col-sm-2" for="pwd">Last Name:</label>
        <div class="col-sm-10">
            @Html.TextBoxFor(x => x.LastName)
        </div>
    </div>
    <div class="form-group">
        <label class="control-label col-sm-2" for="pwd">Phone No:</label>
        <div class="col-sm-10"> 
            @Html.TextBoxFor(x => x.PhoneNo)
        </div>
    </div>
    <div class="form-group">
        <label class="control-label col-sm-2" for="pwd">Email Id:</label>
        <div class="col-sm-10">
            @Html.TextBoxFor(x => x.EmailId)
        </div>
    </div>


</div>

Index.cshtml

@using Microsoft.Web.Mvc
@model WizardPages.ViewModels.WizardViewModel

@{
    var currentStep = Model.Steps[Model.CurrentStepIndex];

    string viewName = currentStep.ToString().Substring(currentStep.ToString().LastIndexOf('.') + 1);
}

<h3>Step @(Model.CurrentStepIndex + 1) out of @Model.Steps.Count</h3>

@using (Html.BeginForm())
{
    @Html.Serialize("wizard", Model)

    @Html.Hidden("StepType", Model.Steps[Model.CurrentStepIndex].GetType())

    @Html.Partial(""+ viewName + "", currentStep);

    if (Model.CurrentStepIndex > 0)
    {

     <input type="submit" value="Previous" name="prev" class="btn btn-warning" />

    }

    if (Model.CurrentStepIndex < Model.Steps.Count - 1)
    {

      <input type="submit" value="Next" name="next" class="btn btn-info" />

    }
    else
    {

      <input type="submit" value="Finish" name="finish" class="btn btn-success" />

    }
}

If there some better solution, please comment to let others know.

Coraciiform answered 10/1, 2018 at 12:46 Comment(0)
B
-9

One option is to create set of identical tables that will store the data collected in each step. Then in the last step if all goes well you can create the real entity by copying the temporary data and store it.

Other is to create Value Objects for each step and store then in Cache or Session. Then if all goes well you can create your Domain object from them and save it

Barham answered 19/6, 2011 at 14:4 Comment(3)
Would be nice if people who down vote also give their reason.Ellga
Did not down vote you, But your answer is completely irrelevant to the question. The OP is asking on how to create the wizard, while you reply on how to handle the response in the back.Saddlery
i usually don't vote, but when i do, i make sure its upvote :-)Posterity

© 2022 - 2024 — McMap. All rights reserved.