Print This Post

Searching SharePoint lists and document libraries with RenderAsHtml

One requirement that I frequently come across in projects is to have a web part which can display a SharePoint list or document library and allow the user to search with wild cards. Obviously, basic searching can be done via filters and web part connections, but these require an exact match to get results, where as most people expect to be able to search on a substring.

So I’ve been attempting (being the operative word) to come up with a web part that can do just that. It thought it would be pretty simple- I was wrong. Very very wrong.

So I started with creating a web part which used the RenderAsHtml method of the SPList and SPView classes. The basic idea is that an SPQuery object can be passed into the RenderAsHtml method and used to filter the list items. RenderAsHtml should then do all of the hard work of displaying the list and menus. So endeth the theory!

So, the first thing we need to do is create a web part which can display the list items in a given view using RenderAsHtml. The web part will have a few basic properties:

  • A URL to a web (or blank to use the context web).
  • The name of a list or document library.
  • The name of a view (or blank to use the default view).
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Web;
using System.Web.UI;
using System.Web.UI.HtmlControls;
using System.Web.UI.WebControls;
using System.Xml.Serialization;
using System.Text;
using System.Reflection;

using Microsoft.SharePoint;
using Microsoft.SharePoint.WebPartPages;
using Microsoft.SharePoint.WebControls;
using Microsoft.SharePoint.Utilities;
using Microsoft.SharePoint.Administration;

namespace WebParts
{
    [ToolboxData("<{0}:ListViewer runat=server></{0}:ListViewer>"), XmlRoot(Namespace = "ListViewer")]
    public class ListViewer : WebPart
    {
        /// <summary>
        /// The url to the web the list is contained within.
        /// </summary>
        [Browsable(true), Category("Options"), FriendlyName("The url to the web the list is contained within."), WebPartStorage(Storage.Shared), DefaultValue("")]
        public string WebURL
        {
            get;
            set;
        }

        /// <summary>
        /// The name of the list the data is contained within.
        /// </summary>
        [Browsable(true), Category("Options"), FriendlyName("The name of the list the data is contained within."), WebPartStorage(Storage.Shared), DefaultValue("")]
        public string ListName
        {
            get;
            set;
        }

        /// <summary>
        /// The name of the view to render. Blank for the default view.
        /// </summary>
        [Browsable(true), Category("Options"), FriendlyName("The name of the view to use. Blank for the default view."), WebPartStorage(Storage.Shared), DefaultValue("")]
        public string ViewName
        {
            get;
            set;
        }
}

Next we need to render the list contents using RenderAsHtml. To do this simply create a new method called GetViewHtml which will use the properties we defined to get the appropriate view and then return the HTML for display.

/// <summary>
/// Gets the HTML of the view.
/// </summary>
/// <returns>The HTML view.</returns>
string GetViewHtml()
{
    SPWeb web = null;
    SPList list = null;
    SPView view = null;

    if (String.IsNullOrEmpty(ListName))
    {
        return "Please configure the list source.";
    }

    try
    {
        if (String.IsNullOrEmpty(WebURL))
        {
            web = SPContext.Current.Web;
            list = web.Lists[ListName];
        }
        else
        {
            using (web = SPContext.Current.Site.OpenWeb(WebURL))
            {
                list = web.Lists[ListName];
            }
        }
    }
    catch (Exception ex)
    {
        return "Error accessing the specified list: " + ex.Message;
    }

    if (list != null)
    {
        if (String.IsNullOrEmpty(ViewName))
        {
            view = list.DefaultView;
        }
        else
        {
            view = list.Views[ViewName];
        }

        try
        {
            string html = null;
            html = view.RenderAsHtml(true, true, SPAlternateUrl.ContextUri.AbsolutePath);
            return html;
        }
        catch (Exception ex)
        {
            return "There was an error rendering the list: " + ex.Message;
        }
    }
    else
    {
        return String.Empty;
    }
}

Finally we simply override the RenderWebPart method of our web part to display the HTML.

/// <summary>
/// Renders the web part.
/// </summary>
/// <param name="output">The output.</param>
protected override void RenderWebPart(System.Web.UI.HtmlTextWriter output)
{
    base.RenderWebPart(output);
    output.Write(GetViewHtml());
}

So far so good. We now get a nicely formatted grid with all of the appropriate menus displayed automatically.

Simple rendered list

You’ll notice that the view I’m using contains groups. Trying to expand the groups at this point will simply display a “loading…” message for the lucky ones or a javascript error for the likes of me. The reason for this is that the rendered list contains javascript which performs a callback to the server to obtain the HTML of the expanded section which it then inserts via the DOM. Since we haven’t implemented a callback this fails. One way to fix this is to set the list to expand all groups (either via the view definition or by changing the CAML at runtime) and then using javascript to collapse all of the groups prior to display. In doing this you ensure that all of the groups are already present in the HTML and so no callbacks are required. This is fine, but it does mean that you lose the late loading properties of the groups. I decided to try and implement the ICallbackEventHandler on the web part. I’ll come back to that later though. First let’s implement the search box.

To implement the search (and other things) we need to do quite a bit of manipulation of CAML statements. There are a number of excellent tools out there to help with this, but for now I’m using a simple helper class of my own.

using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;

namespace WebParts
{
    /// <summary>
    /// Utility class to help process CAML queries.
    /// </summary>
    public class CAMLHelper
    {
        XmlDocument _doc;

        /// <summary>
        /// Constructor.
        /// </summary>
        /// <param name="caml">The CAML query to process.</param>
        public CAMLHelper(string caml)
        {
            _doc = new XmlDocument();
            _doc.LoadXml("<Query>" + caml + "</Query>");
        }

        /// <summary>
        /// Gets the processed CAML query.
        /// </summary>
        public string Caml
        {
            get { return _doc.DocumentElement.InnerXml; }
        }

        /// <summary>
        /// Expands the grouping statement of a CAML query.
        /// </summary>
        /// <returns>A string array of the detected grouping fields..</returns>
        public string[] ExpandGrouping()
        {
            XmlElement group;
            List<string> groupFields;

            group = _doc.SelectSingleNode("/Query/GroupBy") as XmlElement;

            groupFields = new List<string>();

            if (group != null)
            {
                group.SetAttribute("Collapse", "FALSE");

                foreach (XmlNode n in group.ChildNodes)
                {
                    groupFields.Add(n.Attributes["Name"].Value);
                }
            }

            return groupFields.ToArray();
        }

        /// <summary>
        /// Injects a new caml query into and existing query string.
        /// </summary>
        /// <param name="newQuery">The new caml query.</param>
        /// <param name="useOR">True to compound with OR, false for AND.</param>
        public void InjectQuery(string newQuery, bool useOR)
        {
            XmlDocument doc = new XmlDocument();
            XmlDocument newDoc = new XmlDocument();
            XmlNode where;
            XmlNode insertTo;

            newDoc.LoadXml(newQuery);

            where = _doc.SelectSingleNode("/Query/Where");

            if (where == null)
            {
                where = _doc.CreateElement("Where");
                _doc.DocumentElement.AppendChild(where);
            }

            if (where.ChildNodes.Count == 0)
            {
                insertTo = where;
            }
            else
            {
                insertTo = _doc.CreateElement(useOR ? "Or" : "And");
                insertTo.AppendChild(_doc.ImportNode(where.RemoveChild(where.ChildNodes[0]), true));
                where.AppendChild(insertTo);
            }

            insertTo.AppendChild(_doc.ImportNode(newDoc.ChildNodes[0].Name == "Where" ? newDoc.ChildNodes[0].ChildNodes[0] : newDoc.ChildNodes[0], true));
        }

        /// <summary>
        /// Uses regular expressions to get the values from a choice field.
        /// </summary>
        /// <param name="value">The choice field compound value.</param>
        /// <returns>An array of individual values.</returns>
        public string[] ParseChoiceValue(string value)
        {
            Regex r = new Regex(@"#(.+?);");
            Match choice;
            List<string> values;

            values = new List<string>();
            choice = r.Match(value);

            while (choice.Success)
            {
                values.Add(choice.Result("$1"));
                choice = choice.NextMatch();
            }

            return values.ToArray();
        }

        /// <summary>
        /// Sets an OrderBy clause in the query.
        /// </summary>
        /// <param name="sortField">The sort field name.</param>
        /// <param name="ascending">True is the sort is ascending.</param>
        public void SetOrderBy(string sortField, bool ascending)
        {
            XmlElement order;
            XmlElement fieldRef;

            order = _doc.SelectSingleNode("/Query/OrderBy") as XmlElement;

            if (order == null)
            {
                order = _doc.CreateElement("OrderBy");
                _doc.DocumentElement.AppendChild(order);
            }
            else
            {
                order.InnerXml = String.Empty;
            }

            fieldRef = _doc.CreateElement("FieldRef");
            fieldRef.SetAttribute("Name", sortField);
            fieldRef.SetAttribute("Ascending", ascending ? "TRUE" : "FALSE");
            order.AppendChild(fieldRef);
        }

    }
}

The first method of CAMLHelper that we’ll use is InjectQuery which is used to add elements to a query by using AND or OR elements- that is to make the query more or less restrictive. It does this by manipulating the XML DOM and is actually quite simple. This method will be used to add search elements into the CAML query of the view depending on what users enter into the search box. We’d better create the search box first though. To do this simply override the CreateChildControls method in the web part as follows.

TextBox _txtQuery;

protected override void CreateChildControls()
{
    Panel container;
    Label caption;
    Button btnSearch;
    Button btnClear;

    base.CreateChildControls();

    if (String.IsNullOrEmpty(ListName)) return;

    container = new Panel();
    container.Width = new Unit(100, UnitType.Percentage);
    Controls.Add(container);
    container.Style.Add("margin", "5px");
    container.Style.Add("font-size", "small");

    caption = new Label();
    caption.Text = "Search";
    caption.Style.Add("font-size", "9pt");
    caption.Style.Add("font-weight", "bold");
    container.Controls.Add(caption);

    _txtQuery = new TextBox();
    _txtQuery.Style.Add("margin-left", "5px");
    container.Controls.Add(_txtQuery);

    btnSearch = new Button();
    btnSearch.ID = "SearchButton";
    btnSearch.Text = "Search";
    btnSearch.Style.Add("margin-left", "5px");
    container.Controls.Add(btnSearch);
    container.DefaultButton = "SearchButton";

    btnClear = new Button();
    btnClear.Text = "Clear";
    btnClear.Style.Add("margin-left", "5px");
    btnClear.Click += new EventHandler(btnClear_Click);
    container.Controls.Add(btnClear);
}

void btnClear_Click(object sender, EventArgs e)
{
    _txtQuery.Text = String.Empty;
}

Note the event handler for the clear button which will simply clear the search term. The code above will cause a text box, a search button, and a clear button to be displayed before the rendered list. Now we just need to filter the view depending on what has been entered into the search box. To do this the GetHtml method needs to be changed as follows.

string GetViewHtml()
{
    CAMLHelper helper;
    SPWeb web = null;
    SPList list = null;
    SPView view = null;

    if (String.IsNullOrEmpty(ListName))
    {
        return "Please configure the list source.";
    }

    try
    {
        if (String.IsNullOrEmpty(WebURL))
        {
            web = SPContext.Current.Web;
            list = web.Lists[ListName];
        }
        else
        {
            using (web = SPContext.Current.Site.OpenWeb(WebURL))
            {
                list = web.Lists[ListName];
            }
        }
    }
    catch (Exception ex)
    {
        return "Error accessing the specified list: " + ex.Message;
    }

    if (list != null)
    {
        if (String.IsNullOrEmpty(ViewName))
        {
            view = list.DefaultView;
        }
        else
        {
            view = list.Views[ViewName];
        }

        try
        {
            helper = new CAMLHelper(view.Query);

            if (!String.IsNullOrEmpty(_txtQuery.Text))
            {
                CAMLHelper searchHelper = new CAMLHelper(String.Empty);
                string searchQuery;

                foreach (SPField field in list.Fields)
                {
                    if (field.Type == SPFieldType.Text)
                    {
                        searchHelper.InjectQuery("<Contains><FieldRef Name=\"" + field.InternalName + "\"/><Value Type=\"Text\">" + _txtQuery.Text + "</Value></Contains>", true);
                    }
                }

                searchQuery = searchHelper.Caml;

                if (!String.IsNullOrEmpty(searchQuery))
                {
                    helper.InjectQuery(searchQuery, false);
                }
            }

            string html = null;
			view.Query = helper.Caml;
           	html = view.RenderAsHtml(true, true, SPAlternateUrl.ContextUri.AbsolutePath);

            return html;
        }
        catch (Exception ex)
        {
            return "There was an error rendering the list: " + ex.Message;
        }
    }
    else
    {
        return String.Empty;
    }
}

What this does is to loop through each of the text fields in the list adding in a Contains clause to the view query for each one. The Query property of the view is set just before RenderAsHtml is called to restrict the result set.

Filtering the list

So now we can filter the list. But groups still do not expand. So back to the ICallbackEventHandler that needs to be implemented.

string _groupToExpand;

/// <summary>
/// Returns the results of a callback event that targets a control.
/// </summary>
/// <returns>The result of the callback.</returns>
public string GetCallbackResult()
{
    // We need to return the HTML for the group we need to expand.
    return GetViewHtml();
}

/// <summary>
/// Processes a callback event that targets a control.
/// </summary>
/// <param name="eventArgument">A string that represents an event argument to pass to the event handler.</param>
public void RaiseCallbackEvent(string eventArgument)
{
    // Grouping parameters separated as with choice fields
    _groupToExpand = Page.Server.UrlDecode(eventArgument);
}

The javascript on the page will cause the RaiseCallbackEvent method to be called when a group is expanded. An argument will be passed to this method which will be a ;# delimited list of all of the filter values which go together to make the group- for example ;#Group A#;Group B#;. This is stored in the _groupToExpand variable as we’ll need it later. To retrieve the HTML for the expanded group the GetCallbackResult method is called. This returns the output of the GetViewHtml method which we will need to filter to only return the rows that are expected for the expanded group. To do this we use the following approach:

