Dirty state checker for Flex forms

One of the projects I’m working on required checking for changes made in forms while they were in editable state and giving feedback to the user when he was leaving that form without save. I can imagine this is pretty common task so decided to build generic class dealing with this task. Here it is.

Problem

User enters some form and modifies some data. He is about to leave the form without saving his changes. He should be asked if he wants save it or drop it. If he changes data back to the original state he shouldn’t be bugged by any messages.

Solution

DirtyChecker is the class which deals with problem described. It takes one container with its constructor and walks through it to get references for all data input controls. On each of these controls it registers event listener for data change. When any change i made, event is dispatched, DirtyChecker compares original data with current state.

Let’s declare class first:

package uk.co.riait.util
{
 import flash.events.Event;
 import flash.events.EventDispatcher;

 import mx.controls.CheckBox;
 import mx.controls.ColorPicker;
 import mx.controls.ComboBox;
 import mx.controls.DataGrid;
 import mx.controls.DateChooser;
 import mx.controls.DateField;
 import mx.controls.HSlider;
 import mx.controls.List;
 import mx.controls.NumericStepper;
 import mx.controls.RadioButton;
 import mx.controls.RichTextEditor;
 import mx.controls.TextArea;
 import mx.controls.TextInput;
 import mx.controls.VSlider;
 import mx.core.Container;
 import mx.core.UIComponent;
 import mx.events.FlexEvent;
 import mx.utils.ObjectUtil;

 import uk.co.riait.events.DirtyCheckerEvent;

 /**
  * Listens for any change event occured in Flex controls
  * which are child controls of given form and tracks dirty state.
  **/
 public class DirtyChecker extends EventDispatcher
 {
  ...
 }
}

and some private fields:

  /**
   * Array of tracked items.
   */
  private var _listensTo:Array = [];
  /**
   * Original data loaded when instance is created.
   */
  private var _originalData:Object = {};
  /**
   * Dirty state flag.
   */
  private var _isDirty:Boolean = false;

Next step is adding a constructor. It takes one paramter which is the form for which dirty state should be tracked.

  /**
   * Constructor
   * @param element Container which children will be monitored for changes.
   */
  public function DirtyChecker( element:Container )
  {
   this.enumareControls( element );
  }

It is time to build enumerateControls method. This one is quite long because it has to check type of data input control. As you could see in imports it uses few of them…

  /**
   * @private
   * When instance is created walks over child Containers, finds all
   * instances of data input controls and registers them for listening.
   *
   * @param element Current work container.
   */
  private function enumareControls( element:Container ):void
  {
   for ( var i:int=0; i<element.numChildren; i++ )
   {
    if ( child is Container && (child as Container).numChildren > 0 )
     this.enumareControls( child as Container );

    if ( child is UIComponent )
    {
     var child:UIComponent = child as UIComponent;
     if ( child is CheckBox )
     {
      this._originalData[ child.uid ] = ( child as CheckBox ).selected;
      this.registerForListening( child );
     }
     if ( child is ColorPicker )
     {
      this._originalData[ child.uid ] = ( child as ColorPicker ).selectedColor;
      this.registerForListening( child );
     }
     if ( child is ComboBox )
     {
      this._originalData[ child.uid ] = ( child as ComboBox ).selectedIndex;
      this.registerForListening( child );
     }
     if ( child is DataGrid )
     {
      this._originalData[ child.uid ] = ( child as DataGrid ).selectedIndices;
      this.registerForListening( child );
     }
     if ( child is DateChooser )
     {
      this._originalData[ child.uid ] = ( child as DateChooser ).selectedDate;
      this.registerForListening( child );
     }
     if ( child is DateField )
     {
      this._originalData[ child.uid ] = ( child as DateField ).selectedDate;
      this.registerForListening( child );
     }
     if ( child is HSlider )
     {
      this._originalData[ child.uid ] = ( child as HSlider ).values;
      this.registerForListening( child );
     }
     if ( child is List )
     {
      this._originalData[ child.uid ] = ( child as List ).selectedIndices;
      this.registerForListening( child );
     }
     if ( child is NumericStepper )
     {
      this._originalData[ child.uid ] = ( child as NumericStepper ).value;
      this.registerForListening( child );
     }
     if ( child is RadioButton )
     {
      this._originalData[ child.uid ] = ( child as RadioButton ).selected;
      this.registerForListening( child );
     }
     if ( child is RichTextEditor )
     {
      this._originalData[ child.uid+"_text" ] = ( child as RichTextEditor ).text;
      this._originalData[ child.uid+"_htmlText" ] = ( child as RichTextEditor ).htmlText;
      this.registerForListening( child );
     }
     if ( child is TextArea )
     {
      this._originalData[ child.uid+"_text" ] = ( child as TextArea ).text;
      this._originalData[ child.uid+"_htmlText" ] = ( child as TextArea ).htmlText;
      this.registerForListening( child );
     }
     if ( child is TextInput )
     {
      this._originalData[ child.uid+"_text" ] = ( child as TextInput ).text;
      this._originalData[ child.uid+"_htmlText" ] = ( child as TextInput ).htmlText;
      this.registerForListening( child );
     }
     if ( child is VSlider )
     {
      this._originalData[ child.uid+"_text" ] = ( child as VSlider ).values;
      this.registerForListening( child );
     }
    }
   }
  }

