Updating related Phone entities with custom tag he

2019-07-26 04:00发布

问题:

As my application currently sits, each AppUser may (or may not) have 3 phone numbers (UserPhones). One of each type (Mobile, Home, Other).

The following Tag Helper works great (Thanks @itminus).

Calling code from Razor Page:

<user-phones phones="@Model.UserPhones" 
              asp-for="@Model.UserPhones" 
              prop-name-to-edit="PhoneNumber"
              types-to-edit="new EnumPhoneType[] { EnumPhoneType.Mobile, 
                               EnumPhoneType.Other }" />

Code:

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

    [HtmlAttributeName("expression-filter")]
    public Func<string, string> ExpressionFilter { get; set; } = e => e;


    public List<UserPhones> Phones { get; set; }
    public EnumPhoneType[] TypesToEdit { get; set; }
    public string PropNameToEdit { 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 = null; //DO NOT WANT AN OUTTER HTML ELEMENT

        for (int i = 0; i < Phones.Count(); i++)
        {
            var props = typeof(UserPhones).GetProperties();
            var pType = props.Single(z => z.Name == "Type");
            var pTypeVal = pType.GetValue(Phones[i]);
            EnumPhoneType eType = (EnumPhoneType) Enum.Parse(typeof(EnumPhoneType), pTypeVal.ToString());

            string lVal = null;
            switch (eType)
            {
                case EnumPhoneType.Home:
                    lVal = "Home Phone";
                    break;
                case EnumPhoneType.Mobile:
                    lVal = "Mobile Phone";
                    break;
                case EnumPhoneType.Other:
                    lVal = "Other Phone";
                    break;
                default:
                    break;
            }

            //LOOP ALL PROPERTIES
            foreach (var pi in props)
            {
                var v = pi.GetValue(Phones[i]);
                var expression = this.ExpressionFilter(For.Name + $"[{i}].{pi.Name}");
                var explorer = For.ModelExplorer.GetExplorerForExpression(typeof(IList<UserPhones>), o => v);

                //IF REQUESTED TYPE AND PROPERTY SPECIFIED
                if (pi.Name.NormalizeString() == PropNameToEdit.NormalizeString() && TypesToEdit.Contains(eType))
                {
                    TagBuilder gridItem = new TagBuilder("div");
                    gridItem.Attributes.Add("class", "rvt-grid__item");
                    gridItem.InnerHtml.AppendHtml(BuildLabel(explorer, expression, lVal));
                    gridItem.InnerHtml.AppendHtml(BuildTextBox(explorer, expression, v.ToString()));
                    output.Content.AppendHtml(gridItem);
                }
                else //ADD HIDDEN FIELD SO BOUND PROPERLY
                    output.Content.AppendHtml(BuildHidden(explorer, expression, v.ToString()));
            }
        }
    }

    private TagBuilder BuildTextBox(ModelExplorer explorer, string expression, string v)
    {
        return _htmlGenerator.GenerateTextBox(ViewContext, explorer, expression, v, null, new { @class = "form-control" });
    }

    public TagBuilder BuildHidden(ModelExplorer explorer, string expression, string v)
    {
        return _htmlGenerator.GenerateHidden(ViewContext, explorer, expression, v, false, new { });
    }

    public TagBuilder BuildLabel(ModelExplorer explorer, string expression, string v)
    {
        return _htmlGenerator.GenerateLabel(ViewContext, explorer, expression, v, new { });
    }
}

My Question:

Lets assume this AppUser only has one related Mobile phone number listed currently. So AppUser.UserPhones (count = 1 of type Mobile). So the code above, as-is, will only render an input for Mobile phone.

Since types-to-edit calls for both Mobile and Other, I want both inputs to be rendered to the screen. And IF the user adds a phone number to the Other input, then it would be saved to the related UserPhones entity on the Razor Pages OnPostAsync method. If the user does NOT provide a number for the "Other" input, then the related UserPhones record of type "Other" should NOT be created.

Can you help?

Thanks again!!!!

回答1:

TagHelper

As my application currently sits, each AppUser may (or may not) have 3 phone numbers (UserPhones). One of each type (Mobile, Home, Other).

If I understand correctly, an AppUser might have 3 phone numbers and the count of each phone type for every user will be zero or one.

If that's the case, we can simply use PhoneType as an index, in other words, there's no need to use a custom index to iterate through the Phones property, and the ProcessAsync() method could be :

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = null; //DO NOT WANT AN OUTTER HTML ELEMENT

        var props = typeof(UserPhones).GetProperties();

        // display editable tags for phones
        foreach (var pt in this.TypesToEdit) {
            var phone = Phones.SingleOrDefault(p=>p.Type == pt);
            var index = (int) pt;
            foreach (var pi in props)
            {
                // if phone==null , then the pv should be null too
                var pv = phone==null? null: pi.GetValue(phone);
                var tag = GenerateFieldForProperty(pi.Name, pv, index, pt);
                output.Content.AppendHtml(tag);
            }
        }
        // generate hidden input tags for phones
        var phones= Phones.Where(p => !this.TypesToEdit.Contains((p.Type)));
        foreach (var p in phones) {
            var index = (int)p.Type;
            foreach (var pi in props) {
                var pv = pi.GetValue(p);
                var tag = GenerateFieldForProperty(pi.Name,pv,index,p.Type);
                output.Content.AppendHtml(tag);
            }
        }
    }

