Search code examples
razorasp.net-coretag-helpers

Complex custom tag helper


Basically, I'm extending on a previously answered question (Updating related entities) so that it is a Custom Tag Helper.

I want to send the custom tag helper a list of phones related to the user and generate a textbox for each.

So, lets assume I have the following syntax:

<user-phones phones="@Model.UserPhones" />

Here is the start I have for the Custom Tag Helper:

public class UserPhonesTagHelper : TagHelper
{
    private readonly IHtmlGenerator _htmlGenerator;
    private const string ForAttributeName = "asp-for";


    public List<UserPhones> Phones { get; set; }

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

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

    public UserPhonesTagHelper(IHtmlGenerator htmlGenerator)
    {
        _htmlGenerator = htmlGenerator;
    }

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "div";
        output.TagMode = TagMode.StartTagAndEndTag;
        //output.Attributes.Add("class", "form-group");

        StringBuilder sbRtn = new StringBuilder();
        for (int i = 0; i < Phones.Count(); i++)
        {
            //NEED HELP HERE
        }

        output.Content.SetHtmlContent(sbRtn.ToString());
    }
}

Within the for loop, how could I generate a textbox and hidden inputs related to the current `UserPhone' entity in the iteration? I would need this to remain bound when the parent razor page is posted as well.

My thought is a method like so would help. BUT, I do not know how to pass the ModelExpression from the for loop to the method

private void WriteInput(TextWriter writer)
    {
        var tagBuilder = _htmlGenerator.GenerateTextBox(
          ViewContext,
          For.ModelExplorer,
          For.Name,
          value: null,
          format: null,
          htmlAttributes: new { @class = "form-control" });

        tagBuilder.WriteTo(writer, htmlEncoder);
    }

Thank you again for all your help... still learning asp.net core.


Solution

  • You're almost there.

    Design

    The difficulty here is that we need to construct an expression for unknown properties. Let's say when you want to use the <user-phones asp-for=""/> in a much more higher level, considering the following code :

    @model M0
    
    @{
        var M1 = GetM1ByMagic(M0);
    }
    <user-phones asp-for="@M1.M2....Mx.UserPhones">
    </user-phones>
    

    Inside the tag helper, we might assume the default name of each property to be UserPhones[<index>].<property-name>. But that's not always that case, users might want to change it to M0.M2....Mx.UserPhones[<index>].<property-name>. However, it's not possible to know how many levels there will be at compile-time.

    So we need an attribute of ExpressionFilter to convert the default expression to target expression :

    public class UserPhonesTagHelper : TagHelper
    {
    
        [HtmlAttributeName("expression-filter")]
        public Func<string, string> ExpressionFilter { get; set; } = e => e;
    
        // ...
    }
    

    The ExpressionFilter here is a simple delegate to convert expression string.

    Show me the Code

    I simply copy most of your code and make a little change :

    public class UserPhonesTagHelper : TagHelper
    {
        private readonly IHtmlGenerator _htmlGenerator;
        private const string ForAttributeName = "asp-for";
    
    
        public IList<UserPhones> Phones { get; set; }
    
        [ViewContext]
        public ViewContext ViewContext { set; get; }
    
        [HtmlAttributeName(ForAttributeName)]
        public ModelExpression For { get; set; }
    
        public UserPhonesTagHelper(IHtmlGenerator htmlGenerator)
        {
            _htmlGenerator = htmlGenerator;
        }
    
        [HtmlAttributeName("expression-filter")]
        public Func<string, string> ExpressionFilter { get; set; } = e => e;
    
        // a helper method that generate a label and input for some property
        private TagBuilder GenerateSimpleInputForField( int index ,PropertyInfo pi)
        {
            var instance = Phones[index];// current instance of a single UserPhone
            var name = pi.Name;          // property name : e.g. "PhoneNumberId"
            var v = pi.GetValue(instance);
    
            var div = new TagBuilder("div");
            div.AddCssClass("form-group");
    
            var expression = this.ExpressionFilter(For.Name + $"[{index}].{name}");
            var explorer = For.ModelExplorer.GetExplorerForExpression(typeof(IList<UserPhones>), o =>v);
    
            var label = _htmlGenerator.GenerateLabel( ViewContext, explorer, expression, name, new { } );
            div.InnerHtml.AppendHtml(label);
    
            var input = _htmlGenerator.GenerateTextBox( ViewContext, explorer, expression, v, null, new { @class = "form-control" } );
            div.InnerHtml.AppendHtml(input);
            return div;
        }
    
        public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
        {
            output.TagName = "div";
            output.TagMode = TagMode.StartTagAndEndTag;
    
            var type = typeof(UserPhones);
            PropertyInfo phoneId= type.GetProperty("UserPhoneId");
            PropertyInfo phoneNumber= type.GetProperty("PhoneNumber");
    
            for (int i = 0; i< Phones.Count();i++) {
                var div1 = this.GenerateSimpleInputForField(i,phoneId);
                var div2 = this.GenerateSimpleInputForField(i,phoneNumber);
    
                output.Content.AppendHtml(div1);
                output.Content.AppendHtml(div2);
            }
        }
    }
    
    1. The ProcessAsync() above only shows a label and input for UserPhoneId and PhoneNumber field. If you would like to show all the properties automatically, you can simply change the method to be :

      public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
      {
          output.TagName = "div";
          output.TagMode = TagMode.StartTagAndEndTag;
      
          for (int i = 0; i < Phones.Count(); i++)
          {
              var pis = typeof(UserPhones).GetProperties();
              foreach (var pi in pis)
              {
                  var div = this.GenerateSimpleInputForField(i, pi);
                  output.Content.AppendHtml(div);
              }
          }
      }
      
    2. the default expression string for some field is generated by:

      get_the_name_by('asp-for') +'[<index>]'+'<property-name>'  
      

      eg :AppUser.UserPhones[i].<property-name>

      Surely it won't apply for all cases, we can custom our own expression-filter to convert the expression as we like :

      // use <user-phones> in view file :
      
      // custom our own expression filter :
      @{
          var regex= new System.Text.RegularExpressions.Regex(@"...");
      
          Func<string, string> expressionFilter = e => {
              var m = regex.Match(e);
              // ...
              return m.Groups["expression"].Value;
          };
      }
      <user-phones phones="@Model.AppUser.UserPhones" 
          asp-for="@Model.AppUser.UserPhones" 
          expression-filter="expressionFilter">
      </user-phones>
      

    Test case

    <div class="row">
        @await Html.PartialAsync("_NameAndID", Model.AppUser)
    </div>
    
    <form method="post">
        <div class="row">
            <user-phones phones="@Model.AppUser.UserPhones" asp-for="@Model.AppUser.UserPhones" expression-filter="e => e.Substring(8)"></user-phones>
        </div>
    
        <button type="submit">submit</button>
    </form>
    

    The first part is generated by partial view and the second part is generated by user-phones:

    enter image description here