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!).

No comments: