Thursday, July 30, 2020

Undo and Redo with Postsharp

This is a bit scary. If the users knew we had access to this functionality they would camp out on our lawns until we promised to implement it. I'm talking about undo and redo on screens. With PostSharp, this is a breeze. I'm going to walk you through it.

We are going to create a simple screen that displays a vendor and a list of vendor addresses. We will include three bits of PostSharp functionality, two of which I have discussed in earlier blog entries.
The PostSharp documentation for Undo/Redo is here.

Start a new Visual Studio 2019 WPF .Net Core project using C# and call it UndoRedo. Use NuGet to add PostSharp.Patterns.Model and PostSharp.Patterns.Xaml. Add your license to the project.

We can start with the code-behind. We define a Vendor and a VendorAddress class like this. The only attribute we haven't covered in earlier posts is [Recordable]. This indicates that changes to the class properties should be recorded and undoable. We can use the [NotRecorded] attribute on an individual property to indicate that changes should not be included in the change log.

    [NotifyPropertyChanged]
    [Recordable]
    public class Vendor
    {
        public string VendorCode { get; set; }
        public string VendorName { get; set; }

        [Child]
        public IList<VendorAddress> VendorAddresses { get; private set; }

        public Vendor()
        {
            this.VendorAddresses = new AdvisableCollection<VendorAddress>();
        }
    }

    [NotifyPropertyChanged]
    [Recordable]
    public class VendorAddress
    {
        // There is a bug in PostSharp when undoing the delete of a member of an AdvisableCollection when that member has a [Parent] property
        // We can fix it by separating the [Parent] from the property. They are working on it. Bug #27256
        [Parent]
        private Vendor _vendor;

        public Vendor vendor
        {
            get { return _vendor; }
            private set { _vendor = value; }
        }

        public string VendorStreetAddress { get; set; }
        public string VendorCity { get; set; }
        public string VendorState { get; set; }
        public string VendorZipCode { get; set; }
    }

We need to declare a Vendor property in MainWindow and initialize it in the constructor. We probably need to start with an empty change history whenever we load or save a vendor which we do by clearing the recorder. In this example we do it in the MainWindow constructor, but we would also want to do it after any load or save.

    public partial class MainWindow : Window
    {
        public Vendor vendor { get; set; }
           
        public MainWindow()
        {
            RecordingServices.DefaultRecorder.Clear();
            vendor = new Vendor();
            InitializeComponent();
        }
    }

As you can see the undo/redo logic is encapsulated in a using, two decorations, and one line of code. Let's take a look at the XAML. It's pretty much what you expect. The two custom buttons provide the user interface for undo/redo. I made them bold below.

<Window x:Class="UndoRedo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:model="clr-namespace:PostSharp.Patterns.Model.Controls;assembly=PostSharp.Patterns.Xaml"
        xmlns:local="clr-namespace:UndoRedo"
        mc:Ignorable="d"
        Title="Vendor Maintenance" Height="450" Width="800"
        DataContext="{Binding RelativeSource={RelativeSource Self}}">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <Grid Grid.Row="0">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
            </Grid.ColumnDefinitions>
            <TextBlock Grid.Column="0" Text="Vendor Code:" Margin="10,0,0,0"/>
            <TextBox Grid.Column="1" Text="{Binding vendor.VendorCode}" Width="100" MaxLength="6" Margin="10,0,0,0"/>
            <TextBlock Grid.Column="2" Text="Vendor Name:" Margin="20,0,0,0"/>
            <TextBox Grid.Column="3" Text="{Binding vendor.VendorName}" Width="400" MaxHeight="100" Margin="10,0,0,0"/>
            <model:UndoButton Grid.Column="5"/>
            <model:RedoButton Grid.Column="6"/>
        </Grid>
        <DataGrid Grid.Row="1" ItemsSource="{Binding vendor.VendorAddresses}" CanUserAddRows="True" CanUserDeleteRows="True" AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Street Address" Binding="{Binding VendorStreetAddress}" Width="4*"/>
                <DataGridTextColumn Header="City" Binding="{Binding VendorCity}" Width="*"/>
                <DataGridTextColumn Header="State" Binding="{Binding VendorState}" Width="50"/>
                <DataGridTextColumn Header="Zip Code" Binding="{Binding VendorZipCode}" Width="100"/>
            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>

Let's take it out for a spin. Run the program and enter a vendor number, name, and address something like this.


Click the Undo button a few times (you can see it has become enabled) so the screen looks something like this.


And hit redo until your data is back!


Now select the address line and hit delete. As you expect, it is deleted.


Click the Undo button and it comes back!


If you look in the VendorAddress class you can see I have added a comment that mentions a bug I found.

This is the first bug I have found in PostSharp. I zipped up the solution and submitted a question to them. They got back the next day and confirmed they can reproduce the error, gave me a workaround, and they will fix it. This is pretty impressive.

I was thinking about unit testing the undo/redo functionality and was worried that the undo/redo buttons have no exposure in the code behind. However, you could access the recorder object directly and execute undo/redo methods on it.

No comments:

Post a Comment