Here the GenerateFieldForProperty is a simply helper method to generate tag builder for particular property:

    private TagBuilder GenerateFieldForProperty(string propName,object propValue,int index, EnumPhoneType eType )
    {
        // whether current UserPhone is editable (check the PhoneType)
        var editable = TypesToEdit.Contains(eType);
        var expression = this.ExpressionFilter(For.Name + $"[{index}].{propName}");
        var explorer = For.ModelExplorer.GetExplorerForExpression(typeof(IList<UserPhones>), o => propValue);

        //IF REQUESTED TYPE AND PROPERTY SPECIFIED
        if (pi.Name.NormalizeString() == PropNameToEdit.NormalizeString() && editable)
        {
            TagBuilder gridItem = new TagBuilder("div");
            gridItem.Attributes.Add("class", "rvt-grid__item");
            var labelText = this.GetLabelTextByPhoneType(eType);
            gridItem.InnerHtml.AppendHtml(BuildLabel(explorer, expression, labelText));
            gridItem.InnerHtml.AppendHtml(BuildTextBox(explorer, expression, propValue?.ToString()));
            return gridItem;
        }
        else //ADD HIDDEN FIELD SO BOUND PROPERLY
            return BuildHidden(explorer, expression, propValue?.ToString());
    }


    private string GetLabelTextByPhoneType(EnumPhoneType eType) {
        string lVal = null;
        switch (eType)
        {
            case EnumPhoneType.Home:
                lVal = "Home Phone";
                break;
            case EnumPhoneType.Mobile:
                lVal = "Mobile Phone";
                break;
            case EnumPhoneType.Other:
                lVal = "Other Phone";
                break;
            default:
                break;
        }
        return lVal;
    }

When posted to server, if someone doesn't input a phone number for the other PhoneType, the actual payload will be something like:

AppUser.UserPhones[0].UserPhoneId=....&AppUser.UserPhones[0].PhoneNumber=911&....
&AppUser.UserPhones[2].UserPhoneId=&AppUser.UserPhones[2].PhoneNumber=&AppUser.UserPhones[2].Type=&AppUser.UserPhones[2].AppUserId=&AppUser.UserPhones[2].AppUser=
&AppUser.UserPhones[1].UserPhoneId=...&AppUser.UserPhones[1].PhoneNumber=119&....

Since we use phone type as the index, we can conclude that the UserPhones[0] will be used as an Mobile phone and the UserPhones[2] will be treated as an Home phone.

page handler or action method

And the model binder on server side will create a empty string for each UserPhone. To remove those empty inputs and prevent overposting attack, we could use Linq to filter UserPhones so that we can create or update UserPhone records without empty Phones:

    var editables = new[] {
        EnumPhoneType.Mobile,
        EnumPhoneType.Other,
    };
    AppUser.UserPhones = AppUser.UserPhones
        .Where(p => !string.IsNullOrEmpty(p.PhoneNumber))  // remove empty inputs
        .Where(p => editables.Contains(p.Type) )           // remove not editable inputs
        .ToList();
    // now the `UserPhones` will be clean for later use
    // ... create or update user phones as you like 

Let's say you want to create phones :

public IActionResult OnPostCreate() {
    var editables = new[] {
        EnumPhoneType.Mobile,
        EnumPhoneType.Other,
    };
    AppUser.UserPhones = AppUser.UserPhones
        .Where(p => !string.IsNullOrEmpty(p.PhoneNumber))
        .Where(p => editables.Contains(p.Type) )
        .Select(p => {                   // construct relationship for inputs
            p.AppUser = AppUser;
            p.AppUserId = AppUser.Id;
            return p;
        })
        .ToList();

    this._dbContext.Set<UserPhones>().AddRange(AppUser.UserPhones);
    this._dbContext.SaveChanges();

    return Page();
}

Test Case :

<form method="post">
    <div class="row">

    <user-phones 
        phones="@Model.AppUser.UserPhones" 
        asp-for="@Model.AppUser.UserPhones" 
        prop-name-to-edit="PhoneNumber"
        types-to-edit="new EnumPhoneType[] { EnumPhoneType.Mobile, EnumPhoneType.Other}"
        >
    </user-phones>
    </div>

    <button type="submit">submit</button>
</form>

User1 who has Mobile phone and Home phone number:

User2 who wants to create a new Mobile phone number :