Filter Sitefinity content-view based controls by multiple months and years

The following blog post targets all controls in Sitefinity that are based on content view as News, Events, Blog posts etc. Let's take blog posts as example.

Sitefinity provides an amazingly quick and easy-to-use way to create blog posts via the Blogs content module. Naturally once you have more blog posts, you would want to display archive so that users could find older posts. Out-of-the-box there is an Archive widget that you could use to filter blog posts. You can use it to pick up the blog posts that are published on a specific day, month and year or just year.

In some cases you may want to display blog posts filtered by multiple dates of publication. Read below to understand how.

TIP: You can find the full code in GitHub here

Solution plan or preparing the weapons

You must customize the Archive control so that it allows selection of multiple publication dates. One possible scenario is to customize the template of the control and add checkbox list. Then, you must be able to check several checkboxes and apply the filter to the blog posts control.
How the Archive control s filtering the Blog posts view by default? - by appending the filter to the url (depending on the UrlEvaluationMode property value either QueryString or QueryPath).

For example:

  • query string:

http://yourhost/blogs?year=2015&month=02

  • query path

http://yourhost/blogs/2015/02

You must write your own query string builder to the url depending on the checked checkboxes in the archive control. The usage of query strings allows the url to be shared. Desired result:

http://yourhost/blogs?year=2015&month=02&year=2014&month=01&year=2013&month=03

Warning: The bigger amount of checkboxes you check, the longer the url becomes with the query string. What is the limit?:
Microsoft Internet Explorer (Browser) Microsoft states that the maximum length of a URL in Internet Explorer is 2,083 characters, with no more than 2,048 characters in the path portion of the URL. In my tests, attempts to use URLs longer than this produced a clear error message in Internet Explorer.
Firefox (Browser) After 65,536 characters, the location bar no longer displays the URL in Windows Firefox 1.5.x. However, longer URLs will work. I stopped testing after 100,000 characters.
Safari (Browser) At least 80,000 characters will work. I stopped testing after 80,000 characters.
Opera (Browser) At least 190,000 characters will work. I stopped testing after 190,000 characters. Opera 9 for Windows continued to display a fully editable, copyable and pasteable URL in the location bar even at 190,000 characters.


Separate and conquer:

Now that you have a solution plan, it's time for action.
Brace yourselves - code is coming!

Customize Archive widget template

TIP: The default template of the Archive control can be found in Sitefinity's github repository SitefinityResources.

Download the source code on top to see the customized template.

Implement custom url builder

Once you try to append more query string parameters to the url of the blog posts, you will receive an exception as Sitefinity does not allow more than one month or year query string in the url. The solution will be to implement your own logic to filter blog posts by multiple month and year pairs.

TIP: You must specify the UrlEvaluationMode of the Blog posts control to QueryString from the advanced settings in the designer.

  1. Get the base url of the blogs page

    private string GetBaseUrl()
    {
        var url = this.BaseUrl;
        if (string.IsNullOrEmpty(url))
        {
            var siteMap = SiteMapBase.GetCurrentProvider();
            if (siteMap == null || (siteMap != null && siteMap.CurrentNode == null))
            {
                return string.Empty;
            }
            url = siteMap.CurrentNode.Url;
        }
        if (string.IsNullOrEmpty(url))
            throw new ArgumentNullException("BaseUrl property could not be resolved.");
        if (VirtualPathUtility.IsAppRelative(url))
            url = VirtualPathUtility.ToAbsolute(url);
        return url;
    }
    

2.Build custom date evaluator to override the logic from Sitefinity's DateEvaluator.cs to build url with month and year as query strings:

   public class CustomDateEvaluator : DateEvaluator
    {
         public override string BuildUrl(DateTime date, DateBuildOptions options,      UrlEvaluationMode urlEvaluationMode, string urlKeyPrefix)
       {
         var qString = QueryStringBuilder.Current.Reset();

        if (urlEvaluationMode == UrlEvaluationMode.QueryString)
        {
            var yearFullUrlKey = String.Concat(urlKeyPrefix, "year");
            var monthFullUrlKey = String.Concat(urlKeyPrefix, "month");
            var dayFullUrlKey = String.Concat(urlKeyPrefix, "day");

            qString.Add(yearFullUrlKey, date.Year.ToString(), true);
            if (options != DateBuildOptions.Year)
            {
                qString.Add(monthFullUrlKey, date.Month.ToString("00"), true);
                if (options == DateBuildOptions.YearMonthDay)
                {
                    qString.Add(dayFullUrlKey, date.Day.ToString("00"), true);
                }
            }
        }
        return qString.ToString();          
    }
   }

