asp.net-mvcvalidationkendo-gridkendo-asp.net-mvcmodelstate

Server-side validation for newly created rows in a Kendo grid (in-cell, batch editing mode)


I have a Kendo Grid with InCell editing that sends created/updated records to the server in batches (.Batch(true)).

Here's a pared-down example of the grid definition:

@(Html.Kendo().Grid<TagEditingGridViewModel>()
    .Name("...")
    .Columns(c =>
    {
        c.Bound(e => e.TagText);
        c.Bound(e => e.Description);
    })
    .Editable(e => e.Mode(GridEditMode.InCell))
    .DataSource(d => d
        .Ajax()
        .Batch(true)
        .Model(m => m.Id(e => e.ID))
        //.Events(e => e.Error("...").RequestEnd("..."))
        // Read, Update, Create actions
    )
)

The grid handles Tag items, which must have a unique, non-empty value in the TagText property.

Here's the grid's model class, with its validation attributes:

public class TagEditingGridViewModel
{
    public int ID { get; set; }

    [Required(AllowEmptyStrings = false, ErrorMessage = "A tag text is required.")]
    [StringLength(50, ErrorMessage = "Text cannot be longer than 50 characters")]
    public string TagText { get; set; }

    [StringLength(250, ErrorMessage = "Description cannot be longer than 250 characters")]
    public string Description { get; set; }
}

The [StringLength] attribute triggers client-side validation, as does the [Required] attribute when the field is empty. But server-side validation is still needed when the TagText field is whitespace only, and to check uniqueness.

This server-side validation needs to take place both on updating an existing record and on creating a new record. That's where the problem begins. For an existing record, the model has an ID in the database that can be used to find the corresponding row in the grid. But a new record that does not pass validation does not get an ID in the database and does not have a (unique) ID in the grid rows - it is set to 0, so you can't identify a row from that property.

In this post in the Kendo forums, a Telerik employee has posted a solution to showing a server-side validation error in a Kendo grid with InCell and batch editing. Unfortunately, they only show the solution on update, not on create.

In their suggested solution, they use the onError event of the grid's DataSource, where they find the the row in the grid using the model's ID field.

// Controller:
currentErrors.Add(new Error() { id = model.LookupId, errors = errorMessages });

// JavaScript:
var item = dataSource.get(error.id);
var row = grid.table.find("tr[data-uid='" + item.uid + "']");

In my create action, I loop through the incoming items and set the key in the model state dictionary to "models[i].TagText". When the TagText is a string that only contains whitespace, the [Required] attribute catches this server-side, and adds a model state error in that same format.

// items: List<TagEditingGridViewModel>

for (int i = 0; i < items.Count(); i++)
{
    // check for uniqueness of TagText ...
    
    // this is the way the validation attributes do it
    ModelState.AddModelError($"models[{i}].TagText", "Tag text must be unique.");
}

return Json(items.ToDataSourceResult(request, ModelState), JsonRequestBehavior.AllowGet);

In my grid, I can add a handler to the RequestEnd event, which has access to the request type (read, create, or update), the data sent back from the server (which would be items), and any model state errors.

But I still have the problem that I'm not able to map items with an ID of 0 to rows in the grid. Is there any guarantee that the items are still in the same order they were sent, and that that is the order they are in the DOM?


