Search code examples
c#razorasp.net-coreasp.net-core-tag-helpers

Tag Helper Embedded in Another Tag Helper's Code Doesn't Render


I'm trying to simplify creation of long forms with the following custom tag helper in .net Core 2.0:

@model ObApp.Web.Models.ViewComponents.FormFieldViewModel

<span>Debug - Paramater value: @Model.FieldFor</span>
<div class="form-group">
    <label asp-for="@Model.FieldFor"></label>
    <input asp-for="@Model.FieldFor" class="form-control" />
</div>

It seems simple, but I'm getting an unexpected (to me) result when I use:

<vc:form-field field-for="PersonEmail"></vc:form-field>

Expected Result

<span>Debug - Paramater value: PersonEmail</span>
<div class="form-group">
    <label for="PersonEmail">Email</label>
    <input name="PersonEmail" class="form-control" id="PersonEmail" 
        type="text" value="PersonEmail">
</div>

Actual Result

<span>Debug - Paramater value: PersonEmail</span>
<div class="form-group">
    <label for="FieldFor">FieldFor</label>
    <input name="FieldFor" class="form-control" id="FieldFor" 
        type="text" value="PersonEmail">
</div>

I've tried removing the quotes from around @Model.FieldFor, as well as a few other syntactic changes.

Any suggestions?

Thank you!


