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!
The HTML output shows clearly that all the select
s 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 select
s 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);
}
}
Each component only binds data, base on the defined model so you always have the same name fields in the result. In razor you can pass viewdata to the component. you should create custom viewdata for your components.
@{
var myViewComposite1VD = new ViewDataDictionary(ViewData);
myViewComposite1VD.TemplateInfo.HtmlFieldPrefix = "MyViewComposite1";
var myViewComposite2VD = new ViewDataDictionary(ViewData);
myViewComposite2VD.TemplateInfo.HtmlFieldPrefix = "MyViewComposite2";
var myViewComposite3VD = new ViewDataDictionary(ViewData);
myViewComposite3VD.TemplateInfo.HtmlFieldPrefix = "MyViewComposite3";
}
<form id="f1" method="post" data-ajax="true" data-ajax-method="post">
<vc:my-example composite="MyViewComposite1" view-data="myViewComposite1VD " />
<vc:my-example composite="MyViewComposite2" view-data="myViewComposite2VD"/>
<vc:my-example composite="MyViewComposite3" view-data="myViewComposite3VD "/>
</form>
As you see you can change the bindings using TemplateInfo.HtmlFieldPrefix
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With