ModelMetaData, Custom Class Attributes and an indescribable question
Asked Answered
L

2

6

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.

Lyublin answered 26/7, 2011 at 18:35 Comment(0)
D
11

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

Diagenesis answered 27/7, 2011 at 23:38 Comment(4)
This looks amazing. You lit a fire under me again...I had given up and just slapped a property on my interface that all my concrete classes implemented String WizardStepName..Lyublin
This introduces the same problem I had before....@Html.WizardStepLabelFor(model => model) the model passed in is always of type IStepViewModel. so, no attributes get added, since the annotations are on the concrete class implemenations of the interfaceLyublin
Doug, in my test view, I used @model MyApp.Models.IStepViewModel, and in my controller, I passed a new Step1 model to the view. The annotations to the Step1 model came through, and I got the desired label in the generated HTML. Perhaps I do not understand your requirements correctly. Though I am confused as to why you are not passing the concrete models to the view, since you appear to have everything needed to pass the concrete models.Diagenesis
Well, I'll assume the problem is due to my limited knowledge. For now.Lyublin
W
4

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);
        }
    }
}
Wingless answered 8/3, 2018 at 14:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.