3.Resolve the url by appending query strings based on the checkboxes checked in the custom Archive control:

   protected string ResolveUrl(params DateTime[] archiveDates)
    {
        var url = this.GetBaseUrl();
        var evaluator = new CustomDateEvaluator();
        StringBuilder evaluatedResult = new StringBuilder();
        string urlToEvaluate = null;

        for (int i = 0; i < archiveDates.Length; i++)
        {
            urlToEvaluate = evaluator.BuildUrl(archiveDates[i], this.DateBuildOptions, this.GetUrlEvaluationMode(), this.UrlKeyPrefix);
            if (i > 0)
            {
                urlToEvaluate = urlToEvaluate.Replace('?', '&');
            }
            evaluatedResult.Append(urlToEvaluate);
        }

       return string.Concat(url, evaluatedResult.ToString());
    }

4.Hook to the filter button click event to get the checked checkboxes and Response.Redirect the page to append the query string parameters:

protected void FilterButton_Click(object sender, EventArgs e)
    {
        var repeaterItems = this.ArchiveRepeater.Items;
        var archiveDates = new List<DateTime>();

        //cache is invalidated
        ObjectCache.CheckboxesCache.Clear();

        foreach (RepeaterItem item in repeaterItems)
        {
            CheckBox checkbox = item.FindControl("archiveFilter") as CheckBox;
            if (checkbox.Checked)
            {
                DateTime dateToFilter = DateTime.Now;
                DateTime.TryParse(checkbox.Text, out dateToFilter);
                archiveDates.Add(dateToFilter);

                //need to preserve the checked state of checkboxes after page navigation
                //for that purpose an object cache is substituted
                ObjectCache.CheckboxesCache.Add(checkbox.Text);
            }
        }
        if (archiveDates.Count > 0)
        {
            var urlToNavigate = this.ResolveUrl(archiveDates.ToArray());
            SystemManager.CurrentHttpContext.Response.Redirect(urlToNavigate);
        }
        else
        {
            SystemManager.CurrentHttpContext.Response.Redirect(this.GetBaseUrl());
        }
    }

5.Preserve checked checkboxes after page is redirected

This can be achieved my many ways - storing the checked values in session, cache etc. In this blog posts you will create new class ObjectCache with single property CheckboxesCache that will store the checked checkboxes values:

public static class ObjectCache
{
    public static IList<string> CheckboxesCache = new List<string>();
}

This symbolized object cache will be invalidated in the start of the filter button click event handler and substituted if any checkbox is checked in the same event handler.

Now you can handle the PreRender event of the Archive repeater and check all checkboxes which text is found in the ObjectCache:

protected void ArchiveRepeater_PreRender(object sender, EventArgs e)
    {
        var repeater = sender as Repeater;
        foreach (RepeaterItem item in repeater.Items)
        {
            CheckBox checkbox = item.FindControl("archiveFilter") as CheckBox;
            if (ObjectCache.CheckboxesCache.Contains(checkbox.Text))
            {
                checkbox.Checked = true;
            }
        }
    }

Read the built query string and filter blog posts list

Blog posts list view is built by the MasterPostsView class. You must inherit the class and override several methods to apply the custom filter.

Override the InitializeListView method to filter the blog posts query:

 protected override void InitializeListView(IQueryable<BlogPost> query, int? totalCount)
    {
        string sYear = string.Empty;
        string sMonth = string.Empty;
        string sDay = string.Empty;

        sYear = System.Web.HttpContext.Current.Request.QueryString["year"];
        sMonth = System.Web.HttpContext.Current.Request.QueryString["month"];

        if (!string.IsNullOrEmpty(sYear) || !string.IsNullOrEmpty(sMonth))
        {
            query = this.FilterByCustomDateTimeCriteria(query, sYear, sMonth, ref totalCount);
        }

        base.InitializeListView(query, totalCount);
    }

