Handling load/save layout on Infragistics XamDataGrid when model changes

We have a data intensive WPF application that his heavily reliant on Infragistics’ XamDataGrid control for tabular representation of data.  Just to get a feel of how reliant we are on this grid, usage in the application is as follows:

  • 20 tabular data views, each hosting an instance of XamDataGrid.
  • Configurable number of map views hosting a map-tip displaying its data using XamDataGrid.
  • 8 configuration editors, using a typical master-details layout, with an instance of XamDataGrid comprising the major control in the master section of the view.
  • 3 notification windows that present errors, alerts or alarms, depending on how you call it, also using a master-details layout.

From a performance and stylistic point of view, XamDataGrid has mostly worked well.   From a usability perspective, despite some of its shortcomings, such as an innate inability to resize a column to display its longest content when selected column header is double-clicked, XamDataGrid, does scores some good points, especially since it allows grouping and field-reordering abilities.

Our users are allowed to re-order the grid layout according to their preference. With this flexibility, however, comes the additional requirement of being able to persist current layout for each and every instance of the grid used in the application as part of the user profile.  Some of our customers, with multiple monitor arrangements, would re-arrange the grid layout in all major tabular data views, saving this as a read-only profile, thereby preventing further modifications by users with restricted privileges.   These requirements, therefore, demand an ability to reliably save and restore the grid layout when its parent container is being unloading and reloaded from the visual tree, respectively, during window closure or complete application shutdown.

One of our customers reported that there was one view that was not persisting its grid layout to profile. This is a problem we were not able to duplicate in house, especially given that our deployment environment does not entirely mimic those of our customers.   Luckily, using privileged access to one of our customer environments, we were able to gain additional insights after experience the problem firsthand.

The Problem:

XamDataGrid exposes an API that provides an ability to save and restore the grid layout. The respective methods are DataPresenterBase.SaveCustomizations and DataPresenterBase.LoadCustomizations. In our application we use the overloads that take and return a string:

LoadCustomizations(string);
public string SaveCustomizations()

These methods mostly work well until your underlying model changes. To demonstrate this problem, we will use a concrete example.

Assume in v1 your application that displays a list of all classic cars in your geographic region of interest, you have defined Car model with the following properties:

 public class Car
    {
        public string Make { get; set; }
        public string Model { get; set; }
        public double RetailPrice { get; set; }
        public string Manufacturer { get; set; }
        public string Color { get; set; }
        public DateTime DateOfManufacture { get; set; }
    }

Assume that you ran v1 of your application and saved a grid layout that consisted of all of the properties in the Car model. In v1.1, you realized that properties Color and DateOfManufacture are no longer applicable to the model, for whatever reason, and removed them. If you attempt to load v1 profile into v1.1 of your application, this does not work. Also, the grid will no longer be able to load and save your customization.

To demo this, create a MainViewModel class that consist of a collection of cars and all commands required to interact with our grid as follows:

public class MainWindowViewModel
    {
        private string _layout;
        private readonly XamDataGrid _dataGrid;
        private string _layoutLocation = "XamDataGrid.Layout.v1.xml";
        public MainWindowViewModel(XamDataGrid dataGrid)
        {
            _dataGrid = dataGrid;
            Cars = new ObservableCollection<Car>();
        }

        public ObservableCollection<Car> Cars { get; set; }

        public ICommand LoadLayoutFromFileCommand
        {
            get
            {
                return new RelayCommand((args) => true, o =>
                {
                    var layout = File.ReadAllText(_layoutLocation);
                    var fields = typeof (Car).GetProperties().Select(p => p.Name).ToArray();
                    var validatedLayout = XamDataGridHelper.ValidDataGridLayoutCustomization(layout, fields);
                    _dataGrid.LoadCustomizations(validatedLayout);
                });
            }
        }

        public ICommand SaveLayoutToFileCommand
        {
            get
            {
                return new RelayCommand((args) => true, o =>
                {
                    var layout = _dataGrid.SaveCustomizations();
                    File.WriteAllText(_layoutLocation, layout);
                });
            }
        }


        public ICommand LoadLayoutFromMemoryCommand
        {
            get
            {
                return new RelayCommand((args) => true, o =>
                {
                    if (!string.IsNullOrEmpty(_layout))
                    {
                        _dataGrid.LoadCustomizations(_layout);
                    }
                });
            }
        }

        public ICommand SaveLayoutToMemoryCommand
        {
            get
            {
                return new RelayCommand((args) => true, o =>
                {
                    _layout = _dataGrid.SaveCustomizations();
                });
            }
        }

        public ICommand LoadDataCommand
        {
            get
            {
                return new RelayCommand((args) => true, o =>
                {
                    Cars.Clear();
                    
                    Cars.Add(new Car {Make = "Toyota", 
                        Model = "Tercel", 
                        RetailPrice = 250,
                        Color = "#FF0000", 
                        DateOfManufacture = new DateTime(1975, 1, 1),
                        Manufacturer = "Toyota"});
                    Cars.Add(new Car { Make = "Honda", 
                        Model = "Accord", 
                        RetailPrice = 500,
                        Color = "#FF0012",
                        DateOfManufacture = new DateTime(1980, 1, 1),
                        Manufacturer = "Honda" });
                    Cars.Add(new Car { Make = "BMW",
                        Model = "M5", 
                        RetailPrice = 12000,
                        Color = "#FF6666",
                        DateOfManufacture = new DateTime(1985, 11, 1),
                        Manufacturer = "Bayerische Motoren Werke" });
      
                    _dataGrid.DataSource = Cars;
                });
            }
        }

        public ICommand ClearGridCommand
        {
            get
            {
                return new RelayCommand((args) => true, o =>
                {
                    _dataGrid.DataSource = null;
                });
            }
        }

        public class RelayCommand : ICommand
        {
            private readonly Predicate<object> _predicate;
            private readonly Action<object> _action;

            public RelayCommand(Predicate<object> predicate, Action<object> action)
            {
                _predicate = predicate;
                _action = action;
            }

            public void Execute(object parameter)
            {
                _action.Invoke(parameter);
            }

            public bool CanExecute(object parameter)
            {
                return _predicate.Invoke(parameter);
            }

            public event EventHandler CanExecuteChanged;
        }
    }

Since we do not want the grid to automatically generate the layouts for us, create a FieldLayoutFactory that creates a field layouts based on properties of our data as follows:

  public class FieldLayoutFactory
    {
        public static FieldLayout CreateFieldLayout(Type type)
        {
            var properties = type.GetProperties(BindingFlags.Public);

            var fl = new FieldLayout();
            foreach (var property in properties)
            {
                fl.Fields.Add(new Field(property.Name, property.PropertyType));
            }

            return fl;
        }
    }

and to complete the code for this demo, this is a simple XAML for the main window.

<Window x:Class="XamDataGridFieldLayoutProblems.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:dataPresenter="http://infragistics.com/DataPresenter"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <StackPanel Orientation="Horizontal">
            <Button Content="Save Layout To File" Command="{Binding SaveLayoutToFileCommand}"/>
            <Button Content="Load Layout from File" Command="{Binding LoadLayoutFromFileCommand}"/>
            <Button Content="Save Layout to Memory" Command="{Binding SaveLayoutToMemoryCommand}"/>
            <Button Content="Load Layout from Memory" Command="{Binding LoadlayoutFromMemoryCommand}"/>
        </StackPanel>
        <StackPanel
            Grid.Row="1"
            Orientation="Horizontal">
            <Button Content="Load Data" Command="{Binding LoadDataCommand}"/>
            <Button Content="Clear Grid" Command="{Binding ClearGridCommand}"/>
        </StackPanel>
        <dataPresenter:XamDataGrid 
            x:Name="MyXamDataGrid"
            Grid.Row="2"
            DataSource="{Binding Cars}"/>
    </Grid>
</Window>

with the code behind:

 public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            DataContext = new MainWindowViewModel(MyXamDataGrid);
            MyXamDataGrid.FieldLayouts.Add(FieldLayoutFactory.CreateFieldLayout(typeof(Car)));
        }
    }