  • We change the query to cause all groups to be initially expanded by changing the GroupBy element to include and attribute of Collapse=’FALSE’. In doing this we get an array of the field names which make up the entire GroupBy element in the CAML. This is handled by the ExpandGrouping method in CAMLHelper.
  • We update the query to filter by each of the group fields with the values in _groupToExpand. These values are parsed using the ParseChoiceValue method in CAMLHelper.
  • We execute the query against the RenderAsHtml method of the list. This will result in the HTML for one expanded group being created.
  • Now the tricky (hacky) bit. We need to get the rows from the HTML excluding the header and footer. To do this we check for css classes in the html and strip the before and after parts. We then return only this HTML.

The resulting GetViewHtml looks like this:

string GetViewHtml()
{
    CAMLHelper helper;
    SPWeb web = null;
    SPList list = null;
    SPView view = null;

    if (String.IsNullOrEmpty(ListName))
    {
        return "Please configure the list source.";
    }

    try
    {
        if (String.IsNullOrEmpty(WebURL))
        {
            web = SPContext.Current.Web;
            list = web.Lists[ListName];
        }
        else
        {
            using (web = SPContext.Current.Site.OpenWeb(WebURL))
            {
                list = web.Lists[ListName];
            }
        }
    }
    catch (Exception ex)
    {
        return "Error accessing the specified list: " + ex.Message;
    }

    if (list != null)
    {
        if (String.IsNullOrEmpty(ViewName))
        {
            view = list.DefaultView;
        }
        else
        {
            view = list.Views[ViewName];
        }

        try
        {
            helper = new CAMLHelper(view.Query);

            if (!String.IsNullOrEmpty(_txtQuery.Text))
            {
                CAMLHelper searchHelper = new CAMLHelper(String.Empty);
                string searchQuery;

                foreach (SPField field in list.Fields)
                {
                    if (field.Type == SPFieldType.Text)
                    {
                        searchHelper.InjectQuery("<Contains><FieldRef Name=\"" + field.InternalName + "\"/><Value Type=\"Text\">" + _txtQuery.Text + "</Value></Contains>", true);
                    }
                }

                searchQuery = searchHelper.Caml;

                if (!String.IsNullOrEmpty(searchQuery))
                {
                    helper.InjectQuery(searchQuery, false);
                }
            }

            if (!String.IsNullOrEmpty(_groupToExpand))
            {

                string[] groupFields;
                string[] groupValues;
                int i = 0;

                // Filter the results to match the groups to minimise the HTML we return.
                groupFields = helper.ExpandGrouping();
                groupValues = helper.ParseChoiceValue(_groupToExpand);

                foreach (string g in groupFields)
                {
                    StringBuilder sb = new StringBuilder();
                    SPField f = list.Fields.GetFieldByInternalName(g);
                    sb.Append("<Eq><FieldRef Name=\"");
                    sb.Append(g);
                    sb.Append("\"/><Value Type=\"");
                    sb.Append(f.TypeAsString);
                    sb.Append("\">");

                    if (f.Type == SPFieldType.DateTime)
                    {
                        sb.Append(SPUtility.CreateISO8601DateTimeFromSystemDateTime(DateTime.Parse(groupValues[i++])));
                    }
                    else
                    {
                        sb.Append(groupValues[i++]);
                    }

                    sb.Append("</Value></Eq>");

                    helper.InjectQuery(sb.ToString(), false);
                }
            }

            string html = null;

            if (!String.IsNullOrEmpty(_groupToExpand))
            {
                int startPos;
                int endPos;

                // Use the list RenderAsHtml to get the expanded portion of the HTML.
                // Note that the view RenderAsHtml does not allow the group to be expanded.
                SPQuery q = new SPQuery(view);
                q.Query = helper.Caml;
                html = list.RenderAsHtml(q);

                // Extract the html for the expanded section- this is needed by the callback.
                startPos = html.IndexOf("<TBODY id=\"tbod");

                if (startPos >= 0)
                {
                    startPos = html.IndexOf("<TR", startPos);
                    html = html.Substring(startPos);
                }

                endPos = html.LastIndexOf("</TBODY><TBODY id=\"foot");

                if (endPos >= 0)
                {
                    html = html.Substring(0, endPos);
                    html += "<TR><TD colspan=50></TD></TR>";
                }
            }
            else
            {
                int insertPos;

                // Use the view RenderAsHtml to ensure that totals, footers, etc are rendered.
                view.Query = helper.Caml;
                html = view.RenderAsHtml(true, true, SPAlternateUrl.ContextUri.AbsolutePath);

                // Add in the GroupByCol element- this is needed to allow the view to pull
                // groups from the cookies.
                insertPos = html.LastIndexOf("<TBODY");

                if (insertPos >= 0)
                {
                    html = html.Insert(insertPos, "<tbody id=\"GroupByCol" + ID + "\"/>");
                }
            }

            return html;
        }
        catch (Exception ex)
        {
            return "There was an error rendering the list: " + ex.Message;
        }
    }
    else
    {
        return String.Empty;
    }
}

That’s the callback implemented. The groups still don’t expand though. That’s because there are some fields and scripts missing from the HTML generated by RenderAsHtml. To add these in change the OnLoad and RenderWebPart methods as follows and add in the additional methods.

 /// <summary>
/// Registers the window.onload script to load the cookies or groups.
/// </summary>
void RegisterOnload()
{
    string name = "ExpGroupOnPageLoad";
    string key = "onLoadCall" + name;

    if (!this.Page.ClientScript.IsClientScriptBlockRegistered(this.GetType(), key))
    {
        string script = string.Concat(new object[] { "\r\nif (typeof(_spBodyOnLoadFunctionNames) != \"undefined\") {\r\nif (_spBodyOnLoadFunctionNames != null) {\r\n_spBodyOnLoadFunctionNames.push(", '"', name, '"', ");\r\n}\r\n}" });
        Page.ClientScript.RegisterClientScriptBlock(this.GetType(), key, script, true);
    }
}

/// <summary>
/// Raises the <see cref="E:System.Web.UI.Control.Load"/> event.
/// </summary>
/// <param name="e">The <see cref="T:System.EventArgs"/> object that contains the event data.</param>
protected override void OnLoad(EventArgs e)
{
    if (this.Page != null)
    {
        _instanceID = GetInstanceID();

        if (!this.Page.IsCallback)
        {
            string str = this.Page.ClientScript.GetCallbackEventReference(this, "globalArg_" + this.ID, "ExpGroupReceiveData", "globalContext_" + this.ID, "ExpGroupOnError", true);
            string script = "\r\nvar globalArg_" + this.ID + ";\r\nvar globalContext_" + this.ID + ";\r\nfunction ExpGroupCallServer" + this.ID + "(arg, context)\r\n{\r\n    globalArg_" + this.ID + " = arg;\r\n    globalContext_" + this.ID + " = context;\r\n    setTimeout(\"" + str + "\", 0);\r\n}";
            Page.ClientScript.RegisterClientScriptBlock(this.GetType(), "ExpGroupCallServer" + this.ID, script, true);
            Page.ClientScript.RegisterClientScriptBlock(this.GetType(), "ExpGroupOnError", GetCallbackErrorScript(), true);
            Page.ClientScript.RegisterHiddenField("GroupByColFlag", "");
            RegisterOnload();
            base.OnLoad(e);
        }
    }
}

/// <summary>
/// Gets the text of the callback error script.
/// </summary>
/// <returns>The error script.</returns>
string GetCallbackErrorScript()
{
    return ("\r\nfunction ExpGroupOnError(message, context) {\r\nalert('There was an error during the callback.');\r\n}");
}

/// <summary>
/// Renders the web part.
/// </summary>
/// <param name="output">The output.</param>
protected override void RenderWebPart(System.Web.UI.HtmlTextWriter output)
{
    base.RenderWebPart(output);

    output.Write("<input type=\"hidden\" id=\"GroupByWebPartID" + _instanceID.ToString() + "\" webPartID=\"" + ID + "\"/>");
    output.Write(GetViewHtml());

    if (_limited)
    {
        output.Write("<p style='color:#cc0000'>The list viewer is currently operating with limited functionality.</p>");
    }
}

This code results in a couple of hidden fields and scripts being created which are used by the javascript to get the ID of the web part to correctly initiate a callback. One final element has already been injected by the new GetViewHtml method. This is a TBODY tag called GroupByCol{ID} where {ID} is obviously the ID of the web part. This element is essential to ensuring that cookies can be used to persist the expanded groups between page loads and (more importantly) during search and sort operations.

Groups expanding

So now the list can be searched and groups can be expanded. We’re not quite there yet though. Here are some functions which will either not work at all or will fail sometimes.

