更新时间:2022-10-21 09:10:27
否定地回答我自己的问题:我最终得出的结论是,虽然就我们的父级而言,这很可能是直观且理想的功能视图,就我们的视图组件而言,它最终是一个混乱的概念.
即使您通过从 ModelExpression
中提取完全限定的 HtmlFieldPrefix
解决了技术问题,更深层次的问题是概念性的.据推测,视图组件将组装额外的数据,并通过新的视图模型将其传递给视图——例如,问题中提出的 SelectViewModel
.否则,使用视图组件没有真正的好处.然而,在视图组件的视图中,没有将子视图模型的属性映射回父视图模型的逻辑方法.>
例如,假设在您的父视图中,您将视图组件绑定到一个 UserViewModel.Country
属性:
@model UserViewModel<vc:select asp-for="Country"/>
那么,你在子视图中绑定了哪些属性?
@model SelectViewModel<选择 asp-for=@???asp-items="Model.Options"><option value="">选择一个...</option></选择>
在我最初的问题中,我提出了 @Model
,这类似于您在例如通过@Html.EditorFor()
调用的编辑器模板:
这可能会返回正确的 id
和 name
属性,因为它会回退到 ViewData
的 HtmlFieldPrefix
>.但是,它不会访问任何例如数据验证属性,因为它绑定到 SelectViewModel
而不是对原始 UserViewModel.Country
属性的引用,就像在编辑器模板.
同样,您可以通过例如传递ModelExpression.Model
一个 SelectViewModel.Model
属性...
……但这也不能解决问题,因为很明显,传递值并不能传递源属性的属性.
最终,您想要的是将您的 asp-for
绑定到您的 ModelExpression
解析到的原始对象上的原始属性.虽然您可以从 ModelExpression
describing 该属性和对象中获取元数据,但似乎没有一种方法可以将 reference 传递给它以 asp-for
标签助手识别的方式.
显然,可以设想微软在 ModelExpression
和允许中继 ModelExpression 的
对象一直沿线向下.或者,他们可能会建立一个关键字——例如 asp-for
标签助手的核心实现中构建低级工具@ParentModel
——它允许从 父视图 引用模型.然而,如果没有这一点,这似乎是不可行的.
我不会将此标记为答案,希望有人在某个时候找到我遗漏的东西.但是,我想把这些笔记留在这里,以防其他人试图完成这项工作,并记录我自己的结论.
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?
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.