Search code examples
c#asp.net-mvcvalidationunobtrusive-validation

How to display validation errors on page load?


I am creating a feature in my app to process an uploaded CSV file containing multiple records to be imported. Data needs to be validated, and I want to show any validation errors BEFORE the Import button is clicked. High-level plan:

  • Step 1: Upload CSV file
  • Step 2: Display all records from CSV file and any validation errors next to each record (missing required fields, etc.)
  • Step 3: Click "Import" in order to actually import the valid records.

Here's a simplified version of what I have:

User View Model

public class UserViewModel
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; }

    [Required]
    [StringLength(150)]
    public string Email { get; set; }

    [Required]
    [StringLength(10)]
    public string Phone { get; set; }
}

File Upload Action Post

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Upload(HttpPostedFileBase csvFile)
{
    // var csvRecords = do stuff to retrieve data from CSV file
    var newUsersToCreate = new List<UserViewModel>();

    foreach (var csvRecord in csvRecords)
    {
        newUsersToCreate.Add(new UserViewModel
        {
            Name = csvRecord.Name,
            Email = csvRecord.Email,
            Phone = csvRecord.Phone
        });
    }
    return View("ImportPreview", newUsersToCreate);
}

View ImportPreview.cshtml

@model IEnumerable<App.ViewModels.UserViewModel>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    @Html.ValidationSummary(true, "", new { @class = "alert alert-danger", role = "alert" })

    <table>
        <thead>
            <tr>
                <th>Name</th>
                <th>Email</th>
                <th>Phone</th>
                <th>Validation Errors</th>
            </tr>
        </thead>
        <tbody>
            @Html.EditorFor(model => model)
        </tbody>
    </table>

    <button type="submit">Import</button>
}

Editor Template for UserViewModel.cshtml

@model App.ViewModels.UserViewModel

<tr>
    <td>
        @Html.HiddenFor(model => model.Name)
        @Html.DisplayFor(model => model.Name)
    </td>
    <td>
        @Html.HiddenFor(model => model.Email)
        @Html.DisplayFor(model => model.Email)
    </td>
    <td>
        @Html.HiddenFor(model => model.Phone)
        @Html.DisplayFor(model => model.Phone)
    </td>
    <td>
        @Html.ValidationMessageFor(model => model.Name, "", new { @class = "text-danger" })
        @Html.ValidationMessageFor(model => model.Email, "", new { @class = "text-danger" })
        @Html.ValidationMessageFor(model => model.Phone, "", new { @class = "text-danger" })
    </td>
</tr>

Problem

While this generates a nice "preview" table with all prepared User records as essentially rows of hidden fields ready to go, the problem is that it does not display validation errors until the Import button is clicked.

How can I get it to show per-field validation errors in each row, right after the return View('ImportPreview', newUsersToCreate) comes back with the view?


Solution

  • You could do this in the view by checking if the $.validator is valid. Since hidden inputs are not validated by default, you also need to override the validator. Add the following after the jquery-{version}.js, jquery.validate.js and jquery.validate.unobtrusive.js scripts (but not in $(document).ready())

    <script>
        // override validator to include hidden inputs
        $.validator.setDefaults({ 
            ignore: [] 
        });
        // validate form and display errors
        $('form').valid();
    </script>
    

    Note that you might include a (say) <p id="error" style="display:none;"> tag containing a 'general' error message that the data is invalid and use

    if ($('form').valid()) {
        $('#error').show();
    }
    

    The disadvantage is that you need to include the jQuery scripts that otherwise are not needed.

    Another option is to validate in the controller using TryValidateObject on each item in the collection, and add any errors to ModelState which will be displayed in your ValidationMessageFor() placeholders. Note the following assumes csvRecords implements IList<T> so that you can use a for loop.

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Upload(HttpPostedFileBase csvFile)
    {
        // var csvRecords = do stuff to retrieve data from CSV file
        var newUsersToCreate = new List<UserViewModel>();
        for (int i = 0; i < csvRecords.Count; i++)
        {
            UserViewModel model = new UserViewModel
            {
                Name = csvRecords[i].Name,
                ....
            };
            newUsersToCreate.Add(model);
            // validate the model and include the collection indexer
            bool isValid = ValidateModel(model, i));   
        }
        return View("ImportPreview", newUsersToCreate);
    }
    
    private bool ValidateModel(object model, int index) 
    { 
        var validationResults = new List<ValidationResult>(); 
        var context = new ValidationContext(model); 
        if (!Validator.TryValidateObject(model, context, validationResults, true)) 
        { 
            foreach (var error in validationResults) 
            { 
                string propertyName = $"[{index}].{error.MemberNames.First()}"; 
                ModelState.AddModelError(propertyName, error.ErrorMessage); 
            } 
            return false; 
        } 
        return true; 
    }
    

    The advantage of the controller code is that you could add an additional property to your view model (say bool IsValid) and use it for conditional styling of your table rows, and that you could decide that if there are 'too many' errors, you could just display a different view rather that rendering the whole table and displaying potentially hundreds of repeated error messages