Search code examples
javascriptjqueryasp.net-coreunobtrusive-validation

jQuery validation doesn't detect the dynamically added inputs errors


I wrote a script to duplicate the input as much as I want. On each duplication, I'm changing all the name attributes and increasing their index by one because it's a List and it should be put in the correct order to be correctly sent to the server (I use ASP.NET Core for the backend) and to also match the span underneath it, so they can correctly bind together and show the errors:

enter image description here

Notice the first input works fine, its input's class turns into input-validation-error and its span's class also switches to field-validation-error and shows me the error. But the second input that is newly added, although it flashes as I'm typing, but it doesn't switch its class even if it's invalid, that's the problem. And by the way, after each duplication, I'm also reinitializing the jquery validations using $.validator.unobtrusive.parse(document);

I though that only matching the input's and span's names would be enough for the jquery validation to work, is it correct?

This is the main repeater.js file which is responsible to perform addition and removal:

$(document).ready(function ()
{
  $("[data-repeater-container]").repeater();
});

$.fn.repeater = function ()
{
  $(this).each(function ()
  {
    // The container is the main parent which has the attribute [data-repeater-container]
    const container = $(this);
    // The list is inside the container and it contains the items,
    // it has the attribute [data-repeater-list]
    const list = container.find("[data-repeater-list]");
    // The creator is the Add button
    const creator = container.find("[data-repeater-create]");
    // This cached item finds the last item
    // An item contains the inputs and it's the thing that is going to be duplicated
    const cachedItem = list.find("[data-repeater-item]").last();
    // setupRemoves just adds an onclick event to the remove button on the far left of each item
    setupRemovers(container);

    // The creator is the Add button, on click, I'll grab the last item in the list,
    // and make a clone from that, and pass that to the appendItemToList function
    creator.on("click", function ()
    {
      const item = list.find("[data-repeater-item]").last();
      // This is related to ASP.Net Core which adds some extra checkbox inputs
      // at the end of the form, IDK why, but I just remove them, they would cause some problems
      removeExtraCheckboxes(item);
      let itemClone;
      if (item.length === 0 || item.hasClass("category-specification"))
        itemClone = cachedItem.clone();
      else
      {
        itemClone = item.clone();
      }
      appendItemToList(itemClone, list, container);
    });
  });
};

// This adds the new item to the list, and after each addition
function appendItemToList(item, list, container)
{
  item.find("[name]").val("");
  item.css("display", "none");
  item.appendTo(list).slideDown(200, () =>
  {
    setupRemovers(container);
    // Goes through each item in the list and finds the name attributes, grabs their index
    // increases them by one, more comments on the function itself.
    resetIndexes(list);
    // Reinitialize the jquery unobtrusive validation (although it was not needed I guess)
    $.validator.unobtrusive.parse(document);
  });
}

// setupRemoves just adds an onclick event to the remove button on the far left of each item
function setupRemovers(container)
{
  const removers = container.find("[data-repeater-remove]");
  removers.each(function (index, remover)
  {
    $(remover).on("click", function ()
    {
      const item = $(this).closest("[data-repeater-item]");
      const itemsList = item.parents("[data-repeater-list]");
      removeExtraCheckboxes(item);
      item.slideUp(200, () =>
      {
        item.remove();
        resetIndexes(itemsList);
      });
    });
  });
};

// This is related to ASP.Net Core which adds some extra checkbox inputs
// at the end of the form, IDK why, but I just remove them, they would cause some problems
function removeExtraCheckboxes(item)
{
  const mainCheckboxes = item.find("input[type='checkbox']");
  mainCheckboxes.each((index, checkbox) =>
  {
    item.parents("[data-repeater-container]").find("input[type='hidden']")
      .each((innerIndex, hiddenCheckbox) =>
      {
        const checkboxNameAttr = checkbox.getAttribute("name");
        const hiddenCheckboxNameAttr = hiddenCheckbox.getAttribute("name");
        if (checkboxNameAttr === hiddenCheckboxNameAttr)
          hiddenCheckbox.remove();
      });
  });
}

