Search code examples
c#asp.net-mvcasp.net-corerazor-pagesmodel-binding

Multiple view components in .net core razor page not binding correctly


I am creating a .net core 5 web application using razor pages and am struggling with binding view components that I have created to my page -- IF I have multiple of the same view component on the page.

The below works perfectly:

MyPage.cshtml:

@page
@model MyPageModel
<form id="f1" method="post" data-ajax="true" data-ajax-method="post">
    <vc:my-example composite="Model.MyViewComposite1" />
</form>

MyPage.cshtml.cs

[BindProperties]
public class MyPageModel : PageModel
{
    public MyViewComposite MyViewComposite1 { get; set; }

    public void OnGet()
    {
        MyViewComposite1 = new MyViewComposite() { Action = 1 };
    }

    public async Task<IActionResult> OnPostAsync()
    {
        // checking on the values of MyViewComposite1 here, all looks good...
        // ...
        return null;
    }
}

MyExampleViewComponent.cs:

public class MyExampleViewComponent : ViewComponent
{
    public MyExampleViewComponent() { }
    public IViewComponentResult Invoke(MyViewComposite composite)
    {
        return View("Default", composite);
    }
}

Default.cshtml (my view component):

@model MyViewComposite
<select asp-for="Action">
    <option value="1">option1</option>
    <option value="2">option2</option>
    <option value="3">option3</option>
</select>

MyViewComposite.cs

public class MyViewComposite
{
    public MyViewComposite() {}
    public int Action { get; set; }
}

So up to this point, everything is working great. I have a dropdown, and if I change that dropdown and inspect the value of this.MyViewComposite1 in my OnPostAsync() method, it changes to match what I select.

However, I now want to have MULTIPLE of the same view component on the page. Meaning I now have this:

MyPage.cshtml:

<form id="f1" method="post" data-ajax="true" data-ajax-method="post">
    <vc:my-example composite="Model.MyViewComposite1" />
    <vc:my-example composite="Model.MyViewComposite2" />
    <vc:my-example composite="Model.MyViewComposite3" />
</form>

MyPage.cshtml:

[BindProperties]
public class MyPageModel : PageModel
{
    public MyViewComposite MyViewComposite1 { get; set; }
    public MyViewComposite MyViewComposite2 { get; set; }
    public MyViewComposite MyViewComposite3 { get; set; }

    public void OnGet()
    {
        MyViewComposite1 = new MyViewComposite() { Action = 1 };
        MyViewComposite2 = new MyViewComposite() { Action = 1 };
        MyViewComposite3 = new MyViewComposite() { Action = 2 };
    }

    public async Task<IActionResult> OnPostAsync()
    {
        // checking on the values of the above ViewComposite items here...
        // Houston, we have a problem...
        // ...
        return null;
    }
}

I now have three downdowns showing on the page as I would expect, and those three dropdowns are all populated correctly when the page loads. So far so good!

But let's say that I selection "option3" in the first dropdown and submit the form. All of my ViewComposites (MyViewComposite1, MyViewComposite2 and MyViewComposite3) ALL show the same value for Action, even if the dropdowns all have different options selected.

I believe that I see WHY this is happening when I inspect the controls using dev tools:

<select name="Action">...</select>
<select name="Action">...</select>
<select name="Action">...</select>

As you can see, what is rendered is three identical options, all with the same name of "Action". I had hoped that giving them different ids would perhaps help, but that didn't make a difference:

<select name="Action" id="action1">...</select>
<select name="Action" id="action2">...</select>
<select name="Action" id="action3">...</select>

This is obviously a slimmed down version of what I am trying to do, as the view components have a lot more in them than a single dropdown, but this illustrates the problem that I am having...

Is there something I am missing to make this work?

Any help would be greatly appreciated!


