Search code examples
c#asp.net-mvc-partialviewasp.net-core-tag-helpers

C# | Cannot convert 'string' to 'ModelExpression'


I want to make a partial view for form fields, so I don't have to write ~10 lines of boilerplate code everytime.

I tried the following:

FieldViewModel.cs

using Microsoft.AspNetCore.Mvc.ViewFeatures;

public class FieldViewModel
{
    public FieldViewModel(ModelExpression Data, string Label = "")
    {
        this.Data = Data;
        this.Label = Label;
    }

    public ModelExpression Data { get; set; }

    public string Label { get; set; }
}

_Field.cshtml (I stripped out the html code for this example)

// ...
<input asp-for="@Model.Data" />
// ...

and then I call it like this:

@await Html.PartialAsync("_Field", new FieldViewModel(Model.FirstName, "First Name"))

Obviously, this doesn't work because we pass string and it expects ModelExpression. Is there any way to make work? Maybe build somehow the ModelExpression manually (not sure how) and then pass it?


Solution

  • I've tried something and it works so far, maybe there are still some edge cases that you have to consider and some more TagHelpers that you should extend, but basically it looks good.

    First I have a few taghelpers extended, for label input and span.

    using Microsoft.AspNetCore.Mvc.TagHelpers;
    using Microsoft.AspNetCore.Mvc.ViewFeatures;
    using Microsoft.AspNetCore.Razor.TagHelpers;
    
    using System.Threading.Tasks;
    

    In the following cases, "asp-bind" is the name of the attribute that accepts the ModelExpression and reassigns it to the "asp-for" property.

    [HtmlTargetElement("label", Attributes = "asp-bind")]
    public class FormGroupLabelTagHelper : LabelTagHelper
    {
        public FormGroupLabelTagHelper(IHtmlGenerator generator)
            : base(generator) { }
    
        [HtmlAttributeName("asp-bind")]
        public ModelExpression AspBind
        {
            get => base.For;
            set => base.For = value.Model as ModelExpression;
        }
    
        public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
            => base.ProcessAsync(context, output);
    }
    

    ...

    [HtmlTargetElement("input", Attributes = "asp-bind")]
    public class FormGroupInputTagHelper : InputTagHelper
    {
        public FormGroupInputTagHelper(IHtmlGenerator generator)
            : base(generator) { }
    
        [HtmlAttributeName("asp-bind")]
        public ModelExpression AspBind
        {
            get => base.For;
            set => base.For = value.Model as ModelExpression;
        }
    
        public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) 
            => base.ProcessAsync(context, output);
    }
    

    ...

    [HtmlTargetElement("span", Attributes = "asp-bind")]
    public class FormGroupErrorTagHelper : ValidationMessageTagHelper
    {
        public FormGroupErrorTagHelper(IHtmlGenerator generator)
            : base(generator) { }
    
        [HtmlAttributeName("asp-bind")]
        public ModelExpression AspBind
        {
            get => base.For;
            set => base.For = value.Model as ModelExpression;
        }
    
        public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) 
            => base.ProcessAsync(context, output);
    }
    

    Then I have created a partial view that takes as model a ModelExpression.

    @model ModelExpression
    
    <div class="form-group">
        <label asp-bind="@this.Model" class="col-form-label-sm mb-0 pb-0"></label>
        <input asp-bind="@this.Model" class="form-control" />
        <span asp-bind="@this.Model" class="small text-danger"></span>
    </div>
    

    and in the end used like bellow

    <form asp-controller="Home" asp-action="Index" method="post">
        <fieldset class="card card-body m-0 p-3">
            <legend class="w-auto px-1 py-0 m-0 small font-weight-bold ">With Tag Helpers</legend>
            <partial name="TestGroup" model="this.ModelExpressionProvider.CreateModelExpression(this.ViewData, x => x.Name)" />
            <partial name="TestGroup" model="this.ModelExpressionProvider.CreateModelExpression(this.ViewData, x => x.Age)" />
        </fieldset>
        <div class="d-flex mt-3">
            <input type="submit" class="btn btn-primary px-5" value="Sublit" />
        </div>
    </form>
    

    It looks like the created ModelExpression is wrapped in another ModelExpression, where the Model is the actual ModelExpression. I turned it into a ModelExpression and gave it to the For property into already extended TagHelpers.

    If you consider go this way you do not need your model wrapper any more, instead of label you can decorate your properties with some Attributes like bellow

    using System.ComponentModel.DataAnnotations;
    
    public class ExampleModel
    {
        [Required]
        [Display(Name = "Fullname")]
        public string Name { get; set; }
    
        [Range(18, 100)]
        [Display(Name = "Age")]
        public int Age { get; set; }
    
        public ExampleModel InnerModel { get; set; }
    }