Monday, November 15, 2010

Jounce Part 7: Validation and Save/Confirm/Cancel Operations

Line of business applications are full of what we call "CRUD" screens for Create, Read, Update, and Delete. Jounce addresses common concerns that are found in these types of transactions to help with your Silverlight 4 DataForms:

Is Dirty and Commit

It is common to check the status of a record and only apply changes when changes actually exist. Once a user edits a field, it is marked as "dirty" and this is the trigger to begin considering updates. Jounce uses the inverse of the "dirty" flag: the "committed" flag. When a view model derives from the BaseEntityViewModel class, it inherits several properties including the committed functionality. This includes:

  • Committed — a boolean that is set to false when pending changes that have not been committed exist
  • CommitCommand — a command that you can bind to your save buttons that will automatically remain disabled unless the record is both dirty and passes all validations
  • _OnCommitted — a method that is called when the commit command is executed, allowing you to apply changes

Validation

Validation needs to be performed on fields. Jounce provides access to field-level validation via the ClearErrors and SetError methods. You can specify an error for a property simply by passing the property name and the error text. In addition, Jounce provides an override named _ValidateAll that is called before any commit command is processed. This allows you to perform any entity-level validations (or re-apply existing validations) before the commit cycle is triggered. The HasErrors boolean is set to true if any validation errors exist.

Let's take a look at the sample EntityViewModel application that is shipped with the Jounce quick starts (you can download the Jounce source by clicking here and choosing "Download" under the "Latest Version" box in the upper right).

Set up the View Model

We'll start with the view model for a very simple contact record. It will have a first name, a last name, a phone number, and an email. Because this is an entity with validation, we'll derive the view model from the BaseEntityViewModel:

[ExportAsViewModel("MainViewModel")]
public class MainViewModel : BaseEntityViewModel
{
    public MainViewModel()
    {
    }
}

Let's go ahead and create the properties we'll be validating. For each property, we'll call a method for validation of that property. The validation method will first clear any outstanding errors that exist for the given property, then validate. If validation fails, it will call SetError to add the error to the error collection for that property.

private string _firstName;

public string FirstName
{
    get { return _firstName; }
    set
    {
        _firstName = value;
        RaisePropertyChanged(() => FirstName);
        _ValidateName(ExtractPropertyName(() => FirstName), value);
    }
}

private string _lastName;

public string LastName
{
    get { return _lastName; }
    set
    {
        _lastName = value;
        RaisePropertyChanged(() => LastName);
        _ValidateName(ExtractPropertyName(() => LastName), value);
    }
}

private void _ValidateName(string prop, string value)
{
    ClearErrors(prop);

    if (string.IsNullOrEmpty(value))
    {
        SetError(prop, "The field is required.");
    }
}

private string _phoneNumber;

public string PhoneNumber
{
    get { return _phoneNumber; }
    set
    {
        _phoneNumber = value;
        RaisePropertyChanged(() => PhoneNumber);
        _ValidatePhoneNumber();
    }
}

private void _ValidatePhoneNumber()
{
    var prop = ExtractPropertyName(() => PhoneNumber);
    ClearErrors(prop);
    if (string.IsNullOrEmpty(_phoneNumber) || !Regex.IsMatch(_phoneNumber,
                                                                @"^((\+\d{1,3}(-| )?\(?\d\)?(-| )?\d{1,5})|(\(?\d{2,6}\)?))(-| )?(\d{3,4})(-| )?(\d{4})(( x| ext)\d{1,5}){0,1}$"))
    {
        SetError(prop, "Field should be a valid international phone number such as +1 404-555-1212");
    }
}

private string _email;

public string Email
{
    get { return _email; }
    set
    {
        _email = value;
        RaisePropertyChanged(() => Email);
        _ValidateEmail();
    }
}

private void _ValidateEmail()
{
    var prop = ExtractPropertyName(() => Email);
    ClearErrors(prop);
    if (string.IsNullOrEmpty(_email) ||
        !Regex.IsMatch(_email, @"^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$", RegexOptions.IgnoreCase))
    {
        SetError(prop, "Field should be a valid email address.");
    }
}

Notice that our validations are straightforward: for the required fields, we're just checking for the string to be filled with something. For phone number and email, we're using regular expressions and checking for a match to ensure the validation passes.

Global Validation

Before we commit the record, we want to make sure all of our validations pass. We might have record-level validations, service-based validations (i.e. call a service to make sure a name is not duplicate), or validations that haven't fired yet due to the nature of data-binding. With Jounce, it is easy to call these validations because we can simply override the validation method that is called before committing the transaction. If any validations fails, it will not commit.

protected override void _ValidateAll()
{
    _ValidateName(ExtractPropertyName(() => FirstName), _firstName);
    _ValidateName(ExtractPropertyName(() => LastName), _lastName);
    _ValidatePhoneNumber();
    _ValidateEmail();
}

When we commit the record or cancel from editing it, we'll need to clear the fields and reset the commit state (remember, committed goes to false if pending changes exist, but this is not the case when we first enter the transaction). We'll create a short routine that clears the fields and then resets the committed flag:

private void _Reset()
{
    PhoneNumber = string.Empty;
    Email = string.Empty;
    FirstName = string.Empty;
    LastName = string.Empty;
    Committed = true;
}

Handling Commits

