views:

2527

answers:

4

I've spent the majority of the past week knee deep in the new templating functionality baked into MVC2. I had a hard time trying to get a DropDownList template working. The biggest problem I've been working to solve is how to get the source data for the drop down list to the template. I saw a lot of examples where you can put the source data in the ViewData dictionary (ViewData["DropDownSourceValuesKey"]) then retrieve them in the template itself (var sourceValues = ViewData["DropDownSourceValuesKey"];) This works, but I did not like having a silly string as the lynch pin for making this work.

Below is an approach I've come up with and wanted to get opinions on this approach:

here are my design goals:

  • The view model should contain the source data for the drop down list
  • Limit Silly Strings
  • Not use ViewData dictionary
  • Controller is responsible for filling the property with the source data for the drop down list

Here's my View Model:

   public class CustomerViewModel
    {
        [ScaffoldColumn(false)]
        public String CustomerCode{ get; set; }

        [UIHint("DropDownList")]
        [DropDownList(DropDownListTargetProperty = "CustomerCode"]
        [DisplayName("Customer Code")]
        public IEnumerable<SelectListItem> CustomerCodeList { get; set; }

        public String FirstName { get; set; }
        public String LastName { get; set; }
        public String PhoneNumber { get; set; }
        public String Address1 { get; set; }
        public String Address2 { get; set; }
        public String City { get; set; }
        public String State { get; set; }
        public String Zip { get; set; }
    }

My View Model has a CustomerCode property which is a value that the user selects from a list of values. I have a CustomerCodeList property that is a list of possible CustomerCode values and is the source for a drop down list. I've created a DropDownList attribute with a DropDownListTargetProperty. DropDownListTargetProperty points to the property which will be populated based on the user selection from the generated drop down (in this case, the CustomerCode property).

Notice that the CustomerCode property has [ScaffoldColumn(false)] which forces the generator to skip the field in the generated output.

My DropDownList.ascx file will generate a dropdown list form element with the source data from the CustomerCodeList property. The generated dropdown list will use the value of the DropDownListTargetProperty from the DropDownList attribute as the Id and the Name attributes of the Select form element. So the generated code will look like this:

<select id="CustomerCode" name="CustomerCode">
  <option>...
</select>

This works out great because when the form is submitted, MVC will populate the target property with the selected value from the drop down list because the name of the generated dropdown list IS the target property. I kinda visualize it as the CustomerCodeList property is an extension of sorts of the CustomerCode property. I've coupled the source data to the property.

Here's my code for the controller:

public ActionResult Create()
{
    //retrieve CustomerCodes from a datasource of your choosing
    List<CustomerCode> customerCodeList = modelService.GetCustomerCodeList();

    CustomerViewModel viewModel= new CustomerViewModel();
    viewModel.CustomerCodeList = customerCodeList.Select(s => new SelectListItem() { Text = s.CustomerCode, Value = s.CustomerCode, Selected = (s.CustomerCode == viewModel.CustomerCode) }).AsEnumerable();

    return View(viewModel);
}

Here's my code for the DropDownListAttribute:

namespace AutoForm.Attributes
{
    public class DropDownListAttribute : Attribute
    {
        public String DropDownListTargetProperty { get; set; }
    }
}

Here's my code for the template (DropDownList.ascx):

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<IEnumerable<SelectListItem>>" %>
<%@ Import Namespace="AutoForm.Attributes"%>

<script runat="server">
    DropDownListAttribute GetDropDownListAttribute()
    {
        var dropDownListAttribute = new DropDownListAttribute();

        if (ViewData.ModelMetadata.AdditionalValues.ContainsKey("DropDownListAttribute"))
        {
            dropDownListAttribute = (DropDownListAttribute)ViewData.ModelMetadata.AdditionalValues["DropDownListAttribute"];
        }

        return dropDownListAttribute;        
    }
</script>    

<% DropDownListAttribute attribute = GetDropDownListAttribute();%>

<select id="<%= attribute.DropDownListTargetProperty %>" name="<%= attribute.DropDownListTargetProperty %>">
    <% foreach(SelectListItem item in ViewData.Model) 
       {%>
        <% if (item.Selected == true) {%>
            <option value="<%= item.Value %>" selected="true"><%= item.Text %></option>
        <% } %>
        <% else {%>
            <option value="<%= item.Value %>"><%= item.Text %></option>
        <% } %>
    <% } %>
</select>

I tried using the Html.DropDownList helper, but it would not allow me to change the Id and Name attributes of the generated Select element.

NOTE: you have to override the CreateMetadata method of the DataAnnotationsModelMetadataProvider for the DropDownListAttribute. Here's the code for that:

public class MetadataProvider : DataAnnotationsModelMetadataProvider
{
    protected override ModelMetadata CreateMetadata(IEnumerable<Attribute> attributes, Type containerType, Func<object> modelAccessor, Type modelType, string propertyName)
    {
        var metadata = base.CreateMetadata(attributes, containerType, modelAccessor, modelType, propertyName);
        var additionalValues = attributes.OfType<DropDownListAttribute>().FirstOrDefault();

        if (additionalValues != null)
        {
            metadata.AdditionalValues.Add("DropDownListAttribute", additionalValues);
        }

        return metadata;
    }
}

Then you have to make a call to the new MetadataProvider in Application_Start of Global.asax.cs:

protected void Application_Start()
{
    RegisterRoutes(RouteTable.Routes);

    ModelMetadataProviders.Current = new MetadataProvider();
}

Well, I hope this makes sense and I hope this approach may save you some time. I'd like some feedback on this approach please. Is there a better approach?

+1  A: 

Perfect. This is what I'm looking for. Thanks!

But your example model is simple model. How about a complex viewmodel like

public class MaintainServicePackageViewModel
{
    public IEnumerable<ServicePackageWithOwnerName> ServicePackageWithOwnerName { get; set; }
    public ServicePackageWithOwnerName CurrentServicePackage { get; set; }
    public IEnumerable<ServiceWithPackageName> ServiceInPackage { get; set; }
}

public class ServicePackageWithOwnerName : ServicePackage
{
    [UIHint("DropDownList")]
    [DropDownList(DropDownListTargetProperty = "Owner")]
    [DisplayNameLocalized(typeof(Resources.Globalization), "OwnerName")]
    public IEnumerable<SelectListItem> OwnerName { get; set; }
}

The OwnerName is set to a dropdownlist, but it is not a direct element of the viewmodel instead it's a child element of ServicePackageWithOwnerName which is the element of the viewmodel. In such condition, there's no way to set the OwnerName value in the controller, how to fix this? Appreciate!

Regards

Jack

Jack
The first thing that comes to mind is to create a custom editor template for ServicePackageWithOwnerName. The ServicePackageWithOwnerName custom template would emit a dropdownbox for the OwnerName property. In MaintainServicePackageViewModel, you would adorn CurrentServicePackage with UIHint("[ServicePackageWithOwnerName Tempalte Name]"). I'm planning on exploring this concept, but have not had time to do so yet.
tschreck
Jack- Check out this link, it may be what you need:http://dotnetslackers.com/articles/aspnet/asp-net-mvc-2-0-templating.aspx
tschreck
Also, check this link out. Brad Wilson has created a very good series on doing templates. This link deals with nested complex types.http://bradwilson.typepad.com/blog/2009/10/aspnet-mvc-2-templates-part-4-custom-object-templates.html
tschreck
A: 

Love the solution and it works perfectly when using Html.EditorFor(m => m.CustomerCodeList). But for some reason it doesn't work when using Html.EditorForModel(). Can anyone point me in the right direction to solving this?

Brian
+1  A: 

I think I found a solution to make it work when using Html.EditorForModel(); When using EditorForModel(), MVC uses Object.ascx to loop through all properties of the model and calls the corresponding template for each property in the model. ASP.Net MVC out of the box has Object.ascx in code, but you can create your own Object.ascx. Just create an EditorTemplates subfolder in your Shared View folder. Create an Object.ascx file there. (read this post for more information: http://bradwilson.typepad.com/blog/2009/10/aspnet-mvc-2-templates-part-3-default-templates.html)

Here's my Object.ascx:

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl" %>
<%@ Import Namespace="WebAppSolutions.Helpers" %>
<% if (ViewData.TemplateInfo.TemplateDepth > 1) { %>
<%= ViewData.ModelMetadata.SimpleDisplayText%>
<% }
else { %>    
<% foreach (var prop in ViewData.ModelMetadata.Properties.Where(pm => pm.ShowForEdit && !ViewData.TemplateInfo.Visited(pm))) { %>

    <% var htmlFieldName = Html.HtmlFieldNameFor(prop.PropertyName);%>

    <% if (prop.HideSurroundingHtml) { %>
        <%= Html.Editor(htmlFieldName)%>
    <% }
       else { %>
        <div id="<%= htmlFieldName %>Container" class="editor-field">
            <% if (!String.IsNullOrEmpty(Html.Label(prop.PropertyName).ToHtmlString())) { %>
                <%= Html.Label(prop.PropertyName, Html.HtmlDisplayName(prop.PropertyName), prop.IsRequired)%>
            <% } %>
            <%= Html.Editor(prop.PropertyName, "", htmlFieldName)%>
            <%= Html.ValidationMessage(prop.PropertyName, "*") %>
        </div>
    <% } %>
<% } %>

<% } %>

I have some custome code in my WebAppSolutions.Helpers for HtmlFieldNameFor and HtmlDisplayName. These helpers retrieve data from attributes applied to properties in the view model.

    public static String HtmlFieldNameFor<TModel>(this HtmlHelper<TModel> html, String propertyName)
    {
        ModelMetadata modelMetaData = GetModelMetaData(html, propertyName);
        return GetHtmlFieldName(modelMetaData, propertyName);
    }

    public static String HtmlDisplayName<TModel>(this HtmlHelper<TModel> html, String propertyName)
    {
        ModelMetadata modelMetaData = GetModelMetaData(html, propertyName);
        return modelMetaData.DisplayName ?? propertyName;
    }
    private static ModelMetadata GetModelMetaData<TModel>(HtmlHelper<TModel> html, String propertyName)
    {
        ModelMetadata modelMetaData = ModelMetadata.FromStringExpression(propertyName, html.ViewData);
        return modelMetaData;
    }

    private static String GetHtmlFieldName(ModelMetadata modelMetaData, string defaultHtmlFieldName)
    {
        PropertyExtendedMetaDataAttribute propertyExtendedMetaDataAttribute = GetPropertyExtendedMetaDataAttribute(modelMetaData);
        return propertyExtendedMetaDataAttribute.HtmlFieldName ?? defaultHtmlFieldName;
    }

The key to getting this to work using EditorModelFor() is this (should be line 20 or so in Object.ascx above):

<%= Html.Editor(prop.PropertyName, "", htmlFieldName)%>

prop.PropertyName is the property in the ViewModel containing the list of data that will become the DropDownList. htmlFieldName is the name of the property that's hidden that the DropDownList property is replacing. Make sense?

I hope this helps you.

Tom Schreck
A: 

Hello Tom, I tried your solution and it works fine. The only thing from keeping it from being perfect is that the dropdownlist is not part of the validation. If I would like to have a "choose" option (added manually in the EditorTemplate) as default and no value is chosen it results in an error. I believe it's because the DropDownList is not created from the DropDownListFor - Helper? I know you wrote that you tried that one, but is there no way of adding items to the SelectList in the loop with the custom values? I cannot do it since I'm a beginner but I think it would be really great if the dropdown could be inlcuded in the validation.

best regards Max

max
@max: Welcome to Stackoverflow. Rather post your question as a comment to Tom's answer or start your own question for help :).
FreshCode
Thank you. I started a new question with a link to this one, Thanks for the tip. :)
max