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:
Here's a simplified version of what I have:
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; }
}
[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);
}
@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>
}
@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>
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?
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