Saturday, July 9, 2011

Taking data binding, validation and MVVM to the next level - part 2

In part 1 of this series, you saw how to:
  • create a Validation rule
  • add that rule to the databinding of your TextBox
  • show a negative validation result in the tooltip of the TextBox

In this session, we are going to extend the validation rule to more completely check the file path entered by the user. If converters are the most useful and awesome additions to databinding, then validation rules have to be the second most useful and awesome. A couple of reasons why they are so awesome are:
  • you can use them declaratively in XAML
  • you can pass extra parameters to the validation rule
  • validation rules encapsulate logic and separate that logic out from your model or view model
  • validation rules are highly testable, unlike validation done via the IDataErrorInfo interface
  • you can specify multiple different validation rules on a binding
  • you have some control over when they are invoked

Okay, on to business. We are going to use two handy static methods on the Path class, GetInvalidPathChars and GetInvalidFileNameChars. We have to use these in the correct order - there are some characters that are legal in a path, but not in a file name, so there is no point testing the file name before the path. Here is some code:

using System;
using System.IO;
using System.Linq;
using System.Windows.Controls;

namespace FilePathValidation1
{
public class FilePathValidationRule : ValidationRule
{
public override ValidationResult Validate(object value, System.Globalization.CultureInfo cultureInfo)
{
if (value != null && value.GetType() != typeof(string))
return new ValidationResult(false, "Input value was of the wrong type, expected a string");

var filePath = value as string;

if (string.IsNullOrWhiteSpace(filePath))
return new ValidationResult(false, "The file path cannot be empty or whitespace.");

//check the path:
if (Path.GetInvalidPathChars().Any(x => filePath.Contains(x)))
return new ValidationResult(false, string.Format("The characters {0} are not permitted in a file path.", GetPrinatbleInvalidChars(Path.GetInvalidPathChars())));


return new ValidationResult(true, null);
}

/// <summary>
/// Gets the printable characters from the passed char array.
/// </summary>
/// <param name="chars">The array of characters to check.</param>
/// <returns>Returns a string containing the printable characters.</returns>
private string GetPrinatbleInvalidChars(char[] chars)
{
string invalidChars = string.Join("", chars.Where(x => !Char.IsWhiteSpace(x)));
return invalidChars;
}

}
}


So it is quite simple, we grab the list of invalid characters, then using LINQ check each one until we find the first failure, at which point we return a negative validation result, and include the printable invalid characters in the error message.

Checking the file name itself is quite similar:

      public override ValidationResult Validate(object value, System.Globalization.CultureInfo cultureInfo)
{
if (value != null && value.GetType() != typeof(string))
return new ValidationResult(false, "Input value was of the wrong type, expected a string");

var filePath = value as string;

if (string.IsNullOrWhiteSpace(filePath))
return new ValidationResult(false, "The file path cannot be empty or whitespace.");

//check the path:
if (Path.GetInvalidPathChars().Any(x => filePath.Contains(x)))
return new ValidationResult(false, string.Format("The characters {0} are not permitted in a file path.", GetPrinatbleInvalidChars(Path.GetInvalidPathChars())));

//check the filename (if one can be isolated out):
string fileName = Path.GetFileName(filePath);
if (Path.GetInvalidFileNameChars().Any(x => fileName.Contains(x)))
return new ValidationResult(false, string.Format("The characters {0} are not permitted in a file name.", GetPrinatbleInvalidChars(Path.GetInvalidFileNameChars())));


return new ValidationResult(true, null);
}


Because we have already dealt with a possibly null filePath value earlier on in the function, we don't need to worry about the GetFileName() function returning a null, it will return either the file name, or string.Empty.
However.... we have a catch-22 situation here - we need to get the file name so we can check it for invalid characters, but GetFileName() will itself throw an ArgumentException if it encounters an invalid character. So the answer is to wrap that statement in a try...catch:

          //check the filename (if one can be isolated out):
try
{
string fileName = Path.GetFileName(filePath);
if (Path.GetInvalidFileNameChars().Any(x => fileName.Contains(x)))
throw new ArgumentException(string.Format("The characters {0} are not permitted in a file name.", GetPrinatbleInvalidChars(Path.GetInvalidFileNameChars())));
}
catch (ArgumentException e) { return new ValidationResult(false, e.Message); }


Rather than code up two different lines returning a ValidationResult, I have employed the cheap'n'nasty hack of returning it from the catch clause, and throwing my own ArgumentException if necessary to get to it. I wouldn't do this in real code, I'm only doing it here to keep things shorter, and I warned you in the last post that this is example code not coded for prettiness. By doing this I can piggyback upon the exception message returned by the call to GetFileName().