Solution

  • The HTML output shows clearly that all the selects have the same name of Action which will cause the issue you are encountering. Each ViewComponent has no knowledge about its parent view model (of the parent view in which it's used). So basically you need somehow to pass that prefix info to each ViewComponent and customize the way name attribute is rendered (by default, it's affected only by using asp-for).

    To pass the prefix path, we can take advantage of using ModelExpression for your ViewComponent's parameters. By using that, you can extract both the model value & the path. The prefix path can be shared in the scope of each ViewComponent only by using its ViewData. We need a custom TagHelper to target all elements having asp-for and modify the name attribute by prefixing it with the prefix shared through ViewData. That will help the final named elements have their name generated correctly, so the model binding will work correctly after all.

    Here is the detailed code:

    [HtmlTargetElement(Attributes = "asp-for")]
    public class NamedElementTagHelper : TagHelper
    {
        [ViewContext]
        [HtmlAttributeNotBound]
        public ViewContext ViewContext { get; set; }
        public override void Process(TagHelperContext context, TagHelperOutput output)
        {          
            //get the name-prefix shared through ViewData
            //NOTE: this ViewData is specific to each ViewComponent
            if(ViewContext.ViewData.TryGetValue("name-prefix", out var namePrefix) &&
               !string.IsNullOrEmpty(namePrefix?.ToString()) &&
               output.Attributes.TryGetAttribute("name", out var attrValue))
            {
                //format the new name with prefix
                //and set back to the name attribute
                var prefixedName = $"{namePrefix}.{attrValue.Value}";
                output.Attributes.SetAttribute("name", prefixedName);
            }
        }
    }
    

    You need to modify your ViewComponent to something like this:

    public class MyExampleViewComponent : ViewComponent
    {
       public MyExampleViewComponent() { }
       public IViewComponentResult Invoke(ModelExpression composite)
       {
         if(composite?.Name != null){
             //share the name-prefix info through the scope of the current ViewComponent
             ViewData["name-prefix"] = composite.Name;
         }
         return View("Default", composite?.Model);
       }
    }
    

    Now use it using tag helper syntax (note: the solution here is convenient only when using tag helper syntax with vc:xxx tag helper, the other way of using IViewComponentHelper may require more code to help pass the ModelExpression):

    <form id="f1" method="post" data-ajax="true" data-ajax-method="post">
      <vc:my-example composite="MyViewComposite1" />
      <vc:my-example composite="MyViewComposite2" />
      <vc:my-example composite="MyViewComposite3" />
    </form>
    

    Note about the change to composite="MyViewComposite1", as before you have composite="Model.MyViewComposite1". That's because the new composite parameter now requires a ModelExpression, not a simple value.

    With this solution, now your selects should be rendered like this:

    <select name="MyViewComposite1.Action">...</select>
    <select name="MyViewComposite2.Action">...</select>
    <select name="MyViewComposite3.Action">...</select>
    

    And then the model binding should work correctly.

    PS: Final note about using a custom tag helper (you can search for more), without doing anything, the custom tag helper NamedElementTagHelper won't work. You need to add the tag helper at best in the file _ViewImports.cshtml closest to the scope of where you use it (here your ViewComponent's view files):

    @addTagHelper *, [your assembly fullname without quotes]
    

    To confirm that the tag helper NamedElementTagHelper works, you can set a breakpoint in its Process method before running the page containing any elements with asp-for. The code should hit in there if it's working.

    UPDATE:

    Borrowed from @(Shervin Ivari) about the using of ViewData.TemplateInfo.HtmlFieldPrefix, we can have a much simpler solution and don't need the custom tag helper NamedElementTagHelper at all (although in a more complicated scenario, that solution of using a custom tag helper may be more powerful). So here you don't need that NamedElementTagHelper and update your ViewComponent to this:

    public class MyExampleViewComponent : ViewComponent
    {
       public MyExampleViewComponent() { }
       public IViewComponentResult Invoke(ModelExpression composite)
       {
         if(composite?.Name != null){             
             ViewData.TemplateInfo.HtmlFieldPrefix = composite.Name;
         }
         return View("Default", composite?.Model);
       }
    }