Solution

  • As was pointed out to me by others, it may not be possible to directly embed tag helpers the way I first desired when I posted this question. As a result I refactored the code to programmatically "new up" the desired tag helpers instead.

    My final solution was significantly more work than I had expected but in the long run it will save lots of time developing the form-intensive applications I have planned.

    The Objective

    My goal is to speed-up creation of forms by using this custom tag helper, for example:

    <formfield asp-for="OrganizationName"></formfield>
    

    To generate these built-in Razor tag helpers:

    <div class="form-group">
        <div class="row">
            <label class="col-md-3 col-form-label" for="OrganizationName">Company Name</label>
            <div class="col-md-9">
                <input name="OrganizationName" class="form-control" id="OrganizationName" type="text" value="" data-val-required="The Company Name field is required." data-val="true" data-val-maxlength-max="50" data-val-maxlength="Maximum company name length is 50 characters.">
                <span class="field-validation-valid" data-valmsg-replace="true" data-valmsg-for="OrganizationName"></span>
            </div>
        </div>
    </div>
    

    My Initial Working Solution

    This is the first-pass test solution for simple cases. I.e. for default hard coded classes and text-box input types.

    using Microsoft.AspNetCore.Html;
    using Microsoft.AspNetCore.Mvc.Rendering;
    using Microsoft.AspNetCore.Mvc.TagHelpers;
    using Microsoft.AspNetCore.Mvc.ViewFeatures;
    using Microsoft.AspNetCore.Razor.TagHelpers;
    using System.Collections.Generic;
    using System.Threading.Tasks;
    
    namespace ObApp.Web.TagHelpers
    {
        // Builds form elements to generate the following (for example):
        // <div class="form-group">
        //     <div class="row">
        //         <input ... >Email</input>
        //         <div>
        //             <input type="text" ... />
        //             <span class="field-validation-valid ... ></span>
        //         </div>
        //     </div>
        // </div>
    
        public class FormfieldTagHelper : TagHelper
        {
            private const string _forAttributeName = "asp-for";
            private const string _defaultWraperDivClass = "form-group";
            private const string _defaultRowDivClass = "row";
            private const string _defaultLabelClass = "col-md-3 col-form-label";
            private const string _defaultInputClass = "form-control";
            private const string _defaultInnerDivClass = "col-md-9";
            private const string _defaultValidationMessageClass = "";
    
            public FormfieldTagHelper(IHtmlGenerator generator)
            {
                Generator = generator;
            }
    
            [HtmlAttributeName(_forAttributeName)]
            public ModelExpression For { get; set; }
    
            public IHtmlGenerator Generator { get; }
    
            [ViewContext]
            [HtmlAttributeNotBound]
            public ViewContext ViewContext { get; set; }
    
            public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
            {
                // Replace this parent tag helper with div tags wrapping the entire form block
                output.TagName = "div";
                output.Attributes.SetAttribute("class", _defaultWraperDivClass);
    
                // Manually new-up each child asp form tag helper element
                TagHelperOutput labelElement = await CreateLabelElement(context);
                TagHelperOutput inputElement = await CreateInputElement(context);
                TagHelperOutput validationMessageElement = await CreateValidationMessageElement(context);
    
                // Wrap input and validation with column div
                IHtmlContent innerDiv = WrapElementsWithDiv(
                        new List<IHtmlContent>()
                        {
                            inputElement,
                            validationMessageElement
                        },
                        _defaultInnerDivClass
                    );
    
                // Wrap all elements with a row div
                IHtmlContent rowDiv = WrapElementsWithDiv(
                        new List<IHtmlContent>()
                        {
                            labelElement,
                            innerDiv
                        },
                        _defaultRowDivClass
                    );
    
                // Put everything into the innerHtml of this tag helper
                output.Content.SetHtmlContent(rowDiv);
            }
    
            private async Task<TagHelperOutput> CreateLabelElement(TagHelperContext context)
            {
                LabelTagHelper labelTagHelper = 
                    new LabelTagHelper(Generator)
                    {
                        For = this.For,
                        ViewContext = this.ViewContext
                    };
    
                TagHelperOutput labelOutput = CreateTagHelperOutput("label");
    
                await labelTagHelper.ProcessAsync(context, labelOutput);
    
                labelOutput.Attributes.Add(
                    new TagHelperAttribute("class", _defaultLabelClass));
    
                return labelOutput;
            }
    
            private async Task<TagHelperOutput> CreateInputElement(TagHelperContext context)
            {
                InputTagHelper inputTagHelper = 
                    new InputTagHelper(Generator)
                    {
                        For = this.For,
                        ViewContext = this.ViewContext
                    };
    
                TagHelperOutput inputOutput = CreateTagHelperOutput("input");
    
                await inputTagHelper.ProcessAsync(context, inputOutput);
    
                inputOutput.Attributes.Add(
                    new TagHelperAttribute("class", _defaultInputClass));
    
                return inputOutput;
            }
    
            private async Task<TagHelperOutput> CreateValidationMessageElement(TagHelperContext context)
            {
                ValidationMessageTagHelper validationMessageTagHelper = 
                    new ValidationMessageTagHelper(Generator)
                    {
                        For = this.For,
                        ViewContext = this.ViewContext
                    };
    
                TagHelperOutput validationMessageOutput = CreateTagHelperOutput("span");
    
                await validationMessageTagHelper.ProcessAsync(context, validationMessageOutput);
    
                return validationMessageOutput;
            }
    
            private IHtmlContent WrapElementsWithDiv(List<IHtmlContent> elements, string classValue)
            {
                TagBuilder div = new TagBuilder("div");
                div.AddCssClass(classValue);
                foreach(IHtmlContent element in elements)
                {
                    div.InnerHtml.AppendHtml(element);
                }
    
                return div;
            }
    
            private TagHelperOutput CreateTagHelperOutput(string tagName)
            {
                return new TagHelperOutput(
                    tagName: tagName,
                    attributes: new TagHelperAttributeList(),
                    getChildContentAsync: (s, t) =>
                    {
                        return Task.Factory.StartNew<TagHelperContent>(
                                () => new DefaultTagHelperContent());
                    }
                );
            }
        }
    }
    

    Next Steps/Suggested Improvements

    This is working well for text boxes without validation errors. After tweaking the default CSS the next steps I plan to take are:

    1. Display the correct CSS class attributes for formatting when there are validation errors. At this point the tag helper will be "working" for me.
    2. Move the hard coded CSS classes out to a site configuration file.
    3. Bind to HTML attributes in the view to allow non-default classes to be passed in. Another way to do this would be to pass in non-default form classes through the ViewModel.
    4. Detect non-textbox input types and format accordingly.

    Thank you to @Chris Pratt for getting me started in the right direction on this.