I would like to bind a model expression (such as a property) to a view component—much like I would with an HTML helper (e.g., @Html.EditorFor()
) or a tag helper (e.g., <partial for />
)—and reuse this model in the view with nested HTML and/or tag helpers. I am able to define a ModelExpression
as a parameter on a view component, and retrieve a lot of useful metadata from it. Beyond this, I start running into roadblocks:
asp-for
tag helper?ViewData.ModelMetadata
are honored?HtmlFieldPrefix
for the field name
attribute?I've provided a (simplified) scenario with code and outcomes below—but the code exposes more unknowns than answers. Much of the code is known to be incorrect, but I'm including it so we can have a concrete baseline to evaluate and discuss alternatives to.
The values of a <select>
list need to be populated via a data repository. Assume it is impractical or undesirable to populate the possible values as part of e.g. the original view model (see "Alternate Options" below).
/Components/SelectListViewComponent.cs
using system;
using Microsoft.AspNetCore.Mvc.Rendering;
public class SelectViewComponent
{
private readonly IRepository _repository;
public SelectViewComponent(IRepository repository)
{
_repository = repository?? throw new ArgumentNullException(nameof(repository));
}
public IViewComponentResult Invoke(ModelExpression aspFor)
{
var sourceList = _repository.Get($"{aspFor.Metadata.Name}Model");
var model = new SelectViewModel()
{
Options = new SelectList(sourceList, "Id", "Name")
};
ViewData.TemplateInfo.HtmlFieldPrefix = ViewData.TemplateInfo.GetFullHtmlFieldName(modelMetadata.Name);
return View(model);
}
}
Notes
ModelExpression
not only allows me to call the view component with a model expression, but also gives me a lot of useful metadata via reflection such as validation parameters.for
is illegal in C#, since it's a reserved keyword. As such, I'm instead using aspFor
, which will be exposed to the tag helper format as asp-for
. This is a bit of a hack, but yields a familiar interface for developers._repository
code and logic will vary considerably with implementation. In my own use case, I actually pull the arguments from some custom attributes.GetFullHtmlFieldName()
doesn't construct a full HTML field name; it always returns whatever value I submit to it, which is just the model expression name. More on this under "Issues" below./Models/SelectViewModel.cs
using Microsoft.AspNetCore.Mvc.Rendering;
public class SelectViewModel {
public SelectList Options { get; set; }
}
Notes
SelectList
directly to the view, since it will handle the current value. However, if you bind your model to your <select>
's asp-for
tag helper, then it will automatically enable multiple
, which is the default behavior when binding to a collection model. /Views/Shared/Select/Default.cshtml
@model SelectViewModel
<select asp-for=@Model asp-items="Model.Options">
<option value="">Select one…</option>
</select>
Notes
@Model
will return SelectViewModel
. If this were an <input />
that would be obvious. This issue is obscured due to the SelectList
identifying the correct value, presumably from the ViewData.ModelMetadata
.aspFor.Model
to e.g. an UnderlyingModel
property on the SelectViewModel
. That would result in an HTML field name of {HtmlFieldPrefix}.UnderlyingModel
—and would still fail to retrieve any of the metadata (such as validation attributes) from the original property.If I don't set the HtmlFieldPrefix
, and place the view component within the context of e.g. a <partial for />
or @Html.EditorFor()
then the field names will be correct, as the HtmlFieldPrefix
is getting defined in a parent context. If I place it directly in a top-level view, however, I will get the following error due to the HtmlFieldPrefix
not being defined:
ArgumentException: The name of an HTML field cannot be null or empty. Instead use methods Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper.Editor or Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper``1.EditorFor with a non-empty htmlFieldName argument value. (Parameter 'expression')
HtmlFieldPrefix
doesn't get properly populated with a fully qualified value. E.g., if the model property name is Country
it will always return Country
, even if the actual model path is, say, ShippingAddress.Country
or Addresses[2].Country
.[Required]
then that's not getting flagged here. That's presumably because it's being bound to the SelectViewModel
, not the parent property.SelectList
is able to infer the original value from ViewData
, but that is lost to the view. I could relay the aspFor.Model
via the view model, but it won't have access to the original metadata (such as validation attributes).Some other options I've considered, and rejected for my use cases.
IViewComponentActivator
. Country
for the value, CountryList
for the options). That may not be practical or elegant in more sophisticated examples.<select>
element on the client. I use this approach in other applications, but it's undesirable here since I don't want to expose the full range of potential query logic to a public interface.ModelExpression
in order to recreate the parent context under the view component. That's a bit of a kludge, so I'd like to game out the ModelExpression
approach first.This question has been asked (and answered) before:
In both cases, however, the accepted answer (one by the OP) doesn't fully explore the question, and instead decides that a tag helper is more suitable for their scenarios. Tag helpers are great, and have their purpose; I'd like to fully explore the original questions, however, for the scenarios where view components are more appropriate (such as depending on an external service).
Am I chasing a rabbit down a hole? Or are there options that the community's deeper understanding of model expressions can resolve?
A View Component class can be created in the following ways. Same as Controller class, View Component class must be non-abstract, public, and non-nested. This class fully supports constructor dependency injection.
Model binding allows controller actions to work directly with model types (passed in as method arguments), rather than HTTP requests. Mapping between incoming request data and application models is handled by model binders.
View Components in Controller Methods Rather than passing the name of your view component, you can pass its Type object, giving you some IntelliSense support, as in this example: return ViewComponent(typeof(CustomerAddressViewComponent), new { CustomerId = "A123"});
A different view name can be specified when creating the view component result or when calling the View method. We recommend naming the view file Default. cshtml and using the Views/Shared/Components/{View Component Name}/{View Name} path.
To answer my own question in the negative: I ultimately came to the conclusion that while this may well be intuitive and desirable functionality in terms of our parent views, it's ultimately a confused concept in terms of our view components.
Even if you resolve the technical issue with extracting the fully-qualified HtmlFieldPrefix
from ModelExpression
, the deeper issue is conceptual. Presumably, the view component will assemble additional data, and relay it down to the view via a new view model—e.g., the SelectViewModel
proposed in the question. Otherwise, there's no real benefit to using a view component. In the view component's view, however, there's no logical way to map properties of the child view model back to the parent view model.
So, for example, let us say that in your parent view you bind the view component to a UserViewModel.Country
property:
@model UserViewModel
<vc:select asp-for="Country" />
Then, what properties do you bind to in the child view?
@model SelectViewModel
<select asp-for=@??? asp-items="Model.Options">
<option value="">Select one…</option>
</select>
In my original question, I proposed @Model
, which is similar to what you would do in e.g. an editor template called via @Html.EditorFor()
:
<select asp-for=@Model asp-items="Model.Options">
<option value="">Select one…</option>
</select>
That might return the correct id
and name
attributes, since it's falling back to the HtmlFieldPrefix
of the ViewData
. But, it's not going to have access to any e.g. data validation attributes, since it's binding to a SelectViewModel
and not a reference to the original UserViewModel.Country
property, as it would in an editor template.
Similarly, you could relay the ModelExpression.Model
down via e.g. a SelectViewModel.Model
property…
<select asp-for=@Model asp-items="Model.Options">
<option value="">Select one…</option>
</select>
…but that doesn't solve the problem either since, obviously, relaying a value doesn't relay the attributes of the source property.
Ultimately, what you want is to bind your asp-for
to the original property on the original object that your ModelExpression
is resolving to. And while you can get metadata from ModelExpression
describing that property and object, there doesn't seem to be a way to relay a reference to it in a way that the asp-for
tag helpers recognize.
Obviously, one could conceive of Microsoft building in lower-level tooling into ModelExpression
and the core implementations of the asp-for
tag helpers which allow relaying ModelExpression
objects all the way down the line. Alternatively, they might establish a keyword—such as @ParentModel
—which allows a reference to the model from the parent view. In absence of that, however, this doesn't seem feasible.
I'm not going to mark this as the answer in hopes that someone, at some point, finds something I'm missing. I wanted to leave these notes here, however, in case anyone else is trying to make this work, and to document my own conclusions.
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