Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

ModelMetaData, Custom Class Attributes and an indescribable question

What I want to do seems so simple.

In my index.cshtml I want to display the WizardStepAttribute Value

So, a user will see at the top of each page, Step 1: Enter User Information


I have a ViewModel called WizardViewModel. This ViewModel has a property that is IList<IStepViewModel> Steps

each "step" implements the Interface IStepViewModel, which is an empty interface.

I have a view called Index.cshtml. This view displays EditorFor() the current step.

I have a custom ModelBinder, that binds the View to an new instance of the concrete class implementing IStepViewModel based on the WizardViewModel.CurrentStepIndex property

I have created a custom attribute WizardStepAttribute.

Each of my Steps classes are defined like this.

[WizardStepAttribute(Name="Enter User Information")] 
[Serializable]
public class Step1 : IStepViewModel
....

I have several problems though.

My View is strongly typed to WizardViewModel not each step. I don't want to have to create a view for each concrete implementation of IStepViewModel

I thought I could add a property to the interface, but then I have to explicitly implement it in each class. (So this isn't any better)

I'm thinking I could implement it using reflection in the interface but, you can't refer to instances in methods in an interface.

like image 722
Doug Chamberlain Avatar asked Jul 26 '11 18:07

Doug Chamberlain


2 Answers

It can be done, but it is neither easy nor pretty.

First, I would suggest adding a second string property to your WizardStepAttribute class, StepNumber, so that your WizardStepAttribute class looks like this:

[AttributeUsage(AttributeTargets.All, AllowMultiple = false)]
public class WizardStepAttribute : Attribute
{
    public string StepNumber { get; set; }
    public string Name { get; set; }
}

Then, each class must be decorated:

[WizardAttribute(Name = "Enter User Information", StepNumber = "1")]
public class Step1 : IStepViewModel
{
    ...
}

Next, you need to create a custom DataAnnotationsModelMetadataProvider, to take the values of your custom attribute and insert them into the Step1 model's metadata:

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

        if (additionalValues != null)
        {
            modelMetadata.AdditionalValues.Add("Name", additionalValues.Name);
            modelMetadata.AdditionalValues.Add("StepNumber", additionalValues.StepNumber);
        }
        return modelMetadata;
    }
}

Then, to present your custom metadata, I suggest creating a custom HtmlHelper to create your label for each view:

    [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures", Justification = "This is an appropriate nesting of generic types")]
    public static MvcHtmlString WizardStepLabelFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression)
    {
        return WizardStepLabelFor(htmlHelper, expression, null /* htmlAttributes */);
    }

    [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures", Justification = "This is an appropriate nesting of generic types")]
    public static MvcHtmlString WizardStepLabelFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, object htmlAttributes)
    {
        return WizardStepLabelFor(htmlHelper, expression, new RouteValueDictionary(htmlAttributes));
    }

    [SuppressMessage("Microsoft.Design", "CA1011:ConsiderPassingBaseTypesAsParameters", Justification = "Users cannot use anonymous methods with the LambdaExpression type")]
    [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures", Justification = "This is an appropriate nesting of generic types")]
    public static MvcHtmlString WizardStepLabelFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, IDictionary<string, object> htmlAttributes)
    {
        if (expression == null)
        {
            throw new ArgumentNullException("expression");
        }
        ModelMetadata metadata = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);
        var values = metadata.AdditionalValues;

        // build wizard step label
        StringBuilder labelSb = new StringBuilder();
        TagBuilder label = new TagBuilder("h3");
        label.MergeAttributes(htmlAttributes);
        label.InnerHtml = "Step " + values["StepNumber"] + ": " + values["Name"]; 
        labelSb.Append(label.ToString(TagRenderMode.Normal));

        return new MvcHtmlString(labelSb.ToString() + "\r");
    }

As you can see, the custom helper creates an h3 tag with your custom metadata.

Then, finally, in your view, put in the following:

@Html.WizardStepLabelFor(model => model)

Two notes: first, in your Global.asax.cs file, add the following to Application_Start():

        ModelMetadataProviders.Current = new MyModelMetadataProvider();

Second, in the web.config in the Views folder, make sure to add the namespace for your custom HtmlHelper class:

<system.web.webPages.razor>
  <host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
  <pages pageBaseType="System.Web.Mvc.WebViewPage">
    <namespaces>
      <add namespace="System.Web.Mvc" />
      <add namespace="System.Web.Mvc.Ajax" />
      <add namespace="System.Web.Mvc.Html" />
      <add namespace="System.Web.Routing" />
      <add namespace="YOUR NAMESPACE HERE"/>
    </namespaces>
  </pages>
</system.web.webPages.razor>

Voila.

counsellorben

like image 120
counsellorben Avatar answered Sep 28 '22 03:09

counsellorben


In our case we just needed an attribute that implements the IMetadataAware interface:

https://msdn.microsoft.com/en-us/library/system.web.mvc.imetadataaware(v=vs.118).aspx

In your case, this could be:

public class WizardStepAttribute : Attribute, IMetadataAware
{
    public string Name;

    public void OnMetadataCreated(ModelMetadata metadata)
    {
        if (!metadata.AdditionalValues.ContainsKey("WizardStep"))
        {
            metadata.AdditionalValues.Add("WizardStep", Name);
        }
    }
}
like image 24
Michaja Broertjes Avatar answered Sep 28 '22 01:09

Michaja Broertjes