// This will go through each item in the list, and grab their name attribute
// and increase them by one, the same for the spans
function resetIndexes(itemsList)
{
  const items = itemsList.find("[data-repeater-item]");
  for (let i = 0; i < items.length; i++)
  {
    const item = $(items[i]);
    // This regex will grab the number between brackets: [0] => 0
    const regex = /(?<=\[).+?(?=\])/g;

    // First increase the indexes of every element which has a name attribute
    item.find("[name]").each(function (elementIndex, element)
    {
      const attr = element.getAttribute("name");
      const newAttr = attr.replace(regex, i);
      element.setAttribute("name", newAttr);
    });

    // Then increase the indexes of every element which has data-valmsg-for attribute
    // which are the spans that are used to show the validation errors
    item.find("[data-valmsg-for]").each(function (elementIndex, element)
    {
      const attr = element.getAttribute("data-valmsg-for");
      const newAttr = attr.replace(regex, i);
      element.setAttribute("data-valmsg-for", newAttr);
    });

    // This regex will grab the last number(s) in a string: customCheckBox4 => 4
    const grabLastNumberRegex = /(\d+)(?!.*\d)/;
    // This is for custom checkboxes, but currently I don't have any checkbox in the items
    item.find("[for]").each(function (forElementIndex, elementWithFor)
    {
      const forAttr = elementWithFor.getAttribute("for");

      item.find("[id]").each(function (idElementIndex, elementWithId)
      {
        const idAttr = elementWithId.getAttribute("id");
        if (forAttr !== idAttr)
          return;

        const forNumber = forAttr.match(grabLastNumberRegex);
        if (forNumber == null)
        {
          elementWithFor.setAttribute("for", i);
        } else
        {
          const newForAttr = forAttr.replace(grabLastNumberRegex, i);
          elementWithFor.setAttribute("for", newForAttr);
        }

        const idNumber = idAttr.match(grabLastNumberRegex);
        if (idNumber == null)
        {
          elementWithId.setAttribute("id", i);
        } else
        {
          const newIdAttr = idAttr.replace(grabLastNumberRegex, i);
          elementWithId.setAttribute("id", newIdAttr);
        }
      });
    });
  }
}

This is the HTML structure, the @s are part of the ASP.NET Core Razor Pages, they are C# codes in the HTML tags, but ultimately they'll produce the HTML that you saw in the GIF, for example, the asp-for will produce the appropriate names and ids etc...

<form method="post">
  <div class="card">
    <div class="card-body">
      <div class="row">
        <div class="col-md-12" data-repeater-container="">
          <label>@Html.DisplayNameFor(m => m.CreateProductViewModel.Specifications)</label>
          <div data-repeater-list="" class="category-specifications">
            <div data-repeater-item="">
              <div class="row justify-content-between align-items-center">
                <div class="col-11">
                  <[email protected](m => m.CreateProductViewModel.Specifications)-->
                  <!--This EditorFor above will produce the following HTML-->
                  <div class="row">
                    <div class="col-3 pl-0">
                      <input type="text" class="form-control" asp-for="@Model.CreateProductViewModel.Specifications[0].Title"
                             placeholder="@Html.DisplayNameFor(m => m.CreateProductViewModel.Specifications[0].Title)">
                      <span asp-validation-for="@Model.CreateProductViewModel.Specifications[0].Title" class="d-block"></span>
                    </div>
                    <div class="col-9 pl-0">
                      <textarea type="text" class="form-control" asp-for="@Model.CreateProductViewModel.Specifications[0].Description"
                                placeholder="@Html.DisplayNameFor(m => m.CreateProductViewModel.Specifications[0].Description)"></textarea>
                      <span asp-validation-for="@Model.CreateProductViewModel.Specifications[0].Description" class="d-block"></span>
                    </div>
                  </div>
                </div>
                <div class="col-1 d-flex justify-content-center">
                  <button class="btn btn-icon rounded-circle btn-danger" data-repeater-remove="" type="button">
                    <i class="bx bx-trash"></i>
                  </button>
                </div>
              </div>
              <hr>
            </div>
          </div>
          <div class="col p-0">
            <button class="btn btn-primary" data-repeater-create="" type="button">
              <i class="bx bx-plus"></i>
              افزودن
            </button>
          </div>
        </div>
      </div>
    </div>
  </div>