  • Filtering using the toolbar.
  • Sorting using the toolbar.
  • Clicking into folders within a list or a document library.

The parameters used to set up these things are passed on the URL of the page. The RenderAsHtml method doesn’t seem to pick them up though. We can make it do so (and cause all of the above to operate properly) using a little bit of reflection. I’m bound to say that using reflection here isn’t a good thing. Microsoft could easily change the object model code to make it break- and they would be well within their rights to do so. So use with care, trap errors, and understand what won’t work any more if the reflection call fails. That said, we need to call the SetViewRenderQueryParameters method of the list passing in the query string before we call RenderAsHtml.

void SetViewRenderQueryParameters(SPList list, HttpContext context,
    string currentUrl, SPWeb web, string listName, string viewGuid,
    SPView designerView, bool gridview, string instanceID, string selectedID,
    string filterString, string rootFolder, string folderCtId, string paged,
    StringBuilder pagingTokens, int lastFilterIndex)
{
    list.GetType().InvokeMember("SetViewRenderQueryParameters", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.InvokeMethod | BindingFlags.Instance, null,
        list,
        new object[] { context, currentUrl, web, listName, viewGuid,
            designerView, gridview, instanceID, selectedID, filterString,
            rootFolder, folderCtId, paged, pagingTokens, lastFilterIndex });
}

Then in GetViewHtml before we call RenderAsHtml we need to make the call passing in the URL:

try
{
    // This call is used to pass the URL to the underlying list rendering logic to ensure
    // that the appropriate filters and sorts are applied.
    SetViewRenderQueryParameters(list, Context, SPAlternateUrl.ContextUri.OriginalString, web,
        list.ID.ToString("B").ToUpper(), view.ID.ToString("B").ToUpper(),
        view, false, null, null, null, null, null, null, null, 0);
}
catch
{
    // The control will still operate with limited functionality if this call fails.
    // Don't break but let's warn the user.
    _limited = true;
}

I use the _limited flag here to notify the user if the call fails for any reason so that they know what won’t work any more- crude but better than failing completely.

There only really a couple of things still wrong with this implementation:

  • There can only be one instance of the web part per page.
  • The main toolbar (with the new button etc.) isn’t shown.

As it happens I can live with both of these. The “one per page” problem is caused because the javascript emitted by RenderAsHtml is matched to an “Instance ID” which must match the number on the end of the GroupByWebPartID hidden field which contains the ID of the web part so that the call back can be initiated. This is set internally but interestingly calling the RenderAsHtml method multiple times causes the emitted script to have a higher instance ID each time starting at 1, so something could possibly be done with that.

The complete web part code follows.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Web;
using System.Web.UI;
using System.Web.UI.HtmlControls;
using System.Web.UI.WebControls;
using System.Xml.Serialization;
using System.Text;
using System.Reflection;

using Microsoft.SharePoint;
using Microsoft.SharePoint.WebPartPages;
using Microsoft.SharePoint.WebControls;
using Microsoft.SharePoint.Utilities;
using Microsoft.SharePoint.Administration;

namespace WebParts
{
    [ToolboxData("<{0}:ListViewer runat=server></{0}:ListViewer>"), XmlRoot(Namespace = "ListViewer")]
    public class ListViewer : WebPart, ICallbackEventHandler
    {
        string _groupToExpand = null;
        TextBox _txtQuery;
        int _instanceID;
        bool _limited = false;

        /// <summary>
        /// The url to the web the list is contained within.
        /// </summary>
        [Browsable(true), Category("Options"), FriendlyName("The url to the web the list is contained within."), WebPartStorage(Storage.Shared), DefaultValue("")]
        public string WebURL
        {
            get;
            set;
        }