Solution

  • Here's how I ended up solving this issue:

    1. I first modified my grid view model to include a property for the Kendo grid row's UID.

      public string KendoRowUID { get; set; }
      
    2. I added two events to the grid's DataSource (not to the grid as a whole).
      In the Change event, when the action was "add" (when a new row is added), I set the data item's KendoRowUID property to the row's UID.

      .DataSource(d => d
          // ...
          .Events(e => e
              .Change("grdEditTagsOnChange")
              .Error("grdEditTagsOnError")     // explained in step 7
          )
      )
      
      function grdEditTagsOnChange(e) {
          // set the KendoRowUID field in the datasource object to the row uid attribute
          if (e.action == "add" && e.items.length) {
              var item = e.items[0];
              item.KendoRowUID = item.uid;
          }
      }
      
    3. Based on what information I needed to show the ModelState errors on the page, I created this method in my controller. It simply takes the fields I needed and sticks them into a JSON object string that I can later deserialize in JavaScript.
      I added all ModelState errors under the key "", so that later (step 7), they all show up under e.errors[""].

      private void AddGridModelError(string field, string message, 
                                     string kendoRowUid, int? modelId = null)
      {   
          var error = new {    
              field,    
              message, 
              kendoRowUid,
              modelId = (modelId != null && modelId > 0) ? modelId : null 
          };
          ModelState.AddModelError("", 
              // Newtonsoft.Json
              JsonConvert.SerializeObject(error, Formatting.None));
      }
      
    4. I created this method to modify any existing ModelState errors to fit the new format. This is necessary because the [Required(AllowEmptyStrings = false)] attribute does catch empty strings, but only server-side (empty strings don't get caught in client-side validation).
      (This may not be the most efficient or best way to do it, but it works.)

      private void AlterModelError(List<TagEditingGridViewModel> items)
      {
          // stick them in this list (not straight in ModelState)
          // so can clear existing errors out of the modelstate easily
          var newErrors = new List<(string, string, string, int)>();
      
          // get existing model state errors
          var modelStateErrors = ModelState.Where(ms => ms.Key != "" && ms.Value.Errors.Any());
          foreach (var mse in modelStateErrors)
          {
              // the validation attributes do it like this: "models[0].TagText"
              if (mse.Key.Contains('.'))
              {
                  var split = mse.Key.Split('.');
                  if (split.Length == 2)
                  {
                      // get index from "models[i]" part
                      var regex = new Regex(@"models\[(\d+)\]");
                      var match = regex.Match(split[0]);
      
                      var index = match.Groups[1].Value?.ToInt();
                      if (index != null)
                      {
                          var item = items[index.Value];
                          foreach (var err in mse.Value.Errors)
                          {
                              newErrors.Add((split[1], err.ErrorMessage, item.KendoRowUID, item.ID));
                          }
                      }
                  }
              }
          }
      
          // clear everything from the model state, and add new-format errors
          ModelState.Clear();
          foreach (var item in newErrors)
          {
              // call the method shown in step 3:
              AddGridModelError(item.Item1, item.Item2, item.Item3, item.Item4);
          }
      }
      
    5. In the create/update grid actions, I call the AlterModelError method if there are any ModelState errors already present. And did additional validation as necessary.

      if (!ModelState.IsValid)
      {
          AlterModelError(items);
      }
      
      // 'item' is type: TagEditingGridViewModel
      AddGridModelError(
          nameof(TagEditingGridViewModel.TagText), 
          "The tag text must be unique.", 
          item.KendoRowUID, 
          item.ID);
      
    6. At the end of the create/update grid actions, I made sure to include the ModelState dictionary when calling ToDataSourceResult:

      return Json(result.ToDataSourceResult(request, ModelState), JsonRequestBehavior.AllowGet);
      
    7. Finally, in the grid's DataSource's Error event, I ...

      • Check if there are any errors in the event errors property

      • Add a one-time handler to the grid's DataSource sync event

      • In that sync event handler, loop through all the errors, and

      • Parse the string into a JSON object

      • Find the <tr> row.
        If the item already exists in the database, its ID field can be used to get the item from the DataSource, and the row can be gotten from there. If the item was a newly created item, its ID is still set to 0, so the kendoRowUid property of the JSON object is used.

      • Use the field property of the JSON object to locate the correct column (and thus, cell) within the row

      • Append an element to the cell that shows the validation message

      function grdEditTagsOnError(e) {
        // if there are any errors
        if (e.errors && e.errors[""]?.errors.length) {
          var grid = $("#grdEditTags").data("kendoGrid");
      
          // e.sender is the dataSource
          // add a one-time handler to the "sync" event
          e.sender.one("sync", function (e) {
      
            // loop through the errors
            e.errors[""].errors.forEach(err => {
              // try to parse error message (custom format) to a json object
              var errObj = JSON.parse(err);
      
              if (errObj) {
                if (errObj.kendoRowUid) {
                  // find row by uid
                  var row = grid.table.find("tr[data-uid='" + errObj.kendoRowUid + "']");
                } else if (errObj.modelId) {
                  // find row by model id
                  var dsItem = grid.dataSource.get(errObj.modelId);
                  var row = grid.table.find("tr[data-uid='" + dsItem.uid + "']");
                }
      
                // if the row was found
                if (row && row.length) {
                  // find the index of the column
                  var column = null;
                  for (var i = 0; i < grid.columns.length; i++) {
                    if (grid.columns[i].field == errObj.field) {
                      column = i;
                    }
                  }
      
                  if (column != null) {
                    // get the <td> cell
                    var cell = row.find("td:eq(" + column + ")");
                    if (cell) {
                      // create the validation message
                      // in the same format as the grid's default validation elements
                      var valMessage =
                        '<div class="k-tooltip k-tooltip-error k-validator-tooltip k-invalid-msg field-validation-error" ' +
                             'data-for="' + errObj.field + '" ' +
                             'id="' + errObj.field + '_validationMessage" ' +
                             'data-valmsg-for="' + errObj.field + '">' +
                          '<span class="k-tooltip-icon k-icon k-i-warning"></span>' +
                          '<span class="k-tooltip-content">' + errObj.message + '</span>' +
                          '<span class="k-callout k-callout-n"></span>' +                         
                        '</div>';
      
                      // insert validation message after
                      cell.html(cell.html() + valMessage);
      
                      // make the message not cut off
                      cell.css("overflow", "visible");
      
                    }  // end 'if (cell)'
                  }  // end 'if (column != null)'
                }  // end 'if (row && row.length)'
              }  // end 'if (errObj)'
            });// end 'errors.forEach'
          });// end 'e.sender.one("sync", function ...'
        }  // end if any errors
      }  // end function