In this method, you read the query string parameters and perform filtering by custom filter criteria. The code below shows how the filter expression is built:

private IQueryable<BlogPost> FilterByCustomDateTimeCriteria(IQueryable<BlogPost> query, string sYear, string sMonth, ref int? totalCount)
    {
        var values = new List<object>();
        var filterValues = new List<object>();

        var years = sYear.Split(',');
        var months = sMonth.Split(',');

        //if the url does not contain a pair of month and year the query will not be filtered
        if (years.Count() != months.Count())
        {
            return query;
        }

        string filter = null;

        var j = 0;
        var k = 1;
        for (int i = 0; i < years.Count(); i++)
        {
            if (i > 0)
            {
                filter += "||";
                ++k;
                j = k;
                ++k;
            }

            filter += this.SetDates(years[i], months[i], null, out values, "PublicationDate", j, k);
            filterValues.AddRange(values);
        }

        if (filter == null)
        {
            return query;
        }

        query = query.Where(filter, filterValues.ToArray());

        totalCount = query.Count();
        return query;
    }

The code above splits the query string values of the year and month query strings. If by some reason the query string pairs month and year are broken - the query will return all blog posts (same as when no query string is applied). Now that you have a specific month and year pair - you must get the last day of the month to construct expression to find all blog posts between the first and the last day of the month.

For example:
(PublicationDate >= 1/1/2015 AND PublicationDate <= 31/1/2015) OR (PublicationDate >= 1/2/2014 AND PublicationDate <= 28/2/2014)

The following function is used to generate the filter expression:

    private string SetDates(string sYear, string sMonth, string sDay, out System.Collections.Generic.List<object> values, string propertyName, int index1, int index2)
    {
        System.DateTime time;
        System.DateTime time2;
        int num = int.Parse(sYear);
        if (string.IsNullOrEmpty(sMonth))
        {
            time = new System.DateTime(num, 1, 1, 0, 0, 0);
            time2 = new System.DateTime(num, 12, System.DateTime.DaysInMonth(num, 12), 0x17, 0x3b, 0x3b);
        }
        else if (string.IsNullOrEmpty(sDay))
        {
            int num2 = int.Parse(sMonth);
            time = new System.DateTime(num, num2, 1, 0, 0, 0);
            time2 = new System.DateTime(num, num2, System.DateTime.DaysInMonth(num, num2), 0x17, 0x3b, 0x3b);
        }
        else
        {
            int num3 = int.Parse(sMonth);
            int num4 = int.Parse(sDay);
            time = new System.DateTime(num, num3, num4, 0, 0, 0);
            time2 = new System.DateTime(num, num3, num4, 0x17, 0x3b, 0x3b);
        }
        values = new System.Collections.Generic.List<object>();

        values.Add(time);
        values.Add(time2);
        return new System.Text.StringBuilder().Append("(").Append(propertyName).Append(" >= @" + index1 + " && ").Append(propertyName).Append(" <= @" + index2 + ")").ToString();
    }

As a final step you need to register the custom controls that you created. See the next step.

Register custom controls and configure them

1.Register CustomArchiveControl

Follow the steps in Sitefinity's documentation to register the custom archive control in the Page Toolbox. Once you drag the control on the page make sure to set the control's properties:

ContentType: Telerik.Sitefinity.Blogs.Model.BlogPost
DateBuiltOptions: YearMonth

2.Register custom MasterPostsView

Go to Administration->Settings->Advanced->ContentView->Controls->BlogPostsFrontend -> Views-> MasterBlogPostsFrontend and replace the ViewType property with the CLR type of your custom view:

For example: SFBlogs.CustomControls.Blogs.Views.CustomMasterPostsView

Save the changes and from this point on all Blog posts widgets on your site will use the custom master view instead of the default one.

Conclusion

You can now filter your blog posts by multiple date values based on when the posts are published. You can also apply filters to other fields and not only PublicationDate. Even more you can apply the same logic for other content types as News, Events etc. Just use the code from the post as a basis.

Veronica Milcheva

About Veronica Milcheva

I am a passionate Sitefinity blogger, developer and consultant. In my spare time I enjoy running and listening to music. My personal quote: There's no tough problem, just not enough coffee :)

View Comments

comments powered by Disqus