        /// <summary>
        /// The name of the list the data is contained within.
        /// </summary>
        [Browsable(true), Category("Options"), FriendlyName("The name of the list the data is contained within."), WebPartStorage(Storage.Shared), DefaultValue("")]
        public string ListName
        {
            get;
            set;
        }

        /// <summary>
        /// The name of the view to render. Blank for the default view.
        /// </summary>
        [Browsable(true), Category("Options"), FriendlyName("The name of the view to use. Blank for the default view."), WebPartStorage(Storage.Shared), DefaultValue("")]
        public string ViewName
        {
            get;
            set;
        }

        /// <summary>
        /// Called by the ASP.NET page framework to notify server controls that use composition-based implementation to create any child controls they contain in preparation for posting back or rendering.
        /// </summary>
        protected override void CreateChildControls()
        {
            Panel container;
            Label caption;
            Button btnSearch;
            Button btnClear;

            base.CreateChildControls();

            if (String.IsNullOrEmpty(ListName) || _instanceID > 1) return;

            container = new Panel();
            container.Width = new Unit(100, UnitType.Percentage);
            Controls.Add(container);
            container.Style.Add("margin", "5px");
            container.Style.Add("font-size", "small");

            caption = new Label();
            caption.Text = "Search";
            caption.Style.Add("font-size", "9pt");
            caption.Style.Add("font-weight", "bold");
            container.Controls.Add(caption);

            _txtQuery = new TextBox();
            _txtQuery.Style.Add("margin-left", "5px");
            container.Controls.Add(_txtQuery);

            btnSearch = new Button();
            btnSearch.ID = "SearchButton";
            btnSearch.Text = "Search";
            btnSearch.Style.Add("margin-left", "5px");
            container.Controls.Add(btnSearch);
            container.DefaultButton = "SearchButton";

            btnClear = new Button();
            btnClear.Text = "Clear";
            btnClear.Style.Add("margin-left", "5px");
            btnClear.Click += new EventHandler(btnClear_Click);
            container.Controls.Add(btnClear);
        }

        /// <summary>
        /// Handles the Click event of the btnClear 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>
        void btnClear_Click(object sender, EventArgs e)
        {
            _txtQuery.Text = String.Empty;
        }

        /// <summary>
        /// Registers the window.onload script to load the cookies or groups.
        /// </summary>
        void RegisterOnload()
        {
            string name = "ExpGroupOnPageLoad";
            string key = "onLoadCall" + name;

            if (!this.Page.ClientScript.IsClientScriptBlockRegistered(this.GetType(), key))
            {
                string script = string.Concat(new object[] { "\r\nif (typeof(_spBodyOnLoadFunctionNames) != \"undefined\") {\r\nif (_spBodyOnLoadFunctionNames != null) {\r\n_spBodyOnLoadFunctionNames.push(", '"', name, '"', ");\r\n}\r\n}" });
                Page.ClientScript.RegisterClientScriptBlock(this.GetType(), key, script, true);
            }
        }

        /// <summary>
        /// Raises the <see cref="E:System.Web.UI.Control.Load"/> event.
        /// </summary>
        /// <param name="e">The <see cref="T:System.EventArgs"/> object that contains the event data.</param>
        protected override void OnLoad(EventArgs e)
        {
            if (this.Page != null)
            {
                _instanceID = GetInstanceID();

                if (!this.Page.IsCallback)
                {
                    string str = this.Page.ClientScript.GetCallbackEventReference(this, "globalArg_" + this.ID, "ExpGroupReceiveData", "globalContext_" + this.ID, "ExpGroupOnError", true);
                    string script = "\r\nvar globalArg_" + this.ID + ";\r\nvar globalContext_" + this.ID + ";\r\nfunction ExpGroupCallServer" + this.ID + "(arg, context)\r\n{\r\n    globalArg_" + this.ID + " = arg;\r\n    globalContext_" + this.ID + " = context;\r\n    setTimeout(\"" + str + "\", 0);\r\n}";
                    Page.ClientScript.RegisterClientScriptBlock(this.GetType(), "ExpGroupCallServer" + this.ID, script, true);
                    Page.ClientScript.RegisterClientScriptBlock(this.GetType(), "ExpGroupOnError", GetCallbackErrorScript(), true);
                    Page.ClientScript.RegisterHiddenField("GroupByColFlag", "");
                    RegisterOnload();
                    base.OnLoad(e);
                }
            }
        }

        /// <summary>
        /// Gets the text of the callback error script.
        /// </summary>
        /// <returns>The error script.</returns>
        string GetCallbackErrorScript()
        {
            return ("\r\nfunction ExpGroupOnError(message, context) {\r\nalert('There was an error during the callback.');\r\n}");
        }

        /// <summary>
        /// Renders the web part.
        /// </summary>
        /// <param name="output">The output.</param>
        protected override void RenderWebPart(System.Web.UI.HtmlTextWriter output)
        {
            base.RenderWebPart(output);

            if (_instanceID > 1)
            {
                output.Write("<p style='color:#cc0000'>Sorry. Only one instance of this web part can exist per page.</p>");
            }
            else
            {
                output.Write("<input type=\"hidden\" id=\"GroupByWebPartID" + _instanceID.ToString() + "\" webPartID=\"" + ID + "\"/>");
                output.Write(GetViewHtml());

                if (_limited)
                {
                    output.Write("<p style='color:#cc0000'>The list viewer is currently operating with limited functionality.</p>");
                }
            }
        }

