Moving forward with .NET 4.5 the validation story will be played out through the INotifyDataErrorInfo interface.
http://msdn.microsoft.com/en-us/library/system.componentmodel.inotifydataerrorinfo(v=vs.110).aspx
Here is an example of a Silverlight 5 grid and form using INDEI for its validation:
This demo can also be accessed online here.
Tricks
This example shows the following tricks when validating user input in Silverlight:
- MVVM with independent validation rules allowing for contextual validation with injection into view model via the constructor
- No validation is applied when first loading up the form
- Validation is applied when each control is updated, with form wide validation being applied when the user clicks the Ok button
- DatePicker control is loaded with no date and also prevents user textual input along with its validation
- Fluent Validation uses length, email and regex rules to validate properties
- View model implements INotifyPropertyChanged, INotifyDataErrorInfo and IEditableObject interfaces
- INotifyPropertyChanged and INotifyDataErrorInfo implementations are stored within an abstract view model base class
- Model implements bespoke generic ICloneable interface
- Cancel (by pressing escape) on grid reverts the data and controls back to original state
- RelayCommand has been used for the command buttons
Class diagram
Styles
This example explicitly contains all styles used in this solution, within the app.xaml file.
Screenshots
Description viewer
Used to show if a field is required and/or any of other relevant info, when the user hovers over the little circle with the i for information to the right of the control.
Validation Summary
Lists all validation errors on the grid and form.
Validation Tooltips
Validator code
using System;
using FluentValidation;
namespace SilverlightValidation
{
public class UserModelValidator : AbstractValidator<IUserModel>
{
public UserModelValidator()
{
RuleFor(x => x.Username)
.Length(3, 8)
.WithMessage("Must be between 3-8 characters.");
RuleFor(x => x.Password)
.Matches(@"^\w*(?=\w*\d)(?=\w*[a-z])(?=\w*[A-Z])\w*$")
.WithMessage("Must contain lower, upper and numeric chars.");
RuleFor(x => x.Email)
.EmailAddress()
.WithMessage("A valid email address is required.");
RuleFor(x => x.DateOfBirth)
.Must(BeAValidDateOfBirth)
.WithMessage("Must be within 100 years of today.");
}
private bool BeAValidDateOfBirth(DateTime? dateOfBirth)
{
if (dateOfBirth == null) return false;
if (dateOfBirth.Value > DateTime.Today || dateOfBirth < DateTime.Today.AddYears(-100))
return false;
return true;
}
}
}
ViewModelBase code
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace SilverlightValidation
{
public class ViewModelBase : INotifyPropertyChanged, INotifyDataErrorInfo
{
#region INotifyPropertyChanged method plus event
public event PropertyChangedEventHandler PropertyChanged = delegate { };
protected void RaisePropertyChanged(string propertyName)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
#region INotifyDataErrorInfo methods and helpers
private readonly Dictionary<string, List<string>> _errors = new Dictionary<string, List<string>>();
public void SetError(string propertyName, string errorMessage)
{
if (!_errors.ContainsKey(propertyName))
_errors.Add(propertyName, new List<string> { errorMessage });
RaiseErrorsChanged(propertyName);
}
protected void ClearError(string propertyName)
{
if (_errors.ContainsKey(propertyName))
_errors.Remove(propertyName);
RaiseErrorsChanged(propertyName);
}
protected void ClearAllErrors()
{
var errors = _errors.Select(error => error.Key).ToList();
foreach (var propertyName in errors)
ClearError(propertyName);
}
public void RaiseErrorsChanged(string propertyName)
{
ErrorsChanged(this, new DataErrorsChangedEventArgs(propertyName));
}
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged = delegate { };
public IEnumerable GetErrors(string propertyName)
{
return _errors.ContainsKey(propertyName)
? _errors[propertyName]
: null;
}
public bool HasErrors
{
get { return _errors.Count > 0; }
}
#endregion
}
}
UserListViewModel code
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Windows.Input;
using SilverlightValidation.Commands;
using SilverlightValidation.Models;
using SilverlightValidation.Validators;
using SilverlightValidation.Views;
using GalaSoft.MvvmLight.Messaging;
using SilverlightValidation.Messages;
namespace SilverlightValidation.ViewModels
{
public class UserListViewModel
{
UserView window;
public UserListViewModel(IList<UserModel> models, UserModelValidator validator)
{
Data = new ObservableCollection<UserViewModel>();
foreach (var model in models)
Data.Add(new UserViewModel(model, validator));
AddCommand = new RelayCommand(AddCommandExecute);
DeleteCommand = new RelayCommand(DeleteCommandExecute);
Messenger.Default.Register<UserViewResponseMessage>(this, UserViewResponseMessageReceived);
}
private void UserViewResponseMessageReceived(UserViewResponseMessage userViewResponseMessage)
{
if (userViewResponseMessage.UserViewModel != null)
Data.Add(userViewResponseMessage.UserViewModel);
window.Close();
}
#region Properties
public ObservableCollection<UserViewModel> Data { get; set; }
public UserViewModel SelectedItem { get; set; }
#endregion
#region Commands
public ICommand AddCommand { get; set; }
public ICommand DeleteCommand { get; set; }
private void AddCommandExecute(object obj)
{
window = new UserView();
window.Show();
}
private void DeleteCommandExecute(object obj)
{
if (SelectedItem!=null)
Data.Remove(SelectedItem);
}
#endregion
}
}
UserViewModel
using System;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Input;
using FluentValidation;
using SilverlightValidation.Interfaces;
using SilverlightValidation.Validators;
using SilverlightValidation.Models;
using SilverlightValidation.Commands;
using GalaSoft.MvvmLight.Messaging;
using SilverlightValidation.Messages;
namespace SilverlightValidation.ViewModels
{
public class UserViewModel : ViewModelBase, IUserModel, IEditableObject
{
#region Fields
private readonly UserModelValidator _validator;
private UserModel _data;
private UserModel _backup;
#endregion
#region Constructor
public UserViewModel(UserModel model, UserModelValidator validator)
{
_validator = validator;
_data = model;
_backup = model.Clone();
OkCommand = new RelayCommand(OkCommandExecute);
CancelCommand = new RelayCommand(CancelCommandExecute);
}
#endregion
#region Methods
private void SetProperties(IUserModel source)
{
_data.Username = source.Username;
_data.Password = source.Password;
_data.Email = source.Email;
_data.DateOfBirth = source.DateOfBirth;
_data.Description = source.Description;
}
#endregion
#region Properties
private const string UsernameProperty = "Username";
public string Username
{
get { return _data.Username; }
set
{
if (_data.Username != value)
{
_data.Username = value;
RaisePropertyChanged(UsernameProperty);
IsChanged = true;
}
ClearError(UsernameProperty);
var validationResult = _validator.Validate(this, UsernameProperty);
if (!validationResult.IsValid)
validationResult.Errors.ToList().ForEach(x => SetError(UsernameProperty, x.ErrorMessage));
}
}
private const string PasswordProperty = "Password";
public string Password
{
get { return _data.Password; }
set
{
if (_data.Password != value)
{
_data.Password = value;
RaisePropertyChanged(PasswordProperty);
IsChanged = true;
}
ClearError(PasswordProperty);
var validationResult = _validator.Validate(this, PasswordProperty);
if (!validationResult.IsValid)
validationResult.Errors.ToList().ForEach(x => SetError(PasswordProperty, x.ErrorMessage));
}
}
private const string EmailProperty = "Email";
public string Email
{
get { return _data.Email; }
set
{
if (_data.Email != value)
{
_data.Email = value;
RaisePropertyChanged(EmailProperty);
IsChanged = true;
}
ClearError(EmailProperty);
var validationResult = _validator.Validate(this, EmailProperty);
if (!validationResult.IsValid)
validationResult.Errors.ToList().ForEach(x => SetError(EmailProperty, x.ErrorMessage));
}
}
private const string DateOfBirthProperty = "DateOfBirth";
public DateTime? DateOfBirth
{
get { return _data.DateOfBirth; }
set
{
if (_data.DateOfBirth != value)
{
_data.DateOfBirth = value;
RaisePropertyChanged(DateOfBirthProperty);
IsChanged = true;
}
ClearError(DateOfBirthProperty);
var validationResult = _validator.Validate(this, DateOfBirthProperty);
if (!validationResult.IsValid)
validationResult.Errors.ToList().ForEach(x => SetError(DateOfBirthProperty, x.ErrorMessage));
}
}
private const string DescriptionProperty = "Description";
public string Description
{
get { return _data.Description; }
set
{
if (_data.Description != value)
{
_data.Description = value;
RaisePropertyChanged(DescriptionProperty);
IsChanged = true;
}
ClearError(DescriptionProperty);
var validationResult = _validator.Validate(this, DescriptionProperty);
if (!validationResult.IsValid)
validationResult.Errors.ToList().ForEach(x => SetError(DescriptionProperty, x.ErrorMessage));
}
}
#endregion
#region Commands
public ICommand OkCommand { get; set; }
public ICommand CancelCommand { get; set; }
private void OkCommandExecute(object obj)
{
RefreshToViewErrors();
if (IsChanged && !HasErrors)
{
// save here
Messenger.Default.Send<UserViewResponseMessage>(
new UserViewResponseMessage() { UserViewModel = this });
}
}
// in case user hasn't touched the form
private void RefreshToViewErrors()
{
Username = _data.Username;
Password = _data.Password;
Email = _data.Email;
DateOfBirth = _data.DateOfBirth;
}
private void CancelCommandExecute(object obj)
{
Messenger.Default.Send<UserViewResponseMessage>(
new UserViewResponseMessage() { UserViewModel = null });
}
#endregion
private void ResetFormData()
{
SetProperties(_backup);
ClearAllErrors();
IsChanged = false;
}
public bool IsChanged { get; private set; }
#region IEditableObject for datagrid
private bool inEdit;
public void BeginEdit()
{
if (inEdit) return;
inEdit = true;
}
public void CancelEdit()
{
if (!inEdit) return;
inEdit = false;
ResetFormData();
}
public void EndEdit()
{
if (!inEdit) return;
}
#endregion
}
}
Model code
using System;
using System.ComponentModel;
namespace SilverlightValidation
{
public interface IUserModel
{
string Username { get; set; }
string Email { get; set; }
string Password { get; set; }
DateTime? DateOfBirth { get; set; }
string Description { get; set; }
}
public interface ICloneable<T>
{
T Clone();
}
public class UserModel : IUserModel, ICloneable<UserModel>
{
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public DateTime? DateOfBirth { get; set; }
public string Description { get; set; }
public static UserModel Create()
{
return new UserModel() { Username = "", Email = "", Password = "", DateOfBirth = null, Description = "" };
}
public UserModel Clone()
{
return (UserModel) this.MemberwiseClone();
}
}
}
UserListView code
<UserControl x:Class="SilverlightValidation.Views.UserListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Data"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:p="clr-namespace:System.Windows.Controls.Primitives;assembly=System.Windows.Controls"
xmlns:s="clr-namespace:System;assembly=mscorlib"
xmlns:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk"
d:DesignHeight="400"
d:DesignWidth="725"
mc:Ignorable="d">
<Grid x:Name="LayoutRoot" Background="White">
<Grid.RowDefinitions>
<RowDefinition Height="30" />
<RowDefinition Height="40" />
<RowDefinition Height="300" />
<RowDefinition Height="50" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="725" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="1"
Grid.Column="1"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button Width="60"
Command="{Binding AddCommand}"
Content="Add"
Style="{StaticResource ButtonStyle}" />
<Button Width="60"
Command="{Binding DeleteCommand}"
Content="Delete"
Style="{StaticResource ButtonStyle}" />
</StackPanel>
<controls:DataGrid Grid.Row="2"
Grid.Column="1"
AutoGenerateColumns="False"
ItemsSource="{Binding Data}"
SelectionMode="Single"
SelectedItem="{Binding SelectedItem, Mode=TwoWay}">
<controls:DataGrid.Columns>
<controls:DataGridTextColumn Width="125"
Binding="{Binding Username,
Mode=TwoWay,
ValidatesOnNotifyDataErrors=True,
NotifyOnValidationError=True}"
Header="Username" />
<controls:DataGridTemplateColumn Width="125" Header="Password">
<sdk:DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<PasswordBox Password="{Binding Password, Mode=TwoWay, ValidatesOnNotifyDataErrors=True, NotifyOnValidationError=True}" />
</DataTemplate>
</sdk:DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumn>
<controls:DataGridTextColumn Width="150"
Binding="{Binding Email,
Mode=TwoWay,
ValidatesOnNotifyDataErrors=True,
NotifyOnValidationError=True}"
Header="Email" />
<controls:DataGridTemplateColumn Width="150" Header="Date of Birth">
<sdk:DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<sdk:DatePicker KeyDown="DatePicker_KeyDown" SelectedDate="{Binding DateOfBirth, Mode=TwoWay, ValidatesOnNotifyDataErrors=True, NotifyOnValidationError=True}" />
</DataTemplate>
</sdk:DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumn>
<controls:DataGridTextColumn Width="150"
Binding="{Binding Description,
Mode=TwoWay,
ValidatesOnNotifyDataErrors=True,
NotifyOnValidationError=True}"
Header="Description" />
</controls:DataGrid.Columns>
</controls:DataGrid>
</Grid>
</UserControl>
UserView code
<c:ChildWindow x:Class="SilverlightValidation.Views.UserView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:c="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:p="clr-namespace:System.Windows.Controls.Primitives;assembly=System.Windows.Controls"
xmlns:s="clr-namespace:System;assembly=mscorlib"
xmlns:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk"
Title="Add User"
Width="500"
Height="400">
<Grid x:Name="LayoutRoot" Background="White">
<Grid.RowDefinitions>
<RowDefinition Height="30" />
<RowDefinition Height="30" />
<RowDefinition Height="30" />
<RowDefinition Height="30" />
<RowDefinition Height="30" />
<RowDefinition Height="30" />
<RowDefinition Height="50" />
<RowDefinition Height="120" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="30" />
<ColumnDefinition Width="100" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="30" />
<ColumnDefinition Width="30" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="1"
Grid.Column="1"
Style="{StaticResource LabelStyle}"
Text="Username:" />
<TextBox x:Name="tbUsername"
Grid.Row="1"
Grid.Column="2"
Style="{StaticResource TextBoxStyle}"
Text="{Binding Username,
Mode=TwoWay,
ValidatesOnNotifyDataErrors=True,
NotifyOnValidationError=True}" />
<sdk:DescriptionViewer Grid.Row="1"
Grid.Column="3"
Width="20"
Description="Required"
Target="{Binding ElementName=tbUsername}" />
<TextBlock Grid.Row="2"
Grid.Column="1"
Style="{StaticResource LabelStyle}"
Text="Password:" />
<PasswordBox x:Name="tbPassword"
Grid.Row="2"
Grid.Column="2"
Password="{Binding Password,
Mode=TwoWay,
ValidatesOnNotifyDataErrors=True,
NotifyOnValidationError=True}"
Style="{StaticResource PasswordBoxStyle}" />
<sdk:DescriptionViewer Grid.Row="2"
Grid.Column="3"
Width="20"
Description="Required"
Target="{Binding ElementName=tbPassword}" />
<TextBlock Grid.Row="3"
Grid.Column="1"
Style="{StaticResource LabelStyle}"
Text="Email:" />
<TextBox x:Name="tbEmail"
Grid.Row="3"
Grid.Column="2"
Style="{StaticResource TextBoxStyle}"
Text="{Binding Email,
Mode=TwoWay,
ValidatesOnNotifyDataErrors=True,
NotifyOnValidationError=True}" />
<sdk:DescriptionViewer Grid.Row="3"
Grid.Column="3"
Width="20"
Description="Required"
Target="{Binding ElementName=tbEmail}" />
<TextBlock Grid.Row="4"
Grid.Column="1"
Style="{StaticResource LabelStyle}"
Text="Date of Birth:" />
<sdk:DatePicker x:Name="dpDateOfBirth"
Grid.Row="4"
Grid.Column="2"
KeyDown="DatePicker_KeyDown"
SelectedDate="{Binding DateOfBirth,
Mode=TwoWay,
ValidatesOnNotifyDataErrors=True,
NotifyOnValidationError=True}"
Style="{StaticResource DatePickerStyle}" />
<sdk:DescriptionViewer Grid.Row="4"
Grid.Column="3"
Width="20"
Description="Required"
Target="{Binding ElementName=dpDateOfBirth}" />
<TextBlock x:Name="tbDescription"
Grid.Row="5"
Grid.Column="1"
Style="{StaticResource LabelStyle}"
Text="Description:" />
<TextBox Grid.Row="5"
Grid.Column="2"
Style="{StaticResource TextBoxStyle}"
Text="{Binding Description}" />
<StackPanel Grid.Row="6"
Grid.Column="2"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button Command="{Binding OkCommand}"
Content="OK"
Style="{StaticResource ButtonStyle}" />
<Button Command="{Binding CancelCommand}"
Content="Cancel"
Style="{StaticResource ButtonStyle}" />
</StackPanel>
<sdk:ValidationSummary Grid.Row="7"
Grid.Column="1"
Grid.ColumnSpan="2"
Style="{StaticResource ValidationSummaryStyle}" />
</Grid>
</c:ChildWindow>
Download the source
http://stevenhollidge.com/blog-source-code/SilverlightValidation.zip
Thanks for the information. Very helpful and useful!
ReplyDeleteHowever, I'm not sure if it's just my browser (IE 9) or not, but the page is terribly slow to scroll.