Because we're "just pretending" with this example, we'll just clear the transaction when the user saves. How do we handle a save? We simply override the committed method (Jounce guarantees your validations have passed before it will call this method):

protected override void _OnCommitted()
{
    MessageBox.Show("Record was saved.");
    _Reset();
}

Jounce provides the command to commit, but does not make any assumptions about how we want to handle cancellation. We'll wire our own cancel command. It should check to make sure there is something to cancel from (i.e. if the record hasn't been changed, there is no point in performing a cancel). We'll also need to make sure we update the status of the command whenever the commit status changes. Finally, we'll prompt the user and if they confirm, we'll just call our reset routine to clear the record.

Cancel That

Here's the definition of the command:

public IActionCommand CancelCommand { get; private set; }

In the constructor, we'll wire it up. While we're there, let's go ahead and add some design-time data as well.

public MainViewModel()
{
    CancelCommand = new ActionCommand<object>(obj => _Confirm(), obj => !Committed);
    var committedProp = ExtractPropertyName(() => Committed);

    if (InDesigner)
    {
        FirstName = "Jeremy";
        LastName = "Likness";
        PhoneNumber = "+1 404-555-1212";
        Email = "jeremy@jeremylikness.com";
    }
    else
    {
        // anytime the committed status changes, we should re-evaluate the cancel button
        PropertyChanged += (o, e) =>
                                {
                                    if (e.PropertyName.Equals(committedProp))
                                    {
                                        CancelCommand.RaiseCanExecuteChanged();
                                    }
                                };
    }
}

private void _Confirm()
{
    var result = MessageBox.Show("Are you sure?", "Confirm Cancel", MessageBoxButton.OKCancel);
    if (result == MessageBoxResult.OK)
    {
        _Reset();
    }
}

Notice we don't allow cancel unless the record is not committed (meaning there is something pending). We also hook into the property change events for the commit flag so we can update the command status automatically.

The View

That's it - we're done with our view model! Let's wire up the view. One behavior developers find out quickly with Silverlight is the binding behavior of text boxes. The text box binding is typically not updated until the user tabs out of the text box (the text box loses focus) and then only if there was activity inside the text box. This isn't always the ideal behavior - sometimes you want immediate feedback. So, I created a simple behavior that ensures the text box updates the binding immediately:

namespace EntityViewModel
{
    public class TextBoxChangedBehavior : Behavior<TextBox>
    {
        protected override void OnAttached()
        {
            AssociatedObject.TextChanged += AssociatedObject_TextChanged;
        }

        protected override void OnDetaching()
        {
            AssociatedObject.TextChanged -= AssociatedObject_TextChanged;
        }

        static void AssociatedObject_TextChanged(object sender, TextChangedEventArgs e)
        {
            var tb = sender as TextBox;
            if (tb == null) return;
            var binding = tb.GetBindingExpression(TextBox.TextProperty);
            if (binding != null)
            {
                binding.UpdateSource();
            }
        }
    }
}

I'll connect this to the first text box to demonstrate how it works.

First, let's make our view design-time friendly by binding to the view model (remember, we added some sample data in the constructor).

<Grid x:Name="LayoutRoot" 
        HorizontalAlignment="Center" MinWidth="400"
        Background="White" d:DataContext="{d:DesignInstance local:MainViewModel,IsDesignTimeCreatable=True}">

When we're done, we'll end up with nice design-time data like this:

Jounce design-time friendly data

Let's take a look at our text box for first name:

<TextBox HorizontalAlignment="Stretch" x:Name="FirstName"
            Grid.Column="1"
            Text="{Binding FirstName, Mode=TwoWay, NotifyOnValidationError=True, ValidatesOnDataErrors=True}">
    <Interactivity:Interaction.Behaviors>
        <local:TextBoxChangedBehavior/>
    </Interactivity:Interaction.Behaviors>
</TextBox>

The important things to note are the ValidatesOnDataErrors and the NotifyOnValidationError. These directives instruct Silverlight to inspect the error properties on our view model and to show error messages accordingly. These are handled via the SetError methods that we used. Also note the interaction block that I use to attach the changed behavior so this text box will update its bindings immediately.

Our buttons are bound to the commands for commit (supplied by Jounce) and cancel (the one we made):

<Button Content="Save" Command="{Binding CommitCommand}" Grid.Row="4" Grid.Column="0"/>
<Button Content="Cancel" Command="{Binding CancelCommand}" Grid.Row="4" Grid.Column="1"/>

Finally, we'll add a validation summary control. This comes with the toolkit and automatically displays a summary of all validation errors:

<Controls:ValidationSummary Grid.Row="5" Margin="5" Grid.ColumnSpan="2"/> 

That's really it! We export the view and view model along with a binding, and we're good to go. What you should notice is that the save and cancel buttons are automatically disabled when you enter the application. Once you type a character in the first name, both are enabled. However, if you click save without entering the other fields, the validations will fire and prevent the save from happening. The save button will remain disabled until you clear all errors. Once you save or cancel, the record will reset. Here's an example of the validation that is generated:

Jounce validations

Remember, the source for this is included with the quickstarts on the Jounce CodePlex page, and if you want to see the end result, look no further:

Jeremy Likness

1 comment:

  1. nice post Jeremy - I particularly like the attached behaviour on the textbox. Have been thinking about a nice way to rebind in this way. Ideal.

    ReplyDelete