With all of these parts in place, compile and run the application, assuming you have access to InfragisticsWPF3.DataPresenter.v12.1.dll and InfragisticsWPF3.v12.1.dll. I did not run this test with 13.1 of the Infragistics toolkit.

When you initially run the application, it does the usual stuff of creating our view model and initializing the grid. After the application has launched, click on button labeled Load Data. We should see data in our grid as follows:

Re-arrange the grid columns as you wish then click on the button to Save Layout to File. My re-arrangement is shown here:

Clicking on Save Layout to File should create an XML file in the same location as your application executable. Depending on how you re-arranged your grid, the content of XamDataGridLayout.v1.xml should look somewhat like this:

<?xml version="1.0" encoding="utf-8"?>
<xamDataPresenter version="12.1.20121.1010" formatVersion="1.6">
  <fieldLayouts>
    <fieldLayout key="Car" fieldList="Make, Model, RetailPrice;Double, Manufacturer, Color, DateOfManufacture;DateTime">
      <fields>
        <field name="Make" extendedInfo="Make" Visibility="Visible" IgnoreFieldVisibilityOverrides="false" row="0" column="0" rowSpan="1" columnSpan="1" isCollapsedInLayout="false" />
        <field name="Model" extendedInfo="Model" Visibility="Visible" IgnoreFieldVisibilityOverrides="false" row="1" column="0" rowSpan="1" columnSpan="1" isCollapsedInLayout="false" />
        <field name="RetailPrice" extendedInfo="RetailPrice;Double" Visibility="Visible" IgnoreFieldVisibilityOverrides="false" row="0" column="1" rowSpan="1" columnSpan="1" isCollapsedInLayout="false" />
        <field name="Manufacturer" extendedInfo="Manufacturer" Visibility="Visible" IgnoreFieldVisibilityOverrides="false" row="1" column="1" rowSpan="1" columnSpan="1" isCollapsedInLayout="false" />
        <field name="Color" extendedInfo="Color" Visibility="Visible" IgnoreFieldVisibilityOverrides="false" row="0" column="2" rowSpan="1" columnSpan="1" isCollapsedInLayout="false" />
        <field name="DateOfManufacture" extendedInfo="DateOfManufacture;DateTime" Visibility="Visible" IgnoreFieldVisibilityOverrides="false" row="1" column="2" rowSpan="1" columnSpan="1" isCollapsedInLayout="false" />
      </fields>
    </fieldLayout>
  </fieldLayouts>
</xamDataPresenter>

Close and re-run your application without any code changes. After the window launches, click on Load Layout from File and observe that the grid layout is successfully restored.

Now, here comes the fun part ……

Comment out properties DateOfManufacture and Color from the Car model, preventing them from being included in our field layout based on the implementation of FieldlayoutFactory. Of course, you have to also comment our those lines that set the Car color during construction for your code to compile.

After making these changes, re-run the code. You should see only columns Make, Model, RetailPrice and Manufacturer columns. Now, click on Load Layout from File again and observe that nothing happens. Previous layout could not be restored. Re-arrange your grid the way you were expecting it to be restored (even though there are missing fields). Click button to save layout to memory. Next, change the ordering of the fields and click to load layout from memory and observe that the grid does not reload what we saved into memory. The grid is now stuck in some state that prevents it from loading or saving its layout.

The Solution
This new and strange behavior is happening because the grid loaded a field layout with columns that do not match the model or field layout definition is was initially provided. If you restore the properties in Car model that were commented out, or validate the layout before submitting to the grid by remove the two outdated fields, the problem goes away.

