Page 1 of 1

WPF Build An Application 5, Data Validation

#1 andrewsw  Icon User is offline

  • Fire giant boob nipple gun!
  • member icon

Reputation: 3480
  • View blog
  • Posts: 11,833
  • Joined: 12-December 12

Posted 13 June 2014 - 02:40 PM

Data Validation was first introduced in Part 3 using the ValidationRule Class and Validation.ErrorTemplate.

The process is more involved for complex business rules, where we need to check combinations of field-values.

Some Links:

Enforcing Complex Business Data Rules with WPF
Explicitly Updating And Validating Databindings In WPF
Data Validation in 3.5
WPF: Validation of business objects, a simpler approach

(The first link is very useful, the others vary.)

I also adapted the code from the answer in this StackOverflow question.




Topic List

  • General Considerations
  • What About Our Application?
  • Exception Validation
  • IDataErrorInfo
  • Prevent Navigation
  • Prevent Saving


Attached Image

General Considerations

The primary level of data-validation should be at the data-source (the database) level, using unique indexes, primary keys and constraints, etc.. Thereafter, it is a little tricky to determine at what level, and with how much detail, data-validation should occur.

You, as the developer, have the responsibility to ensure that the integrity of the data is maintained. As the designer, you also should ensure that data-validation occurs in a natural manner, providing the user with useful and clear feedback. You also need to ensure that the validation isn't too obstructive; that is, avoid error messages that are too detailed, or too many of them, and interrupt the user's workflow as little as necessary.

Obtaining feedback from your client and their users is very important, in order to agree an appropriate level and amount of validation.

What About Our Application?

Our application already has a WPF quirk in that, even though the Title should be validated on lost-focus, clicking in and out of the TextBox doesn't trigger this validation. There also isn't a simple, obvious way to prevent navigating away from an item if it contains invalid data. We will tackle these issues.

When the user clicks New, a new ToDo item is created immediately. We also don't have a Cancel or a Confirm/Save button, which would be a natural place to run the data-validation. Instead, if the user wishes to abandon a new item, they have to specifically Delete it. Given this, our approach will be to immediately indicate that the Title is required. That is, we won't have a separate (Explicit) validation-step.

Immediately showing an error message when starting a new item isn't very user-friendly, but it is reasonable for our simple application (and for the purpose of this tutorial).

The Business Rules:

  • The Title cannot be empty or whitespace.
  • No date-value before 1st Jan 2000 is allowed. (This is an arbitrary, but sensible, earliest date.)
  • If there is both a StartDate and a DueDate then the StartDate must be on or before the DueDate.
  • If there is both a StartDate and a CompletedDate then the StartDate must be on or before the CompletedDate.
  • If Completed is ticked then there must be a CompletedDate.
  • If there is a CompletedDate then Completed must be ticked.

(When a CompletedDate is keyed we will automatically tick Completed. However, this is only a convenience to the user, and we still require the validation-rule because the user could then untick Completed.)

You can see how combining validation-rules across several fields can quickly become complicated, and require much more thought (and testing) than rules against individual fields.

Exception Validation

This is the simplest to implement at a single-field level. When attempting to set the value of a bound-property we simply throw an Exception for an invalid value. All we need to do in the XAML is to set ValidatesOnExceptions="True" and the Exception error-message will be fed back and used with the Validation.ErrorTemplate that we built earlier.

If we were, for example, using a TextBox rather than a DatePicker for one of the dates then an Exception would already be raised if the user keyed a non-date. However, without setting ValidatesOnExceptions this Exception would be silently ignored by the presentation-layer (with the value typically reverting to the last-submitted valid date-value).

We will demonstrate this using the Title. Modify ToDo.xaml:
    <TextBox Grid.Row="0" Grid.Column="1" Grid.ColumnSpan="3" x:Name="txtTitle" 
             Validation.ErrorTemplate="{StaticResource validationTemplate}" 
             Style="{StaticResource styTextMissing}">
        <TextBox.Text>
            <Binding Path="/Title" Mode="TwoWay" UpdateSourceTrigger="LostFocus"
                     ValidatesOnExceptions="True">
                <Binding.ValidationRules>
                    <models:TitleValidator></models:TitleValidator>
                </Binding.ValidationRules>
            </Binding>
        </TextBox.Text>
    </TextBox>


The only change here is the addition of ValidatesOnExceptions. Modify ToDo.cs to raise the Exception:
        public string Title {
            get { return _title; }
            set {
                if (_title == value) return;
                _title = value;
                if (string.IsNullOrWhiteSpace(value))
                    throw new FieldAccessException("Title cannot be empty (or whitespace).");
                RaisePropertyChanged("Title");
            }
        }


