Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

EditorFor Tag Helper doesn't render validation attributes when using FluentValidator

I have a simple form like this which makes use of the @Html.EditorFor extension:

<form method="post">
    @Html.EditorFor(x => x.SystemSettings.EmailFromAddress)
    <submit-button title="Save"></submit-button>
</form>

I want to take advantage of .NET Core's tag helpers so that my form looks like this instead:

<form method="post">
    <editor asp-for="SystemSettings.EmailFromAddress"/>
    <submit-button title="Save"></submit-button>
</form>

I also eventually would like to have my own custom tag helpers so I can do something like this instead:

<text-box asp-for="SystemSettings.EmailFromAddress"></text-box>

I have a string template which gets rendered by the @Html.EditorFor extension:

@model string
<div class="form-group">
    <label asp-for="@Model" class="m-b-none"></label>
    <span asp-description-for="@Model" class="help-block m-b-none small m-t-none"></span>
    <div class="input-group">
        <input asp-for="@Model" class="form-control" />
        <partial name="_ValidationIcon" />
    </div>
    <span asp-validation-for="@Model" class="validation-message"></span>
</div>

To do that, I saw someone implemented an EditorTagHelper, which looks like this:

[HtmlTargetElement("editor", TagStructure = TagStructure.WithoutEndTag,
    Attributes = ForAttributeName)]
public class EditorTagHelper : TagHelper
{
    private readonly IHtmlHelper _htmlHelper;

    private const string ForAttributeName = "asp-for";
    private const string TemplateAttributeName = "asp-template";

    [HtmlAttributeName(ForAttributeName)]
    public ModelExpression For { get; set; }


    [HtmlAttributeName(TemplateAttributeName)]
    public string Template { get; set; }

    [ViewContext]
    [HtmlAttributeNotBound]
    public ViewContext ViewContext { get; set; }

    public EditorTagHelper(IHtmlHelper htmlHelper)
    {
        _htmlHelper = htmlHelper;
    }

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));

        if (output == null)
            throw new ArgumentNullException(nameof(output));

        if (!output.Attributes.ContainsName(nameof(Template)))
        {
            output.Attributes.Add(nameof(Template), Template);
        }

        output.SuppressOutput();

        (_htmlHelper as IViewContextAware).Contextualize(ViewContext);

        output.Content.SetHtmlContent(_htmlHelper.Editor(For.Name, Template));

        await Task.CompletedTask;
    }
}

When I use the EditorTagHelper though, it seems to be missing the unobtrusive Javascript validation attributes:

Using @Html.EditorFor, this gets rendered:

<input class="form-control valid" type="text" data-val="true" data-val-required="Email From Address cannot be empty" id="SystemSettings_EmailFromAddress" name="SystemSettings.EmailFromAddress" value="[email protected]" aria-required="true" aria-invalid="false" aria-describedby="SystemSettings_EmailFromAddress-error">

It's got the data-val attributes so client-side validation gets applied.

When I use the EditorTagHelper instead, this gets rendered:

<input class="form-control valid" type="text" id="SystemSettings_EmailFromAddress" name="SystemSettings.EmailFromAddress" value="[email protected]" aria-invalid="false">

The unobtrusive validation attributes are not being applied. I am using FluentValidation and I have specified an AbstractValidator like this:

public class SystemSettingsValidator : AbstractValidator<SystemSettings>
{
    public SystemSettingsValidator()
    {
        RuleFor(x => x.EmailFromAddress).NotEmpty()
            .WithMessage("Email From Address cannot be empty");
    }
}

I found that if I removed the AbstractorValidator and simply added a [Required] attribute to my model property the validation then works properly. This suggests that there is something wrong with FluentValidation. Perhaps there is a configuration issue.

I am using Autofac dependency injection to scan my assemblies and register validator types:

builder.RegisterAssemblyTypes(Assembly.Load(assembly))
    .Where(t => t.IsClosedTypeOf(typeof(IValidator<>)))
    .AsImplementedInterfaces()
    .PropertiesAutowired()
    .InstancePerLifetimeScope();

This seems to work fine. In case it wasn't fine, I also tried registering the validators from the fluent validation options like this:

.AddFluentValidation(fv =>
{
    fv.RegisterValidatorsFromAssemblies(new List<Assembly>
        {Assembly.GetExecutingAssembly(), Assembly.Load(nameof(Entities))});
})

This also seemed to be fine.