Add the following helper class to your solution.

        public class XamDataGridHelper
    {
        /// <summary>
        /// This method will remove any field not that is not contained in the list of columns.
        /// Comparison is done using the field name.
        /// </summary>
        /// <param name="fieldNames">list of field names to validate against</param>
        /// <param name="fieldList">comma seperated field list i.e content of fieldList node in layout. <fields></fields></param>
        /// <returns></returns>
        public static string ValidateFieldsLayoutFieldListNode(IList<string> fieldNames, string fieldList)
        {
            if (fieldNames == null)
                throw new ArgumentNullException("fieldNames");

            if (fieldNames.Count == 0)
                throw new ArgumentException("list of field names is empty");

            if (string.IsNullOrEmpty(fieldList))
                return fieldList;


            var fields = fieldList.Split(new[] { ',' });
            var fieldsToKeep = new StringBuilder();

            var splitter = new[] { ';' };
            for (var i = 0; i < fields.Length; i++)
            {
                var field = fields[i];
                var fieldParts = field.Split(splitter);
                if (fieldParts.Length > 0 && fieldNames.Contains(fieldParts[0].Trim()))
                {
                    fieldsToKeep.AppendFormat("{0}{1}", field, ", ");
                }
            }

            return fieldsToKeep.Remove(fieldsToKeep.Length - 2, 2).ToString();
        }

        /// <summary>
        /// This method will remove any field not that is not contained in the list of columns.
        /// Comparison is done using the field name.
        /// </summary>
        /// <param name="fieldNames">list of field names to validate against</param>
        /// <param name="fieldLayout">the field node to validate.  It must begin with <fields></fields></param>
        /// <returns></returns>
        public static XElement ValidateFieldLayoutFieldsNode(IList<string> fieldNames, XElement fieldLayout)
        {
            if (fieldNames == null)
                throw new ArgumentNullException("fieldNames");

            if (fieldNames.Count == 0)
                throw new ArgumentException("list of field names is empty");

            if (fieldLayout == null)
                throw new ArgumentNullException("fieldLayout");

            var fieldsInlayout = fieldLayout.Elements("field");
            var fieldsToKeep = new List<XElement>();
            foreach (var node in fieldsInlayout)
            {
                var name = node.Attribute("name");
                if (name != null && fieldNames.Contains(name.Value))
                {
                    fieldsToKeep.Add(node);
                }
            }

            fieldLayout.RemoveAll();
            fieldLayout.Add(fieldsToKeep);
            return fieldLayout;
        }

        /// <summary>
        /// This method validates a fieldLayout node by calling <code>ValidateFieldLayoutFieldsNode</code>
        /// and ValidateFieldsLayoutFieldListNode.  It attempts to remove any fields that are not part 
        /// of the validFields list.
        /// </summary>
        /// <param name="xml">the layout </param>
        /// <param name="validFields">valid fields to remove</param>
        /// <returns></returns>
        public static string ValidDataGridLayoutCustomization(string xml, IList<string> validFields)
        {
            var xdoc = XElement.Parse(xml);
            var fieldlayout = xdoc.XPathSelectElement("fieldLayouts/fieldLayout");

            var fieldListNode = fieldlayout.Attribute("fieldList");
            if (fieldListNode != null)
            {
                var fieldList = fieldListNode.Value;
                var cleanedFieldList = ValidateFieldsLayoutFieldListNode(validFields, fieldList);
                fieldListNode.SetValue(cleanedFieldList);
            }

            ValidateFieldLayoutFieldsNode(validFields, fieldlayout.Element("fields"));

            return xdoc.ToString();
        }
    }

Then modify the command handler that loads layout from file so the command definition looks like this:

   public ICommand LoadLayoutFromFileCommand
        {
            get
            {
                return new RelayCommand((args) => true, o =>
                {
                   <b> var layout = File.ReadAllText(_layoutLocation);
                    var fields = typeof (Car).GetProperties().Select(p => p.Name).ToArray();
                    var validatedLayout = XamDataGridHelper.ValidDataGridLayoutCustomization(layout, fields);</b>
                    _dataGrid.LoadCustomizations(validatedLayout);
                });
            }
        }

Compile and run the application. Now click on the button to Load Layout from File and observe that the layout is restored even though some of the fields are missing as show here:

These kinds of problems are difficult to encounter in house especially if there is a discrepancy between test and deployment environments. Also application/profile versioning is always a challenge. While we take full responsibility for this bug and fixed it, I think Infragistics could have done a better job here. If they had failed-fast by say, throwing an exception since provided layout does not match current grid layout, this problem would have been spotted earlier. Note that this is not a generalization or criticism of the quality of their coding guide lines but hopefully a means to share common pitfalls to be aware of when dealing with third party APIs. At least Infragistics has promised to fix this in a future release.

Hoping this helps others that run into a similar problem.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s