MDI Case Study Purchasing - Part IX - Undo / Redo
Introduction
In Part IX we will complete our Undo and Redo functionality. To achieve this, we will create 2 stack collections, each containing PurchaseOrder
objects, one for Undo states, and one for Redo states. Each time a change is made to the document, a copy of the object BEFORE the change will be added to the top of the Undo stack. When a change is "Undone" the current PurchaseOrder
will be placed on the Redo stack, and then the topmost Undo object will become the live object. The reason we use stacks is because they offer the LIFO functionality we want, (L)ast (I)n (F)irst (O)out. Check out this graphic
Now is when it will become more clear why we created both a Changing
and Changed
event. In the Changing
event, we will place a clone of our current PurchaseOrder
into the Undo stack BEFORE allowing the change to be made. It's important to remember, we are using an Object Oriented framework, thus our Data objects are reference objects. If we were to simply store our object in the stacks, we would of course be storing the references to them, and thus any changes made to the live object would also be made to the objects in the stacks, since they shared the same reference. That's where our Clone()
method comes into play. Whenever we store a state into either of the stacks, or when we pull a state off of a stack and make it live, we want to clone the state so that a new reference instance is created. So let's create a Clone()
method for our PurchaseOrder
class. In this case we also want to be sure and create a "deep clone", so that any reference properties within PurchaseOrder
are also cloned. Here is what the PurchaseOrder.Clone()
method should look like
public PurchaseOrder Clone()
{
PurchaseOrder clone = new PurchaseOrder();
clone.PurchaseOrderNumber = this.PurchaseOrderNumber;
clone.Vendor = this.Vendor;
clone.VendorAddress = this.VendorAddress;
clone.BillingAddress = new BillingAddress();
clone.BillingAddress.Attention = this.BillingAddress.Attention;
clone.BillingAddress.CompanyName = this.BillingAddress.CompanyName;
clone.BillingAddress.AddressLine1 = this.BillingAddress.AddressLine1;
clone.BillingAddress.AddressLine2 = this.BillingAddress.AddressLine2;
clone.BillingAddress.City = this.BillingAddress.City;
clone.BillingAddress.State = this.BillingAddress.State;
clone.BillingAddress.ZipCode = this.BillingAddress.ZipCode;
clone.BillingAddress.PhoneNumber = this.BillingAddress.PhoneNumber;
clone.BillingAddress.FaxNumber = this.BillingAddress.FaxNumber;
clone.ShippingAddress = new ShippingAddress();
clone.ShippingAddress.Attention = this.ShippingAddress.Attention;
clone.ShippingAddress.CompanyName = this.ShippingAddress.CompanyName;
clone.ShippingAddress.AddressLine1 = this.ShippingAddress.AddressLine1;
clone.ShippingAddress.AddressLine2 = this.ShippingAddress.AddressLine2;
clone.ShippingAddress.City = this.ShippingAddress.City;
clone.ShippingAddress.State = this.ShippingAddress.State;
clone.ShippingAddress.ZipCode = this.ShippingAddress.ZipCode;
clone.ShippingAddress.Reference = this.ShippingAddress.Reference;
clone.Items = new ItemCollection();
foreach (Item i in this.Items)
{
Item cloneItem = new Item();
cloneItem.ProductNumber = i.ProductNumber;
cloneItem.Description = i.Description;
cloneItem.Cost = i.Cost;
cloneItem.PurchaseQuantity = i.PurchaseQuantity;
clone.Items.Add(cloneItem);
}
return clone;
}
Note - In our application, Vendor and Vendor Address needs to stay tied to the reference of the original, so in our Clone() method we don't want to clone those 2 properties, just pass them along. As an example, if we cloned the Vendor, then let's say we make a change to the document, and then the Vendor information changes, and then we undo the document change, the document would not have the old outdated Vendor data in it.
Next, we want to create a new class that will hold the state information for out document for each Undo and Redo action taken, let's call it UndoRedoState
, in this class we would hold any information regarding the current state of the document, for now just the PurchaseOrder
object. Here is what our new UndoRedoState
class should look like
public class UndoRedoState
{
public PurchaseOrder DataObject { get; set; }
public UndoRedoState() { DataObject = new PurchaseOrder(); }
public UndoRedoState(PurchaseOrder dataobject) { DataObject = dataobject; }
}
Now, we need to make a few changes to our PurchaseOrderForm
class. First let's add 2 instances of Stack<UndoRedState>
one for undo states, and one for redo states. Stack<T>
can be found in System.Collections.Generic
, so add a using statement for this library if you don't already have it
private Stack<UndoRedoState> _undoStates;
private Stack<UndoRedoState> _redoStates;
Next, we need a public bool CanUndo
, and CanRedo
, this will aid us in controlling the MDIForm
UI undo and redo buttons when we toggle between documents
public bool CanUndo { get { return _undoStates.Count > 0; } }
public bool CanRedo { get { return _redoStates.Count > 0; } }
Now let's create an Undo()
and Redo()
method in PurchaseOrderForm
, which will actually do the changes for us. Here is what they should look like
public void Undo()
{
if (_undoStates.Count > 0)
{
UndoRedoState undoState = _undoStates.Pop();
UndoRedoState redoState = new UndoRedoState(_purchaseOrder.Clone());
_redoStates.Push(redoState);
_purchaseOrder = undoState.DataObject.Clone();
_purchaseOrder.Changing += purchaseOrder_Changing;
_purchaseOrder.Changed += purchaseOrder_Changed;
purchaseOrderBindingSource.DataSource = _purchaseOrder;
purchaseOrderBindingSource.ResetBindings(false);
}
}
public void Redo()
{
if (_redoStates.Count > 0)
{
UndoRedoState redoState = _redoStates.Pop();
UndoRedoState undoState = new UndoRedoState(_purchaseOrder.Clone());
_undoStates.Push(undoState);
_purchaseOrder = redoState.DataObject.Clone();
_purchaseOrder.Changing += purchaseOrder_Changing;
_purchaseOrder.Changed += purchaseOrder_Changed;
purchaseOrderBindingSource.DataSource = _purchaseOrder;
purchaseOrderBindingSource.ResetBindings(false);
}
}
Lastly, we need to make sure each time a change is occuring, we get a pre-change copy of the data object into our Undo stack, and also we want to clear out the redo stack. Before we can edit the Changing
handler, however, we need to make a small change to our PurchaseOrderChangingEventArgs
class. We want it to contain an instance of PurchaseOrder
, which will be the current state of the data object before the impending change is made, so open up EventArgs.cs and change PurchaseOrderChaningEventArgs
to
public class PurchaseOrderChangingEventArgs : EventArgs
{
public PurchaseOrder CurrentState { get; set; }
public PurchaseOrderChangingEventArgs(PurchaseOrder currentState) { CurrentState = currentState; }
}
That will give 3 build errors now in PurchaseOrder
because we've changed the contructor to now require an argument, so let's fix them, in PurchaseOrder
, change the PurchaseOrderNumber
get/set method to
public String PurchaseOrderNumber
{
get { return _purchaseOrderNumber; }
set
{
if (Changing != null) OnChanging(new PurchaseOrderChangingEventArgs(this.Clone()));
_purchaseOrderNumber = value;
if (Changed != null) OnChanged(new PurchaseOrderChangedEventArgs());
}
}
Now that we are using Clone, each time we set a property and call OnChanging we would end up with a circular reference, and an infinite loop, so we need to add the if(Changing != null)
check to avoid this.
Now change the DeleteItems
and AddItems
methods to the following
public void AddItems(ItemCollection addItems)
{
OnChanging(new PurchaseOrderChangingEventArgs(this.Clone()));
foreach (Item i in addItems) _items.Add(i);
OnChanged(new PurchaseOrderChangedEventArgs());
}
public void DeleteItems(ItemCollection deleteItems)
{
OnChanging(new PurchaseOrderChangingEventArgs(this.Clone()));
foreach (Item i in deleteItems) _items.Remove(i);
OnChanged(new PurchaseOrderChangedEventArgs());
}
Now we need to add out current state into the Undo stack when PurchaseOrder
changes, and for this, let's modify the purchaseOrder_Changing
handler inside PurchaseOrderForm
. We want to add an Undo state, as well as flush all current Redo states
private void purchaseOrder_Changing(object sender, PurchaseOrderChangingEventArgs e)
{
UndoRedoState undoState = new UndoRedoState(e.CurrentState);
_undoStates.Push(undoState);
_redoStates.Clear();
OnChanging(e);
}
Now we have the guts of Undo/Redo done, all we need to do is handle the UI buttons. Lets move on to MDIForm
. In Design View, set the Undo and Redo menu items Enabled
property to False, then double click each of them to create their handlers, and all we need to do is get the ActiveMdiChild
, and call it's Undo or Redo method, then reset the buttons' Enabled
property accordingly
private void undoToolStripMenuItem_Click(object sender, EventArgs e)
{
PurchaseOrderForm activeChildForm = this.ActiveMdiChild as PurchaseOrderForm;
activeChildForm.Undo();
undoToolStripMenuItem.Enabled = activeChildForm.CanUndo;
redoToolStripMenuItem.Enabled = activeChildForm.CanRedo;
}
private void redoToolStripMenuItem_Click(object sender, EventArgs e)
{
PurchaseOrderForm activeChildForm = this.ActiveMdiChild as PurchaseOrderForm;
activeChildForm.Redo();
undoToolStripMenuItem.Enabled = activeChildForm.CanUndo;
redoToolStripMenuItem.Enabled = activeChildForm.CanRedo;
}
Next, let's go to OnMdiChildActivate
and set our Undo and Redo buttons to enabled when appropriate. First at the top, set both items Enabled
to False.
protected void OnMdiChildActivate(object sender, EventArgs e)
{
undoToolStripMenuItem.Enabled = redoToolStripMenuItem.Enabled = false;
.....
And in the if(this.ActiveMdiChild != null)
block
if (this.ActiveMdiChild != null)
{
.....
undoToolStripMenuItem.Enabled = activeChildForm.CanUndo;
redoToolStripMenuItem.Enabled = activeChildForm.CanRedo;
}
And finally, in the purchaseOrderForm_Changed
handler, let's add a check for enabling or disabling the Undo and Redo button
private void purchaseOrderForm_Changed(object sender, PurchaseOrderChangedEventArgs e)
{
PurchaseOrderForm activeChildForm = (PurchaseOrderForm)this.ActiveMdiChild;
PurchaseOrderForm sendingForm = sender as PurchaseOrderForm;
if (activeChildForm == sendingForm)
{
saveToolStripButton.Enabled = saveToolStripMenuItem.Enabled = true;
selectAllToolStripMenuItem.Enabled = activeChildForm.PurchaseOrder.Items.Count > 0;
undoToolStripMenuItem.Enabled = activeChildForm.CanUndo;
redoToolStripMenuItem.Enabled = activeChildForm.CanRedo;
}
}
That completes Part IX, Undo and Redo funictionality is now complete. This is the basic framework for the functionality. As you expand your data object you will need to be sure to account for each change, and expand the UndoRedoState class to hold all of the state information. In Part X we will handle premature closure of our application, so that unsaved changes can be caught and the user can be alerted to save before closure.
Points of Interest
- Undo / Redo
- Stack<T>
History
Keep a running update of any changes or improvements you've made here.