Thursday, 19 April 2012

Silverlight 5 Validation

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

image

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.

image

Validation Summary

Lists all validation errors on the grid and form.

image

image

Validation Tooltips

image

image

image

image

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

1 comment:

  1. Thanks for the information. Very helpful and useful!

    However, I'm not sure if it's just my browser (IE 9) or not, but the page is terribly slow to scroll.

    ReplyDelete