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:
- How do I relay and bind to the underlying source model to e.g. an
asp-for
tag helper? - How do I ensure property metadata (e.g. validation attributes) from
ViewData.ModelMetadata
are honored? - How do I assemble a fully qualified
HtmlFieldPrefix
for the fieldname
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.
Scenario
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).
Sample Code
/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
- Using
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. - The parameter name
for
is illegal in C#, since it's a reserved keyword. As such, I'm instead usingaspFor
, which will be exposed to the tag helper format asasp-for
. This is a bit of a hack, but yields a familiar interface for developers. - Obviously, the
_repository
code and logic will vary considerably with implementation. In my own use case, I actually pull the arguments from some custom attributes. - The
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
- Technically, in this case, I could just return the
SelectList
directly to the view, since it will handle the current value. However, if you bind your model to your<select>
'sasp-for
tag helper, then it will automatically enablemultiple
, 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
- Technically, the value for
@Model
will returnSelectViewModel
. If this were an<input />
that would be obvious. This issue is obscured due to theSelectList
identifying the correct value, presumably from theViewData.ModelMetadata
. - I could instead set the
aspFor.Model
to e.g. anUnderlyingModel
property on theSelectViewModel
. 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.
Variations
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')
Issues
- The
HtmlFieldPrefix
doesn't get properly populated with a fully qualified value. E.g., if the model property name isCountry
it will always returnCountry
, even if the actual model path is, say,ShippingAddress.Country
orAddresses[2].Country
. - The jQuery Validation Unobtrusive functionality isn't firing. For instance, if the property this is bound to is marked as
[Required]
then that's not getting flagged here. That's presumably because it's being bound to theSelectViewModel
, not the parent property. - The original model isn't being relayed in any way to the view component's view; the
SelectList
is able to infer the original value fromViewData
, but that is lost to the view. I could relay theaspFor.Model
via the view model, but it won't have access to the original metadata (such as validation attributes).
Alternate Options
Some other options I've considered, and rejected for my use cases.
- Tag Helpers: This is easy to achieve via tag helpers. Injecting dependencies, such as a repository, into a tag helper is less elegant since there isn't a way to instantiate a tag helper via the composition root, as one can do with e.g.
IViewComponentActivator
. - Controllers: In this simplified example, it is also possible to define the source collection on the top-level view model, next to the actual property (e.g.,
Country
for the value,CountryList
for the options). That may not be practical or elegant in more sophisticated examples. - AJAX: The values could be retrieved via a JavaScript call to a web service, binding the JSON output to the
<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. - Explicit Values: I could explicitly relay the parent model along with the
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 theModelExpression
approach first.
Previous Research
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?