Now one final thing - let's tidy up that empty/null entry condition checking. We are going to add a boolean property to the FilePathValidationRule to indicate whether it is allowable to have a null or empty path, we will add a new check into the rule that uses the new property, and we will set that new property from XAML.

    public class FilePathValidationRule : ValidationRule
{

public override ValidationResult Validate(object value, System.Globalization.CultureInfo cultureInfo)
{
if (value != null && value.GetType() != typeof(string))
return new ValidationResult(false, "Input value was of the wrong type, expected a string");

var filePath = value as string;

//check for empty/null file path:
if (string.IsNullOrEmpty(filePath))
{
if (!AllowEmptyPath)
return new ValidationResult(false, "The file path may not be empty.");
else
return new ValidationResult(true, null);
}

//null & empty has been handled above, now check for pure whitespace:
if (string.IsNullOrWhiteSpace(filePath))
return new ValidationResult(false, "The file path cannot consist only of whitespace.");

//check the path:
if (Path.GetInvalidPathChars().Any(x => filePath.Contains(x)))
return new ValidationResult(false, string.Format("The characters {0} are not permitted in a file path.", GetPrinatbleInvalidChars(Path.GetInvalidPathChars())));

//check the filename (if one can be isolated out):
try
{
string fileName = Path.GetFileName(filePath);
if (Path.GetInvalidFileNameChars().Any(x => fileName.Contains(x)))
throw new ArgumentException(string.Format("The characters {0} are not permitted in a file name.", GetPrinatbleInvalidChars(Path.GetInvalidFileNameChars())));
}
catch (ArgumentException e) { return new ValidationResult(false, e.Message); }

return new ValidationResult(true, null);
}

/// <summary>
/// Gets and sets a flag indicating whether an empty path forms an error condition or not.
/// </summary>
public bool AllowEmptyPath { get; set; }


private string GetPrinatbleInvalidChars(char[] chars)
{
string invalidChars = string.Join("", chars.Where(x => !Char.IsWhiteSpace(x)));
return invalidChars;
}

}


<Window x:Class="FilePathValidation1.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow"
SizeToContent="WidthAndHeight"

xmlns:this="clr-namespace:FilePathValidation1"
>

<Window.Resources>
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="true">
<Setter Property="ToolTip" Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}"/>
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>

<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="20" >
<TextBlock Text="Enter the path to your file" VerticalAlignment="Bottom" />
<TextBox x:Name="FilePathTextBox" Width="350" Margin="5,0,0,0">
<TextBox.Text>
<Binding Path="FilePath" UpdateSourceTrigger="PropertyChanged" >
<Binding.ValidationRules>
<this:FilePathValidationRule AllowEmptyPath="True" />
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
<Button x:Name="FileBrowseButton"
Content="..."
Command="{Binding FileBrowseCommand}"
Width="20" Margin="5,0,0,0"
/>
</StackPanel>
</Grid>
</Window>


As you can see, the only change to the XAML was the use of the new AllowEmptyPath property (which you only have to set if you need a value different from its default of false). From the next three images, you'll see that our new rule conditions are working quite nicely:






But.... remember before when I said that there were a lot of edge cases, and the functions built into the .Net framework were not going to be able to do all the work for you? Check this nasty path, which according to our rules is valid:



Tune in for the next post, where I show you how to catch this, and also illustrate a nice edge case regarding path length (which is not always limited to 260 characters! A-ha!).

Thursday, July 7, 2011

Taking data binding, validation and MVVM to the next level - part 1

I've been having fun today, working on something that on the surface seems very simple, but once you delve into it there are a lot of complexities and edge cases hidden just below the surface.

Today boys and girls, let's talk about how to validate a file system path. We are going to do this in a nice MVVM compliant way.

First, let us set the scene; how many times have you coded up a window with a textbox and a simple button which opens the file or folder browse dialog:



The XAML code for this is very simple:
<Window x:Class="FilePathValidation1.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow"
SizeToContent="WidthAndHeight"
>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="20" >
<TextBlock Text="Enter the path to your file" VerticalAlignment="Bottom" />
<TextBox x:Name="FilePathTextBox" Text="{Binding FilePath}" Width="350" Margin="5,0,0,0" />
<Button x:Name="FileBrowseButton"
Content="..."
Command="{Binding FileBrowseCommand}"
Width="20" Margin="5,0,0,0"
/>
</StackPanel>
</Grid>
</Window>