        /// <summary>
        /// Gets the instance ID of the web part.
        /// </summary>
        /// <returns>The instance ID.</returns>
        int GetInstanceID()
        {
            int res = 0;

            foreach (WebPart wp in WebPartManager.WebParts)
            {
                if (wp is ListViewer)
                {
                    res++;
                }

                if (wp == this)
                    break;
            }

            return res;
        }

        void SetViewRenderQueryParameters(SPList list, HttpContext context,
            string currentUrl, SPWeb web, string listName, string viewGuid,
            SPView designerView, bool gridview, string instanceID, string selectedID,
            string filterString, string rootFolder, string folderCtId, string paged,
            StringBuilder pagingTokens, int lastFilterIndex)
        {
            list.GetType().InvokeMember("SetViewRenderQueryParameters", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.InvokeMethod | BindingFlags.Instance, null,
                list,
                new object[] { context, currentUrl, web, listName, viewGuid,
                    designerView, gridview, instanceID, selectedID, filterString,
                    rootFolder, folderCtId, paged, pagingTokens, lastFilterIndex });
        }

        /// <summary>
        /// Gets the HTML of the view.
        /// </summary>
        /// <returns>The HTML view.</returns>
        string GetViewHtml()
        {
            CAMLHelper helper;
            SPWeb web = null;
            SPList list = null;
            SPView view = null;

            if (String.IsNullOrEmpty(ListName))
            {
                return "Please configure the list source.";
            }

            try
            {
                if (String.IsNullOrEmpty(WebURL))
                {
                    web = SPContext.Current.Web;
                    list = web.Lists[ListName];
                }
                else
                {
                    using (web = SPContext.Current.Site.OpenWeb(WebURL))
                    {
                        list = web.Lists[ListName];
                    }
                }
            }
            catch (Exception ex)
            {
                return "Error accessing the specified list: " + ex.Message;
            }

            if (list != null)
            {
                if (String.IsNullOrEmpty(ViewName))
                {
                    view = list.DefaultView;
                }
                else
                {
                    view = list.Views[ViewName];
                }

                try
                {
                    helper = new CAMLHelper(view.Query);

                    if (!String.IsNullOrEmpty(_txtQuery.Text))
                    {
                        CAMLHelper searchHelper = new CAMLHelper(String.Empty);
                        string searchQuery;

                        foreach (SPField field in list.Fields)
                        {
                            if (field.Type == SPFieldType.Text)
                            {
                                searchHelper.InjectQuery("<Contains><FieldRef Name=\"" + field.InternalName + "\"/><Value Type=\"Text\">" + _txtQuery.Text + "</Value></Contains>", true);
                            }
                        }

                        searchQuery = searchHelper.Caml;

                        if (!String.IsNullOrEmpty(searchQuery))
                        {
                            helper.InjectQuery(searchQuery, false);
                        }
                    }

                    if (!String.IsNullOrEmpty(_groupToExpand))
                    {

                        string[] groupFields;
                        string[] groupValues;
                        int i = 0;

                        // Filter the results to match the groups to minimise the HTML we return.
                        groupFields = helper.ExpandGrouping();
                        groupValues = helper.ParseChoiceValue(_groupToExpand);

                        foreach (string g in groupFields)
                        {
                            StringBuilder sb = new StringBuilder();
                            SPField f = list.Fields.GetFieldByInternalName(g);
                            sb.Append("<Eq><FieldRef Name=\"");
                            sb.Append(g);
                            sb.Append("\"/><Value Type=\"");
                            sb.Append(f.TypeAsString);
                            sb.Append("\">");

                            if (f.Type == SPFieldType.DateTime)
                            {
                                sb.Append(SPUtility.CreateISO8601DateTimeFromSystemDateTime(DateTime.Parse(groupValues[i++])));
                            }
                            else
                            {
                                sb.Append(groupValues[i++]);
                            }

                            sb.Append("</Value></Eq>");

                            helper.InjectQuery(sb.ToString(), false);
                        }
                    }

                    string html = null;

                    try
                    {
                        // This call is used to pass the URL to the underlying list rendering logic to ensure
                        // that the appropriate filters and sorts are applied.
                        SetViewRenderQueryParameters(list, Context, SPAlternateUrl.ContextUri.OriginalString, web,
                            list.ID.ToString("B").ToUpper(), view.ID.ToString("B").ToUpper(),
                            view, false, null, null, null, null, null, null, null, 0);
                    }
                    catch
                    {
                        // The control will still operate with limited functionality if this call fails.
                        // Don't break but let's warn the user.
                        _limited = true;
                    }

                    if (!String.IsNullOrEmpty(_groupToExpand))
                    {
                        int startPos;
                        int endPos;

                        // Use the list RenderAsHtml to get the expanded portion of the HTML.
                        // Note that the view RenderAsHtml does not allow the group to be expanded.
                        SPQuery q = new SPQuery(view);
                        q.Query = helper.Caml;
                        html = list.RenderAsHtml(q);

                        // Extract the html for the expanded section- this is needed by the callback.
                        startPos = html.IndexOf("<TBODY id=\"tbod");

                        if (startPos >= 0)
                        {
                            startPos = html.IndexOf("<TR", startPos);
                            html = html.Substring(startPos);
                        }

                        endPos = html.LastIndexOf("</TBODY><TBODY id=\"foot");

                        if (endPos >= 0)
                        {
                            html = html.Substring(0, endPos);
                            html += "<TR><TD colspan=50></TD></TR>";
                        }
                    }
                    else
                    {
                        int insertPos;

                        // Use the view RenderAsHtml to ensure that totals, footers, etc are rendered.
                        view.Query = helper.Caml;
                        html = view.RenderAsHtml(true, true, SPAlternateUrl.ContextUri.AbsolutePath);

                        // Add in the GroupByCol element- this is needed to allow the view to pull
                        // groups from the cookies.
                        insertPos = html.LastIndexOf("<TBODY");

                        if (insertPos >= 0)
                        {
                            html = html.Insert(insertPos, "<tbody id=\"GroupByCol" + ID + "\"/>");
                        }
                    }

                    return html;
                }
                catch (Exception ex)
                {
                    return "There was an error rendering the list: " + ex.Message;
                }
            }
            else
            {
                return String.Empty;
            }
        }