</form>

The model:

public class CreateProductViewModel
{
    [Required(ErrorMessage = ValidationMessages.IdRequired)]
    public long CategoryId { get; set; }

    [Display(Name = "نام محصول")]
    [Required(ErrorMessage = ValidationMessages.ProductNameRequired)]
    [MaxLength(50, ErrorMessage = ValidationMessages.MaxCharactersLength)]
    public string Name { get; set; }

    [Display(Name = "نام انگلیسی محصول")]
    [MaxLength(50, ErrorMessage = ValidationMessages.MaxCharactersLength)]
    public string? EnglishName { get; set; }

    [DisplayName("اسلاگ")]
    [Required(ErrorMessage = ValidationMessages.SlugRequired)]
    [MaxLength(100, ErrorMessage = ValidationMessages.MaxCharactersLength)]
    public string Slug { get; set; }

    [DisplayName("توضیحات")]
    [Required(ErrorMessage = ValidationMessages.DescriptionRequired)]
    [MaxLength(2000, ErrorMessage = ValidationMessages.MaxCharactersLength)]
    [DataType(DataType.MultilineText)]
    public string? Description { get; set; }

    [DisplayName("عکس اصلی محصول")]
    [Required(ErrorMessage = "لطفا {0} را وارد کنید")]
    [ImageFile(ErrorMessage = "عکس اصلی محصول نامعتبر است")]
    public IFormFile MainImage { get; set; }

    [DisplayName("عکس های گالری محصول")]
    [Required(ErrorMessage = "لطفا {0} را وارد کنید")]
    [ListNotEmpty(ErrorMessage = "لطفا عکس های گالری محصول را وارد کنید")]
    [ImageFile(ErrorMessage = "عکس های گالری محصول نامعتبر هستند")]
    [ListMaxLength(10, ErrorMessage = "عکس های گالری محصول نمی‌تواند بیشتر از 10 عدد باشد")]
    public List<IFormFile> GalleryImages { get; set; }

    [DisplayName("مشخصات")]
    public List<ProductSpecificationViewModel>? Specifications { get; set; } = new() { new() };

    [DisplayName("توضیحات بیشتر")]
    public List<ProductExtraDescriptionViewModel>? ExtraDescriptions { get; set; } = new() { new() };
}

EDIT (reply to one of the comments):

There's no working and non-working input, the Asp.Net framework repeats those inputs based on how many items there are in the Specifications list in the model class above, and then using javascript I duplicate those inputs, and change the index numbers in their name attributes, and based on my current knowledge of jquery validation, I'm expecting jquery to validate these newly added inputs because the name attributes and everything looks fine, but it doesn't validate them correctly, however, I think it IS trying to validate the inputs but it just doesn't detect errors, if I call the .valid() method on these inputs on the click event, it returns true every time even if the input is empty:

enter image description here

I think jQuery validation works differently than what it seems to be, I thought it will work if the input has the right name and right data attributes (and the input is parsed by jquery unobtrusive after it's added to the DOM) but I think it only works with the initial inputs that are added to the page by the backend framework itself. IDK...


Solution

  • I saw this function which gives you the current validation info of a form:

    var validator = $('form').data('validator');  
    var settings = validator.settings; 
    

    And even after parsing the document, the validation rules were only set for the first inputs:

    enter image description here

    So I had to first remove the whole jquery validation from the form, and only THEN I should parse the document.

    So you have to remove the validation like this:

    $("form").removeData("validator").removeData("unobtrusiveValidation");
    

    And only then, reinitialize jquery validation for the whole document by calling this:

    $.validator.unobtrusive.parse(document);
    

    Now both of the inputs are in the rules array, and their validation works fine:

    enter image description here