And the class behind:

using System;
using System.Windows;
using System.Windows.Input;
using Microsoft.Win32;
using System.ComponentModel;

namespace FilePathValidation1
{

///
/// Interaction logic for MainWindow.xaml
///

public partial class MainWindow : Window, INotifyPropertyChanged
{
public MainWindow()
{
InitializeComponent();
this.Loaded += new RoutedEventHandler(MainWindowLoaded);
}

private void MainWindowLoaded(object sender, RoutedEventArgs e)
{
this.DataContext = this;
}

///
/// Gets the command used to browse for a file.
///

public ICommand FileBrowseCommand
{
get
{
if (_fileBrowseCommand == null)
_fileBrowseCommand = new RelayCommand(OpenFileBrowseDialog);
return _fileBrowseCommand;
}
}

///
/// Gets and sets the file path.
///

public string FilePath
{
get { return _filePath; }
set
{
if (!string.Equals(value, _filePath, StringComparison.InvariantCultureIgnoreCase))
{
_filePath = value;
OnPropertyChanged("FilePath");
}
}
}

private void OpenFileBrowseDialog(object context)
{
OpenFileDialog dlg = new OpenFileDialog();
var retVal = dlg.ShowDialog();
if (retVal.HasValue && retVal.Value)
{
FilePath = dlg.FileName;
}
}

///
/// Raises the event.
///

/// The name of the property that changed.
private void OnPropertyChanged(string propertyName)
{
if (PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}

///
/// Occurs when a property value changes.
///

public event PropertyChangedEventHandler PropertyChanged;


private ICommand _fileBrowseCommand;
private string _filePath;
}
}


Just remember this code isn't designed to win any awards for being pretty.
So if you have a play with the code above, you'll find that you can either enter a file path directly in the textbox, or you can pop open the file browse dialog and select an existing file. All good, yeah?

But pretty quickly you'll also discover that you need to validate anything the user enters. To do this, we'll take advantage of the ValidationRule class that is already built into the framework, and the ValidationRules property that is built into the WPF binding mechanism.

Let's start with the validation rule:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Controls;

namespace FilePathValidation1
{
public class FilePathValidationRule : ValidationRule
{

public override ValidationResult Validate(object value, System.Globalization.CultureInfo cultureInfo)
{
if (value != null && value.GetType() != typeof(string))
return new ValidationResult(false, "Input value was of the wrong type, expected a string");

var filePath = value as string;

if (string.IsNullOrWhiteSpace(filePath))
return new ValidationResult(false, "The file path cannot be empty or whitespace.");


return new ValidationResult(true, null);
}
}
}


This validation rule simply extends the System.Windows.Controls.ValidationRule class that is found in the PresentationFramework assembly, we've got just a couple of simple checks in it for now.

Here is how we use it in the XAML:
<Window x:Class="FilePathValidation1.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow"
SizeToContent="WidthAndHeight"

xmlns:this="clr-namespace:FilePathValidation1"
>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="20" >
<TextBlock Text="Enter the path to your file" VerticalAlignment="Bottom" />
<TextBox x:Name="FilePathTextBox" Width="350" Margin="5,0,0,0">
<TextBox.Text>
<Binding Path="FilePath" UpdateSourceTrigger="PropertyChanged" >
<Binding.ValidationRules>
<this:FilePathValidationRule />
</Binding.ValidationRules>
</Binding>
</TextBox.Text>

</TextBox>
<Button x:Name="FileBrowseButton"
Content="..."
Command="{Binding FileBrowseCommand}"
Width="20" Margin="5,0,0,0"
/>
</StackPanel>
</Grid>
</Window>


I have highlighted the differences in yellow. Notice how we are now using the long form for specifying the binding on the textbox, and we can also specify any number of validation rules to run. These rules are run whenever the user changes what is in the textbox, this is controlled by the UpdateSourceTrigger property on the binding (for example, I could change it so the validation only runs when the user removes the focus from the textbox).

Soooo... all you have to do now to see this in action is run the project, enter some text in the textbox, then delete the text and whammo!! the validation rule will return a validation error, the border of the TextBox will turn red, and with the addition of a handy style on the TextBox the validation message will show in the tooltip:



Pretty nifty, yeah?
Okay, this post is long enough and it lays down the base of what we are going to continue and work with. You need to validate more than just a path consisting of whitespace, you need to also check for forbidden characters and stuff. Go and make yourself a coffee, then come back and read part 2, where I show you why validating a file path can be so darned tricky.