- Part 1 – The Read-Only Hierarchical Field Template
- Part 2 – The Edit Hierarchical Field Template
- Part 3 – The Basic Hierarchical Filter
In this second part we are going to complete the field templates by adding the CascadeHierarchical_Edit field template (see Figure 1) and in the next adding a CascadeHierarchical filter.
Starting with a copy of the ForeignKey_Edit field template as our base we will end up with a field template that looks like Figure 1 allowing you to have a hierarchy ‘n’ levels deep.
Figure 1 – Cascade Hierarchical Field Template in action.
The first we must make the copy of the ForeignKey_Edit field template, and then make sure we rename it to CascadeHierarchical_Edit, also we must change the class name all it’s files; this sample being a Web Application project there are three files:
- CascadeHierarchical_Edit.ascx
- CascadeHierarchical_Edit.ascx.cs
- CascadeHierarchical_Edit.ascx.designer.cs
Each must be modified as follows (I generally follow the naming convention use in the other standard field templates i.e. Text_Edit’s class name is Text_EditField), so we will set our field templates class name to CascadeHierarchical_EditField.
In the .cs file change the class name to CascadeHierarchical_EditField and in the CascadeHierarchical_Edit.ascx change the Inherits property to be namespace.CascadeHierarchical_EditField, namespace is usually the name of the project, in any case if will already be filled in in the template unless you are copying from one project to another.
Now we have a basis to begin, lets first start with the Page_Init and some explanation.
<%@ Control Language="C#" CodeBehind="CascadeHierarchical_Edit.ascx.cs" Inherits="CascadeHierarchicalFieldTemplate.CascadeHierarchical_EditField" %> <asp:RequiredFieldValidator runat="server" ID="RequiredFieldValidator1" CssClass="DDControl DDValidator" Display="Static" Enabled="false"/> <asp:DynamicValidator runat="server" ID="DynamicValidator1" CssClass="DDControl DDValidator" Display="Static"/>
Listing 1 – the aspx page.
Here in Listing 1 you can see all we have are the required and dynamic validators, this is all we need at the dropdown lists will all be created dynamically.
#region member variables // hold the current data context to access the model private object context; // hold the list of filters private SortedList<int, HierachicalListControl> filters = new SortedList<int, HierachicalListControl>(); // hold the attribute private CascadeHierarchicalAttribute cascadeHierarchicalAttribute; #endregion protected void Page_Init(object sender, EventArgs e) { // check we have a cascade hierarchical attribute if not throw error cascadeHierarchicalAttribute = Column.GetAttribute<CascadeHierarchicalAttribute>(); if (cascadeHierarchicalAttribute == null) throw new InvalidOperationException("Was expecting a CascadeFilterAttribute."); // check we have correct column type if not throw error if (!(Column is MetaForeignKeyColumn)) throw new InvalidOperationException(String.Format("Column {0} must be a foreign key column navigation property", Column.Name)); // get current context context = Table.CreateContext(); // get hierarchical cascade columns var parentColumns = new SortedList<int, String>(); for (int i = 0; i < cascadeHierarchicalAttribute.Parameters.Length; i++) parentColumns.Add(i, cascadeHierarchicalAttribute.Parameters[i]); // add extra column to represent this column itself parentColumns.Add(cascadeHierarchicalAttribute.Parameters.Length, ""); //get current column into a local variable MetaForeignKeyColumn column = ForeignKeyColumn; // setup list of filter definitions for (int i = 0; i < parentColumns.Count; i++) { // get parent column name var parentColumnName = parentColumns[i]; // create dropdown list var ddl = new DropDownList() { ID = String.Format("ListControl{0}", i), Enabled = false, AutoPostBack = true }; // create filter var filter = new HierachicalListControl(ddl) { Column = column }; // check for last parent filter if (!String.IsNullOrEmpty(parentColumnName)) { // set parent column from parent table filter.ParentColumn = (MetaForeignKeyColumn)column.ParentTable.GetColumn(parentColumnName); // set current column to parent column column = filter.ParentColumn; } else { // this is the last parent and has // no parent itself so set to null filter.ParentColumn = null; column = null; } // add filter to list of filters filters.Add(i, filter); } // add dropdown list to page in correct order 2, 1, 0 // last parent, parent<N>, child for (int i = parentColumns.Count - 1; i >= 0; i--) { // setup dropdown list filters[i].ListControl.Items.Clear(); filters[i].ListControl.Items.Add(new ListItem("------", "")); // add parent list controls event handler if (i > 0) filters[i].ListControl.SelectedIndexChanged += ListControls_SelectedIndexChanged; // add control to place holder this.Controls.Add(filters[i].ListControl); } if (Mode == DataBoundControlMode.Insert) { // fill last parent filter var lastParentIndex = filters.Count - 1; var parentTable = filters[lastParentIndex].Column.ParentTable; var parentQuery = parentTable.GetQuery(context); // set next descendant list control PopulateListControl(lastParentIndex, parentQuery); } }
Listing 2 - Page_Init
The reason for using Page_Init (see Listing 2) is that we are going to create the dropdown lists dynamically, these need to be instantiated in the OnInit event to be fully involved in post back.
The first thing we need to do in the Page_Init is to make sure we have the correct Column Type (MetaForeignKeyColumn) and that the column has a CascadeHierarchicalAttribute assigned, if either of these in not present then we throw an error.
There are three for loops in the Page_Init, the first for loop we are building a list of parent columns to help with the next for loop which builds the filters list (by having two loops we make it easy to put the list controls onto the page in the most logical order, last parent to the left and child to the right).
This list of filters is used throughout the rest of the field template to build each list control and made up of a dropdown list the current column and it’s parent column (see Listing 3).
/// <summary> /// Class to contains information about cascading dropdown lists /// </summary> protected internal class HierachicalListControl { /// <summary> /// Returns a <see cref="System.String"/> that represents this instance. /// </summary> /// <returns> /// A <see cref="System.String"/> that represents this instance. /// </returns> public override string ToString() { var parentColumn = ParentColumn != null ? ParentColumn.Name : "null"; return String.Format("{0}.{1}", Column.Name, parentColumn); //return this.Column.Name; } /// <summary> /// Initializes a new instance of the <see cref="HierachicalListControl"/> class. /// </summary> /// <param name="column">This column.</param> /// <param name="parentColumn">This column's parent column.</param> public HierachicalListControl(ListControl listControl) { ListControl = listControl; } /// <summary> /// Gets or sets the filter column. /// </summary> /// <value>The column.</value> public MetaForeignKeyColumn Column { get; set; } /// <summary> /// Gets or sets the filter column's parent column. /// </summary> /// <value>The parent column.</value> public MetaForeignKeyColumn ParentColumn { get; set; } /// <summary> /// Gets or sets the list control. /// </summary> /// <value>The list control.</value> public ListControl ListControl { get; set; } }
Listing 3 – HierachicalListControl used in list of filters .
The final for loop is use to initialise each dropdown list with it’s default item, hook-up the SelectionIndexChanged event and then add it to the page.
The final segment of code in Page_Init is the if statement for when we are in insert mode as the OnDataBound event will not fire, so we populate the last filter the (highest in the hierarchy).
protected void Page_Load(object sender, EventArgs e) { if (filters[0] != null && filters[0].ListControl != null) { RequiredFieldValidator1.ControlToValidate = "ListControl0"; DynamicValidator1.ControlToValidate = "ListControl0"; SetUpValidator(RequiredFieldValidator1); SetUpValidator(DynamicValidator1); } }
Listing 4 – Page_Load event handler
The Page_Load event just makes sure that we have some filter[0] and then sets up the validators. The next event handler is the OnDataBinding handler here we make sure we are in Edit mode and that the current value is not null and then we call the main method for setting up the filters SetupListControls.
protected override void OnDataBinding(EventArgs e) { base.OnDataBinding(e); // Set initial value if (Mode == DataBoundControlMode.Edit && FieldValue != null) PopulateAllListControls(FieldValue); } /// <summary> /// Sets the default values. /// </summary> /// <param name="fieldValue">The value.</param> private void PopulateAllListControls(object fieldValue) { var displayStrings = new SortedList<int, String>(); #region Get list of propert values // get property values var propertyValues = new SortedList<int, Object>(); propertyValues.Add(0, fieldValue); for (int i = 0; i < filters.Count - 1; i++) { var parentName = filters[i].ParentColumn.Name; object pv = propertyValues[i].GetPropertyValue(parentName); propertyValues.Add(i + 1, pv); } #endregion // stating at the first filter and work way up to the last filter for (int i = 0; i < filters.Count; i++) { var parentTable = filters[i].Column.ParentTable; var parentQuery = parentTable.GetQuery(context); IQueryable listItemsQuery; if (i == cascadeHierarchicalAttribute.Parameters.Length) { listItemsQuery = parentQuery.GetQueryOrdered(parentTable); } else { var pcol = filters[i + 1].Column; var selectedValue = filters[i].ParentColumn.GetForeignKeyString(propertyValues[i]); listItemsQuery = parentQuery.GetQueryFilteredFkColumn(pcol, selectedValue); } // set next descendant list control PopulateListControl(i, listItemsQuery); // set initial values var selectedValueString = filters[i].Column.Table.GetPrimaryKeyString(propertyValues[i]); ListItem item = filters[i].ListControl.Items.FindByValue(selectedValueString); if (item != null) filters[i].ListControl.SelectedValue = selectedValueString; } }
Listing 5 – OnDataBinding and PopulateAllListControls
The first thing SetupListControls does is get a list of actual values for each filter in the cascade of filters using fieldValue as a starting point, working back from the passed in fieldValue, it calls the method GetPropertyValue which uses reflection to get the value of the next entity using the current value and the parent column’s name. These values are in turn used to fill each filter with appropriately filtered values. The last parent getting an unfiltered list of items as it has no parent itself.
/// <summary> /// Gets the property value. /// </summary> /// <param name="sourceObject">The source object.</param> /// <param name="propertyName">Name of the property.</param> /// <returns>The named properties value.</returns> public static Object GetPropertyValue(this Object sourceObject, string propertyName) { if (sourceObject != null) return sourceObject.GetType() .GetProperty(propertyName) .GetValue(sourceObject, null); else return null; }
Listing 6 – GetPropertyValue
The two extension methods used to get the list of items are called GetQueryOrdered Listing 7 or GetQueryFilteredFkColumn Listing 8.
/// <summary> /// Gets the query ordered. /// </summary> /// <param name="sourceQuery">The source query.</param> /// <param name="table">The table.</param> /// <returns></returns> public static IQueryable GetQueryOrdered(this IQueryable sourceQuery, MetaTable table) { // get display column attribute var displayColumnAttribute = table.GetAttribute<DisplayColumnAttribute>(); // check to see if sort is assigned if (displayColumnAttribute == null || displayColumnAttribute.SortColumn == null) return sourceQuery; // {row.OrderBy(row => row.Name)} var orderByCall = GetOrderByCallExpression( sourceQuery, table, displayColumnAttribute.SortColumn, false); // create and return query return sourceQuery.Provider.CreateQuery(orderByCall); }
Listing 7 – GetQueryOrdered
/// <summary> /// Gets a list of entities from the source IQueryable /// filtered by the MetaForeignKeyColumn's selected value /// </summary> /// <param name="sourceQuery">The query to filter</param> /// <param name="fkColumn">The column to filter the query on</param> /// <param name="fkSelectedValue">The value to filter the query by</param> /// <returns> /// An IQueryable of the based on the source query /// filtered but the FK column and value passed in. /// </returns> public static IQueryable GetQueryFilteredFkColumn(this IQueryable sourceQuery, MetaForeignKeyColumn fkColumn, String fkSelectedValue) { // if no filter value return the query if (String.IsNullOrEmpty(fkSelectedValue)) return sourceQuery; // order query sourceQuery = GetQueryOrdered(sourceQuery, fkColumn.Table); // {RequiredPlots} var parameterExpression = Expression.Parameter(sourceQuery.ElementType, fkColumn.Table.Name); // {(RequiredPlots.Builders.Id = 1)} var body = BuildWhereClause(fkColumn, parameterExpression, fkSelectedValue); // {RequiredPlots => (RequiredPlots.Builders.Id = 1)} var whereLambda = Expression.Lambda(body, parameterExpression); // {Developers.Where(RequiredPlots => (RequiredPlots.Builders.Id = 1))} var whereCall = Expression.Call(typeof(Queryable), "Where", new Type[] { sourceQuery.ElementType }, sourceQuery.Expression, Expression.Quote(whereLambda)); // create and return query return sourceQuery.Provider.CreateQuery(whereCall); }
Listing 8 – GetQueryFilteredFkColumn
Listings 7 & 8 are form the IQueriableExtensionMethods.cs class file in the NotAClue.Web.DynamicData class library, and are a part of my dynamic Linq Expression extension methods that originally came from the ASP.NET July 2007 Futures Source Code sample on ASP.Net on CodePlex.com, I continue to expand and modify them as I need to; so I wont go into detail here as there better posts on Linq Expression than any I could write.
/// <summary> /// Handles the SelectedIndexChanged event of the parentListControls control. /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="System.EventArgs"/> instance containing the event data.</param> /// <summary> /// Setups the parent list control. /// </summary> /// <param name="table">The table.</param> /// <param name="filterIndex">The parent id.</param> /// <param name="items">The items.</param> public void PopulateListControl(int filterIndex, IQueryable items) { // clear the list controls list property filters[filterIndex].ListControl.Items.Clear(); // enable list control filters[filterIndex].ListControl.Enabled = true; // add unselected value showing the column name // [Styles] filters[filterIndex].ListControl.Items.Add( new ListItem(String.Format("[{0}]", filters[filterIndex].Column.DisplayName), "")); foreach (var row in items) { // populate each item with the display string and key value filters[filterIndex].ListControl.Items.Add( new ListItem(filters[filterIndex].Column.ParentTable.GetDisplayString(row), filters[filterIndex].Column.ParentTable.GetPrimaryKeyString(row))); } }
Listing 9 – PopulateListControl
Once we have the query filtered and ordered we then set the list control’s items up by calling PopulateListControl (Listing 9), then finally we set the initial value in of the list control.
/// <summary> /// Handles the SelectedIndexChanged event for each List control, /// and populates the next list control in the hierarchy. /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e"> /// The <see cref="System.EventArgs"/> instance containing the event data. /// </param> void ListControls_SelectedIndexChanged(object sender, EventArgs e) { // get list control var listControl = (ListControl)sender; // get the sending list controls id as an int var id = ((Control)sender).ID; // use regular expression to find list control index var regEx = new Regex(@"\d+"); var parentIndex = int.Parse(regEx.Match(id).Value); if (!String.IsNullOrEmpty(listControl.SelectedValue)) { if (parentIndex > 0) { // set child index var childIndex = parentIndex - 1; // get parent table var parentTable = filters[childIndex].Column.ParentTable; // get query from table var query = parentTable.GetQuery(context); // get items for list control var itemQuery = query.GetQueryFilteredFkColumn( filters[parentIndex].Column, listControl.SelectedValue); // populate list control PopulateListControl(childIndex, itemQuery); // reset all descendant list controls ResetAllDescendantListControls(childIndex); } } else { // reset all descendant list controls ResetAllDescendantListControls(parentIndex); } }
Listing 10 – parentListControls_SelectedIndexChanged
The next thing we have to deal with is SelectedIndexChanged event on each dynamically created filter, we do this in the ListControls_SelectedIndexChanged handler (Listing 10) we first get the posting list control and then extract the filter index from it’s name. Remember all the filters are named ListControl{N} where N is the index into the filters list for the filter.
Then we check to see if the list control has a SelectedValue if not we reset it and all it’s children to the default of "----" and Enabled to false. If it has a SelectedValue then we get a query on the child column filtered by the current SelectedValue and populate the next list control in line.
We then call SetupParentListControl to populate the control and finally ResetAllDescendantListControls to set any descendant controls to their default value of "----" and disabled.
The last two methods as two of the standard field template methods just slightly change to avoid exceptions seen here in Listings 11.
protected override void ExtractValues(IOrderedDictionary dictionary) { // If it's an empty string, change it to null string value; if (filters[0] != null && filters[0].ListControl != null) value = filters[0].ListControl.SelectedValue; else value = String.Empty; if (String.IsNullOrEmpty(value)) value = null; ExtractForeignKey(dictionary, value); } public override Control DataControl { get { if (filters[0] != null && filters[0].ListControl != null) return filters[0].ListControl; else return null; } }
Listing 11 – ExtractValues method and DataControl property.
These two methods merely check to see if there are nay controls before processing.
Download
The sample is a Visual Studio 2010 and .Net 4.0 sample but the code and field template should work with Visual Studio 2008 SP1 and .Net 3.5 SP1 DD Web Application.