Few words on that one. It iterates over child items for given form. When it locates Container instance with any child items it executes itself with child container to look for nested data input controls. When any data input control is found it checks for controls’ type. If control is one of tracked types it saves the value in this._originalData object.

Control is registered for tracking by the method presented below:

  /**
   * @private
   * Registers control for listening.
   *
   * @param elem Element to register.
   */
  private function registerForListening( elem:UIComponent ):void
  {
   this._listensTo.push( elem );
   elem.addEventListener(FlexEvent.VALUE_COMMIT, this.anyControlChanged);
  }

To handle events registered by registerForListening class uses following method:

  /**
   * @private
   * Event handler for any change in observed controls.
   */
  private function anyControlChanged( event:Event ):void
  {
   // get current data set:
   var o:Object = this.getState();
   // bypass old ditry state:
   var _oldDirty:Boolean = this._isDirty;
   // compare:
   this._isDirty = (ObjectUtil.compare(this._originalData, o) != 0);
   // if new state and old state are different notify an event:
   if ( _oldDirty != this._isDirty )
    this.dispatchEvent( new DirtyCheckerEvent(DirtyCheckerEvent.DIRTY_STATE_CHANGED, this._isDirty ) );
  }

Let’s see what is happening here. First - current form state is loaded. Then it is compared with original state. When dirty state from before comparison is different than new state, event is dispatched. Let’s take a look at getState() methid then.

  /**
   * @private
   * Gets data from observed controls for further comparison.
   *
   * @return Data set to compare.
   */
  private function getState():Object
  {
   var out:Object = {};

   for ( var i:int=0; i<this._listensTo.length; i++ )
   {
    var child:UIComponent = this._listensTo[i] as UIComponent;
    if ( child is CheckBox )
     out[ child.uid ] = ( child as CheckBox ).selected;

    if ( child is ColorPicker )
     out[ child.uid ] = ( child as ColorPicker ).selectedColor;

    if ( child is ComboBox )
     out[ child.uid ] = ( child as ComboBox ).selectedIndex;

    if ( child is DataGrid )
     out[ child.uid ] = ( child as DataGrid ).selectedIndices;

    if ( child is DateChooser )
     out[ child.uid ] = ( child as DateChooser ).selectedDate;

    if ( child is DateField )
     out[ child.uid ] = ( child as DateField ).selectedDate;

    if ( child is HSlider )
     out[ child.uid ] = ( child as HSlider ).values;

    if ( child is List )
     out[ child.uid ] = ( child as List ).selectedIndices;

    if ( child is NumericStepper )
     out[ child.uid ] = ( child as NumericStepper ).value;

    if ( child is RadioButton )
     out[ child.uid ] = ( child as RadioButton ).selected;

    if ( child is RichTextEditor )
    {
     out[ child.uid+"_text" ] = ( child as RichTextEditor ).text;
     out[ child.uid+"_htmlText" ] = ( child as RichTextEditor ).htmlText;
    }
    if ( child is TextArea )
    {
     out[ child.uid+"_text" ] = ( child as TextArea ).text;
     out[ child.uid+"_htmlText" ] = ( child as TextArea ).htmlText;
    }
    if ( child is TextInput )
    {
     out[ child.uid+"_text" ] = ( child as TextInput ).text;
     out[ child.uid+"_htmlText" ] = ( child as TextInput ).htmlText;
    }
    if ( child is VSlider )
     out[ child.uid+"_text" ] = ( this._listensTo[i] as VSlider ).values;

   }
   return out;
  }

This one is shorter than enumerateControls() but looks similar. The difference is it uses this._listensTo array which already contains references to all known form items. This method simply gets data from each field and returns object similar to this._originalData.

Time to add some utility methods. First one is called dispose() and unregisters event listeners from each of tracked controls. Second one is called setCurrentStateAsClear() and it may be used to alter this._originalData. You might want use it when your form is for example populated with some server side data.

  /**
   * Unregisters all event listeners from observed controls.
   */
  public function dispose():void
  {
   for ( var i:int=0; i<this._listensTo.length; i++ )
    ( this._listensTo[ i ] as UIComponent ).removeEventListener(FlexEvent.VALUE_COMMIT, this.anyControlChanged);
  }

  /**
   * Tells dirty state checker to save current state as clear state.
   */
  public function setCurrentStateAsClear():void
  {
   this._originalData = this.getState();
   this._isDirty = false;
  }

One more thing in this class - public field to check current form state:

  /**
   * Dirty state.
   */
  public function get isDirty():Boolean
  {
   return this._isDirty;
  }

Last piece we’re missing here is event class (this is separate file). Here it is:

package uk.co.riait.events
{
 import flash.events.Event;
 public class DirtyCheckerEvent extends Event
 {
  public static const DIRTY_STATE_CHANGED:String = "dirtyCheckerChange";

  private var _state:Boolean;

  public function DirtyCheckerEvent( type:String, isDirty, bubbles:Boolean=false, cancelable:Boolean=false )
  {
   super( type, bubbles, cancelable );
   this._state = isDirty;
  }
  public function get isDirty():Boolean
  {
   return this._state;
  }
  override public function clone():Event
  {
   return new DirtyCheckerEvent( type, isDirty, bubbles, cancelable );
  }
 }
}

Source code

You can download source code from here: dirty-state-checker.zip.

Enjoy!

License

As always - this code is available under MIT license.


About this entry