        #region ICallbackEventHandler Members

        /// <summary>
        /// Returns the results of a callback event that targets a control.
        /// </summary>
        /// <returns>The result of the callback.</returns>
        public string GetCallbackResult()
        {
            // We need to return the HTML for the group we need to expand.
            return GetViewHtml();
        }

        /// <summary>
        /// Processes a callback event that targets a control.
        /// </summary>
        /// <param name="eventArgument">A string that represents an event argument to pass to the event handler.</param>
        public void RaiseCallbackEvent(string eventArgument)
        {
            // Grouping parameters separated as with choice fields
            _groupToExpand = Page.Server.UrlDecode(eventArgument);
        }

        #endregion
    }
}

There Are 9 Responses So Far. »

  1. Nice detailed article. Haven’t tried it yet but will do it soon.

  2. Great Article! It is exactly what I am looking for. But is it possible to modify the view so I can edit the webpart in Sharepoint designer? Maybe I didn’t deploy well but I can not convert it to XSL sheet and do any other customization.
    Could you please give me one more tip( sorry I am not very familiar with webpart project yet): is it possible to build a connection of this webpart to the other webpart. My target is to pass of the item id of the search results to filter the other webpart with full string match.
    Many thanks,
    jennie

  3. Hi Jennie,

    Unfortunately you can’t modify the view in Sharepoint designer. When you do that the custom logic would be lost as the control would be converted to a Data View web part.

    You can however get the web part to work with connections. To do this you need to create a property on the webpart (e.g. ItemId) and then add the ConnectionConsumer attribute.

    There is a good example of this here http://blogs.msdn.com/jerrydixon/archive/2007/08/31/web-part-connections-in-wss-3-0.aspx.

    Once you have the item id you can insert it into the CAML query and you should be set.

    Hope this helps.

    Darren

  4. [...] Nach einiger Recherche im Netz fand ich verschiedene Seiten, die ctxId=1 und ctx1 gegen eine Zufallszahl ersetzen (um mehrere Instanzen dieses Webparts auf einer Seite zu ermöglichen -> http://www.tech-archive.net) und eine Seite wo das Interface ICallBackHandler implementiert wird um die Gruppierung zu ermöglichen(http://darrenjohnstone.net). [...]

  5. Thanks a lot, Darren. Your information really helps. The connection of your web part works well!:)

  6. Hi,
    Nice post…
    I want to get group by records from the list in same example….
    but by using RenderAsHtml i m not able to get group by result for more than 2 levels .
    I want to get the results for more than 2 group by level…so how can i achieve this…because in Sharepoint itself we can define 2 fields for group by but i want to make it upto “n-level”.
    Can u help me for the same.
    - Kirti

  7. Hi Kirti,

    Sorry. Only two levels of grouping are supported in views. However, you might find the following article useful http://guru-web.blogspot.com/2007/01/grouping-documents-by-more-than-two.html.

    Cheers,
    Darren

  8. Darren,

    Fantastic article! A real life-saver for me.

    I was working on a webpart using a Microsoft.SharePoint.WebControls.ListView control to render a list (without the advanced querying you use) and got stuck on the grouping (groups didn’t expand, they only showed the ‘loading…’ text) when I found this post. I would Also have ran into issue of filtering & sorting, so this saved me a bunch of time.

    I got my webpart working now, except for 1 tiny issue: The expanded groups that are (apparently) stored in a coockie do not get reset when I reload the page i.e. if I navigate away from the page and back, the groups that ere expanded when I left the page are expanded again.

    Do you have any clue on how to ‘reset’ the grouping?

  9. Darren,

    I succeeded in rendering a (fully functional) toolbar by using this piece of code (ViewToolBar is in Microsoft.SharePoint.WebControls)

    ViewToolBar toolbar = new ViewToolBar();

    SPContext renderContext = SPContext.GetContext(this.Context, view.ID, list.ID, web);

    toolbar.RenderContext = renderContext;

    Controls.Add(toolbar);

    This will render a toolbar with the ‘views’ dropdown on the right hand side. Changing the view here will navigate to the default sharepoint page for the view. Therefore I prefer to hide the dropdown in the toolbar (call after adding the ViewToolBar to the controls collection):

    ToolBar innerToolbar = (ToolBar)toolbar.Controls[0].Controls[1];
    innerToolbar.RightButtons.Controls.Clear();

Post a Response