I chose FieldAccessException but you could use, and find, a more appropriate Exception. This is not essential (you could just raise the (general) Exception) but it is more professional to find one that fits-in with the .NET Exception hierarchy.

You could run and test this. I have kept the previous ValidationRule which uses IsNullOrEmpty. If you delete the Title then this rule's error message will display; if you use a space for the Title then the Exception error-message will appear instead. This demonstrates that there can be several data-validation rules applied to the same bound-control*. The order that they are applied in the XAML is significant, with the first one set, and met, being the one that is used/displayed.

*Side-note: WPF prefers the term 'elements' rather than 'controls' as used by WinForms. I trust that you will forgive my occasional use of the term 'control'.

For the Title we don't need both of these rules, and we should either allow whitespace or not, but keeping both rules is useful for demonstration purposes.

We will do a similar thing for the date-fields, not allowing dates before 1st January 2000. Modify each of the three DatePicker controls in ToDo.xaml following this pattern:
        <DatePicker Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" x:Name="dpStartDate"
                    Validation.ErrorTemplate="{StaticResource validationTemplate}">
            <DatePicker.SelectedDate>
                <Binding Path="/StartDate" Mode="TwoWay" ValidatesOnExceptions="True" />
            </DatePicker.SelectedDate>
        </DatePicker>


(We can continue to use the validationTemplate we created previously.)
In ToDo.cs:
        public DateTime? StartDate {
            get { return _startDate; }
            set {
                if (_startDate == value) return;
                if (value < new DateTime(2000, 1, 1))
                    throw new IndexOutOfRangeException("Must be 1st Jan 2000 or later.");
                _startDate = value;
                RaisePropertyChanged("StartDate");
            }
        }


You could copy and paste this for the DueDate and CompletedDate.

You could test these rules now. Notice that this alone is still not sufficient to prevent the user from moving away from an invalid ToDo item.

IDataErrorInfo

We will implement this interface in our model ToDo.cs. This will allow us to validate across multiple fields (complex business rules). Essentially, we set a string-value if some field/column isn't valid. When testing one field we can examine the values that are held in other fields. The XAML will look for these string-values, recognising that there is invalid data, provided we set ValidatesOnDataErrors=True.