One thing to note is that an earlier problem I had was that using Autofac assembly scanning was breaking the application when tag helpers were included. I added a filter to ensure that tag helpers are not included when registering these dependencies, e.g.

builder.RegisterAutowiredAssemblyInterfaces(Assembly.Load(Web))
    .Where(x => !x.Name.EndsWith("TagHelper"));

I have uploaded a working sample of the code here: https://github.com/ciaran036/coresample2

Navigate to the Settings Page to see the field I am trying to validate.

This issue also appears to affect view components.

Thanks.

like image 219
Ciaran Gallagher Avatar asked Feb 05 '20 14:02

Ciaran Gallagher


1 Answers

I believe the issue is in the tag helper, in that it uses IHtmlHelper.Editor rather than IHtmlHelper<TModel>.EditorFor to generate the HTML content. They are not quite the same.

As you point out FluentValidation injects the validation attributes as you'd expect for @Html.EditorFor(x => x.SystemSettings.EmailFromAddress). However for @Html.Editor("SystemSettings.EmailFromAddress"), which is what your custom tag helper is doing, FluentValidation doesn't inject the validation attributes. So that rules out the tag helper itself and moves the problem to the Editor invocation.

I also noticed that Editor doesn't resolve <label asp-for (or the other <span asp-description-for tag helper you're using) so that suggests it's not a FluentValidation specific issue.

I wasn't able to replicate your success with the Required attribute for the custom tag helper/Editor - the Required attribute only injected the validation attributes when using EditorFor.

The internals for Editor and EditorFor are similar but with a key difference, the way they resolve the ModelExplorer instance used to generate the HTML content differs and I suspect this is the problem. See below for these differences.

Editor vs EditorFor ModelExplorer

Things like PropertyName set to null and Metadata.Property not being set for Editor, but set to EmailFromAddress and SystemSettings.EmailFromAddress for EditorFor are standing out as potential causes for the behaviour we're seeing.

The painful part is the tag helper has a valid ModelExplorer instance via the For property. But there is no built in provision to provide it to the html helper.

As to the resolution, the obvious one seems to be to use EditorFor rather than Editor however it doesn't look easy. It'd likely involve reflection and building an expression.

Another option is, considering the tag helper resolves the ModelExplorer correctly, is to extend HtmlHelper and override the GenerateEditor method - what both Editor and EditorFor end up invoking - so you can pass in the ModelExplorer and work around the problem.

public class CustomHtmlHelper : HtmlHelper, IHtmlHelper
{
    public CustomHtmlHelper(IHtmlGenerator htmlGenerator, ICompositeViewEngine viewEngine, IModelMetadataProvider metadataProvider, IViewBufferScope bufferScope, HtmlEncoder htmlEncoder, UrlEncoder urlEncoder) : base(htmlGenerator, viewEngine, metadataProvider, bufferScope, htmlEncoder, urlEncoder) { }

    public IHtmlContent CustomGenerateEditor(ModelExplorer modelExplorer, string htmlFieldName, string templateName, object additionalViewData)
    {
        return GenerateEditor(modelExplorer, htmlFieldName, templateName, additionalViewData);
    }

    protected override IHtmlContent GenerateEditor(ModelExplorer modelExplorer, string htmlFieldName, string templateName, object additionalViewData)
    {
        return base.GenerateEditor(modelExplorer, htmlFieldName, templateName, additionalViewData);
    }
}

Update your tag helper to use it:

public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
    if (context == null)
        throw new ArgumentNullException(nameof(context));

    if (output == null)
        throw new ArgumentNullException(nameof(output));

    if (!output.Attributes.ContainsName(nameof(Template)))
    {
        output.Attributes.Add(nameof(Template), Template);
    }

    output.SuppressOutput();

    (_htmlHelper as IViewContextAware).Contextualize(ViewContext);

    var customHtmlHelper = _htmlHelper as CustomHtmlHelper;
    var content = customHtmlHelper.CustomGenerateEditor(For.ModelExplorer, For.Metadata.DisplayName ?? For.Metadata.PropertyName, Template, null);
    output.Content.SetHtmlContent(content);

    await Task.CompletedTask;
}

Finally register the new helper, the earlier the better I'd say

services.AddScoped<IHtmlHelper, CustomHtmlHelper>();

Working solution

like image 129
rgvlee Avatar answered Sep 28 '22 00:09

rgvlee