The validation, and display of error messages, will occur instantly. This is suitable for our application (as we don't have a Save button). The alternative is to use Explicit Validation by setting UpdateSourceTrigger=Explicit. This is discussed in the second link (at the top of this tutorial).




I don't use or discuss Explicit Data Validation any further in this part. It uses code-behind to obtain the BindingExpression behind a Control, and then uses the method UpdateSource() on this expression.

An alternative approach using a BindingGroup is discussed in the first link above. Essentially, this requires a set of controls to share a common ancestor element.

Many don't consider data-validation fully-implemented in WPF: perhaps it will be in future versions. I noticed also that some developers ignore, or side-step the validation features, building their own validation process. Even if you should decide to do this you still need a good understanding of the built-in validation features.




Here is the full code for ToDo.xaml which adds ValidatesOnDataErrors="True" and Validation.ErrorTemplate to each of the bound-elements.

Spoiler

The IDataErrorInfo interface requires one property and one indexer:
public interface IDataErrorInfo {
  string Error { get; }
  string this[string propertyName] { get; }
}


The indexer returns a string for each column; if the string is null then there is no error for the column.

The Error property is intended to represent an error on the object as a whole (row-level), rather than on individual properties (cell-level). It is intended for use with, for example, a DataGrid or BindingGroup. Nevertheless, I do make use of Error based on the code in the StackOverflow link at the top. This will allow us to prevent navigating away from an invalid ToDo item, or saving the ToDoList while there is an invalid item.

Add the interface to ToDo.cs:
    public class ToDo : INotifyPropertyChanged, IDataErrorInfo {


Then the two required members:
        public string Error {
            get { return this[null]; }
        }

        public string this[string columnName] {
            get { return IsValid(columnName); }
        }


The Error property calls the indexer with null in place of a column-name.
The indexer calls a separate helper-method (IsValid) passing the columnName, or null if called by Error.

Here is the essential structure of the IsValid method:
    public string IsValid(string columnName) {
        StringBuilder result = new StringBuilder();
        if (string.IsNullOrEmpty(columnName) || columnName == "Title") {
            if (string.IsNullOrWhiteSpace(Title))
                result.Append("Title cannot be empty.\n");
        }
        if (string.IsNullOrEmpty(columnName) || columnName == "StartDate") {
            if (StartDate != null) {
                if (DueDate != null && DueDate < StartDate)
                    result.Append("StartDate must be on or before DueDate.\n");
                if (CompletedDate != null && CompletedDate < StartDate)
                    result.Append("StartDate must on or before CompletedDate.\n");
            }
        }

        return (result.Length == 0) ? null : result.Remove(result.Length - 1, 1).ToString();


This uses a StringBuilder but if a columnName (other than null) is supplied then this will return either null or (typically) a single error-message for the particular column.

If Error has called this method with null then all of the validation tests will occur (for all of the columns) and the returned StringBuilder will either be null, if there are no errors in any column, or it will consist of one or more error messages, separated by newlines.

If the StartDate column is being explored, and there is a StartDate, it is compared to the DueDate and CompletedDate (if there are either).

Validation of the other columns follows a similar pattern and I will provide the full code shortly. First, here is a new method:
    public bool IsItemValid {
        get { return string.IsNullOrEmpty(this.Error); }
    }


We will use this method later to prevent moving away from an invalid ToDo item, and to prevent saving with an invalid item.

If you were to add the validation for the other columns you would find that there are inconsistencies. For example, if an error message occurs on keying a DueDate, because its StartDate occurs after this date, if you correct the StartDate it won't automatically remove the error message on the DueDate. To correct this behaviour we use code like this:
        public DateTime? StartDate {
            get { return _startDate; }
            set {
                if (_startDate == value) return;
                if (value < new DateTime(2000, 1, 1))
                    throw new IndexOutOfRangeException("Must be 1st Jan 2000 or later.");
                _startDate = value;
                RaisePropertyChanged("StartDate");
                // check properties in parallel
                RaisePropertyChanged("DueDate");
                RaisePropertyChanged("CompletedDate");
            }
        }


When the StartDate is changed we signal that both the DueDate and CompletedDate have also changed, triggering their validation.

We need to be careful with this process, as it could be possible to create a non-ending change-sequence.

Full code for ToDo.cs:

Spoiler

You could run and test the application now.

Prevent Navigation

Add the following to ToDoList.cs:
using cview = System.Windows.Data.CollectionViewSource;


Now modify to the following code, which uses IsItemValid to prevent (Cancel) navigating away from an invalid ToDo item:
            CommandExit = new MajorCommand(this, Major.Exit);
            cview.GetDefaultView(this).CurrentChanging += ToDoList_CurrentChanging;
        }

        void ToDoList_CurrentChanging(object sender, System.ComponentModel.CurrentChangingEventArgs e) {
            try {
                if (!((ToDo)cview.GetDefaultView(this).CurrentItem).IsItemValid) {
                    if (e.IsCancelable)
                        e.Cancel = true;
                }
            } catch (NullReferenceException ex) {
                // no current item, ignore
            }
        }


We do not need to modify our NavigationCommand(s). A NavigationCommand could attempt to move away from the currrent ToDo item, which triggers CurrentChanging, which in-turn will be cancelled (e.Cancel = true;) if the current item is invalid.

It is also sensible that this occurs at the Model (or ViewModel) level because any attempt to move away from an invalid item will be prevented.

For reference, here is the full code of ToDoList.cs:

Spoiler

Prevent Saving

The remaining situation in which we need to check for an invalid ToDo item is when the user attempts to save the current list. Items other than the current one will already be valid (based on the previous code) so we only need to check the current item. If it is invalid we won't allow the user to save the list.

Add this to MajorCommand.cs:
using cview = System.Windows.Data.CollectionViewSource;


Now modify the Save case:
        case Major.Save:
            try {
                if (!((ToDo)cview.GetDefaultView(_list).CurrentItem).IsItemValid) {
                    MessageBox.Show("Current item has errors.", "ToDo Application");
                    MessageBox.Show(((ToDo)cview.GetDefaultView(_list).CurrentItem).Error,
                        "ToDo Application");
                    return;
                }
            } catch (NullReferenceException ex) {
                // no current item, ignore
            }
            SaveFileDialog dsave = new SaveFileDialog();


We don't really need two MessageBoxes here, but the second one demonstrates that we have access to the full list of errors for the item (from the StringBuilder).

Concluding

I don't claim to have covered data-validation in full, but hopefully have provided a good overview of this (sometimes challenging) subject.

Here is a zipped version of the complete project to-date:

Attached File  ToDoApplication.zip (19K)
Number of downloads: 41

Please note though, that if you have problems with installing the zip then I probably won't do much about it ;) .. sorry, as my intention is that you follow and enter the code provided in the tutorial.

This post has been edited by andrewsw: 22 June 2014 - 10:54 AM


Is This A Good Question/Topic? 0
  • +

Page 1 of 1