Posted on:
Categories: CodePlex;SharePoint
Description:

With the introduction of Friendly URLs in SharePoint 2013, publishing pages and catalog items can now be bound to SEO-friendly URLs. However, what if you want to expose other types of content to the web using friendly URLs? In this blog post, I will show how you can assign any blog post list item within SharePoint a Friendly URL via Search & a Content Search Web Part. However, with a simple switching of ContentTypeIds, you can use the same code to assign friendly URLs to any type of list item - custom or OOB.

Note: You will still need to create a publishing page bound to a Taxonomy Navigation Term (where you will insert a Content Search Web Part), and this Navigation Term will be the parent of all list item friendly URLs. If this seems confusing, please read through the below steps - hopefully it will make more sense.

Note 2: I will skip some of the more trivial steps involved in the process, so to see everything in action, or to skip the long explanation and skip to the source code [!], please head over to the CodePlex Page for this project: friendlyurlsforlistitemssp2013.codeplex.com.

Step 1: the Site Columns & Content Type

In order to bind the list item to a friendly URL, we will need to add 2 Site Columns to the List which will contain the associated Taxonomy Term ID, and the corresponding Friendly URL for each list item. I've created the 2 site columns, and packaged them up into a content type:

<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
  <!-- Parent ContentType: Post (0x0110) -->
  <ContentType ID="0x011000E831A1F5EC3A413D853C724DCDD94D4F" Name="Blog Post with Friendly Url" Group="Friendly URLs For list Items Content Types" Description="This content type inherits from the OOB Blog Post Content type, but includes required site columns " Inherits="TRUE" Version="0">
    <FieldRefs>
      <FieldRef ID="{7887d76f-8adf-4340-ab33-f16a586fe2fd}" DisplayName="Navigation Term Id" Required="FALSE" Name="ListItemNavigationTermId" />
      <FieldRef ID="{E96AD669-3CE3-4F8E-9A5F-A3C352CA3443}" DisplayName="Mapped Friendly URL" Required="FALSE" Name="ListItemMappedFriendlyURL" Format="Hyperlink" />
    </FieldRefs>
  </ContentType>
</Elements>

This will be the content type (see my CodePlex link for full site column definitions, along with the full visual studio solution) that I will add to my list where I would like to use Friendly URLs.

Step 2: Bind an Event Receiver to Create the Navigation Term/Friendly URL

Now that we have a custom content type and site columns, we need to create an ItemAdded and ItemUpdated event receiver - which we will bind to the content type - which will create and configure a Friendly URL for the list items. However, since we will be binding the event receiver to a content type (not a list), don't use Visual Studios "Event Receiver" template. Instead, simply add a new C# class and implement the following code:

using System;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Publishing.Navigation;
using Microsoft.SharePoint.Taxonomy;

namespace FriendlyURLsForListItemsSP2013
{
    public class BlogPostAddedOrUpdatedEventReceiver : SPItemEventReceiver
    {
        private bool eventFiringEnabledStatus;

        public override void ItemAdded(SPItemEventProperties properties)
        {
            base.ItemAdded(properties);
            UpdateFriendlyUrl(properties);
        }

        private void UpdateFriendlyUrl(SPItemEventProperties properties)
        {
            try
            {
                if (properties.ListItem == null || properties.Web == null) return;

                EventFiringEnabled = false;

                var addedItem = properties.ListItem;
                var webUrl = properties.Web.Url;

                // first, read relevant existing metadata from current item
                var enteredTitle = addedItem["Title"] as string;
                var existingFriendlyUrl = "";
                if (addedItem["ListItemMappedFriendlyURL"] != null &&
                    !String.IsNullOrEmpty(addedItem["ListItemMappedFriendlyURL"].ToString()))
                {
                    var existingFriendlyUrlFldVl = new SPFieldUrlValue(addedItem["ListItemMappedFriendlyURL"].ToString());
                    if (!String.IsNullOrEmpty(existingFriendlyUrlFldVl.Url))
                        existingFriendlyUrl = existingFriendlyUrlFldVl.Url;
                }

                
                var newFriendlyUrl = String.Empty;
                var newFriendlyUrlTermId = String.Empty;
                var createTermResult = false;
                SPSecurity.RunWithElevatedPrivileges(delegate
                {
                    using (var site = new SPSite(webUrl))
                    {
                        using (var web = site.OpenWeb())
                        {
                            // TODO: when implementing this solution, replace this URL with the appropriate value for
                            //          the friendly URL of the navigation term that will be the parent of your newly created terms
                            var parentFriendlyTermsUrl = web.Url + "/blog";

                            //now create a taxonomy term in the navigation term set with the title in the url, and return relevant values
                            createTermResult = CreateTermForBlog(enteredTitle, web, existingFriendlyUrl, parentFriendlyTermsUrl, out newFriendlyUrl, out newFriendlyUrlTermId);
                        }
                    }
                });

                // if we failed to generate a valid friendly URL, don't proceed.
                if (String.IsNullOrEmpty(newFriendlyUrl) || !createTermResult) return;

                // set the friendly url & navigation taxonomy term id field
                var newFriendlyUrlFieldVal = new SPFieldUrlValue { Description = newFriendlyUrl, Url = newFriendlyUrl };
                addedItem["ListItemMappedFriendlyURL"] = newFriendlyUrlFieldVal;

                // set the friendly url taxonomy term ID if we have it.
                if (!String.IsNullOrEmpty(newFriendlyUrlTermId)) 
                    addedItem["ListItemNavigationTermId"] = "#0" + newFriendlyUrlTermId;
                // the #0 is added because the {Term.id} property in the web part query parameters list has that at the beginning for some reason
                //   http://technet.microsoft.com/en-us/library/jj683123.aspx

                addedItem.Update();
            }
            catch (Exception outerEx)
            {
                // TODO: log exception.
                throw;
            }
            finally
            {
                EventFiringEnabled = true;
            }
        }

        private bool CreateTermForBlog(String itemTitle, SPWeb web, String existingFriendlyUrl, String parentTermFriendlyUrl, out String newFriendlyUrl, out String newFriendlyUrlTermId)
        {
            try
            {
                newFriendlyUrl = String.Empty;
                newFriendlyUrlTermId = String.Empty;

                if (web == null) return false;

                // grab the managed navigation term set configured for the current web 
                //      (if it's not properly configured, this will fail)
                var globalNavTaxTermSet = TaxonomyNavigation.GetTermSetForWeb(web, StandardNavigationProviderNames.GlobalNavigationTaxonomyProvider, true);

                // NOTE: this method is NOT efficient, but when I use globalnavTaxTermSet with the FindTermForUrl() method, 
                //          it always fails due to the fact that somehow the term set has no child nodes in it...
                //      so for now we do this roundabout method of creating a new context and re-retrieving the "editable" term set.
                var taxSession = new TaxonomySession(web.Site, true);
                var editableNavTs = globalNavTaxTermSet.GetAsEditable(taxSession);
                var taxTsFromNavTs = editableNavTs.GetTaxonomyTermSet();
                var resolvedNavTs = NavigationTermSet.GetAsResolvedByWeb(taxTsFromNavTs, web, StandardNavigationProviderNames.GlobalNavigationTaxonomyProvider);

                // retrieve the parent navigation term
                var parentTerm = resolvedNavTs.FindTermForUrl(parentTermFriendlyUrl);
                if (parentTerm == null)
                    return false;

                // either create or modify the navigation term underneath the parent
                NavigationTerm newBlogTerm = null;
                if (!String.IsNullOrEmpty(existingFriendlyUrl))
                {
                    // term has already been created, find it!
                    newBlogTerm = globalNavTaxTermSet.FindTermForUrl(existingFriendlyUrl);
                }

                if (newBlogTerm == null)
                {
                    // term doesn't exist, create a new one  (physical url inherited from /blog parent term)
                    newBlogTerm = parentTerm.CreateTerm(itemTitle, NavigationLinkType.FriendlyUrl);
                    parentTerm.GetTaxonomyTerm().TermStore.CommitAll();
                    newFriendlyUrl = newBlogTerm.GetWebRelativeFriendlyUrl();
                    newFriendlyUrlTermId = newBlogTerm.Id.ToString();
                }
                else
                {
                    // term already exists, simply modify the title
                    var newBlogNavEditableTerm = newBlogTerm.GetAsEditable(taxSession);
                    newBlogNavEditableTerm.FriendlyUrlSegment.Value = itemTitle.Replace(' ', '-');
                    var newBlogTaxTerm = newBlogNavEditableTerm.GetTaxonomyTerm();
                    newBlogTaxTerm.Name = itemTitle;
                    newBlogTaxTerm.TermStore.CommitAll();
                }

                return true;
            }
            catch (Exception ex)
            {
                // TODO: log exception

                newFriendlyUrl = "";
                newFriendlyUrlTermId = "";
                return false;
            }
        }

        public override void ItemUpdated(SPItemEventProperties properties)
        {
            base.ItemUpdated(properties);
            UpdateFriendlyUrl(properties);
        }

        private void DisableItemEventsScope()
        {
            eventFiringEnabledStatus = EventFiringEnabled;
            EventFiringEnabled = false;
        }

        private void EnableItemEventsScope()
        {
            eventFiringEnabledStatus = EventFiringEnabled;
            EventFiringEnabled = true;
        }
    }
}

Step 3: Bind the Event Receiver to the Content Type

First, add a feature Event Receiver to your existing feature, which should already contain both the Site Columns and Content Type. Add the following code to associate our ItemAdded/ItemUpdated Event Receiver class with our custom Content Type:

public override void FeatureActivated(SPFeatureReceiverProperties properties)
{
 if (!(properties.Feature.Parent is SPSite)) return;
 var site = properties.Feature.Parent as SPSite;

 var assemblyName = System.Reflection.Assembly.GetExecutingAssembly().FullName;
 
 // TODO: CHANGE THIS NAMESPACE/CLASS TO SUIT YOUR OWN EVENT RECEIVER CLASS
 const string className = "FriendlyURLsForListItemsSP2013.BlogPostAddedOrUpdatedEventReceiver";
 
 // TODO: CHANGE THIS CONTENT TYPE ID TO SUIT YOUR OWN CONTENT TYPE
 var contentType = site.RootWeb.ContentTypes[new SPContentTypeId("0x011000E831A1F5EC3A413D853C724DCDD94D4F")];
 if (contentType == null) return;

 // add the event receivers to the content type, then update.
 AddEventReceiverToContentType(className, contentType, assemblyName, SPEventReceiverType.ItemAdded,
          SPEventReceiverSynchronization.Asynchronous);
 AddEventReceiverToContentType(className, contentType, assemblyName, SPEventReceiverType.ItemUpdated,
          SPEventReceiverSynchronization.Asynchronous);
 contentType.Update();
}

protected static void AddEventReceiverToContentType(string className,
             SPContentType contentType,
             string assemblyName,
             SPEventReceiverType eventReceiverType,
             SPEventReceiverSynchronization eventReceiverSynchronization)
{
 if (className == null) throw new ArgumentNullException("className");
 if (contentType == null) throw new ArgumentNullException("contentType");
 if (assemblyName == null) throw new ArgumentNullException("assemblyName");

 // remove any previous instances of this event receiver (Compare by class name since previous assembly version should be removed)
 if (contentType.EventReceivers.Count > 0)
 {
  for (var i = (contentType.EventReceivers.Count - 1); i >= 0; i--)
  {
   // normally this would be unnecessary, however if there are duplicate event receiver definitions (as there will mostly likely be
   //          if something went wrong), deleting 1 event receivers may actually delete more than 1.
   if ((contentType.EventReceivers.Count - 1) < i) continue;

   // ok, we're at a valid index, check if we've found the current class definition.
   if (contentType.EventReceivers[i].Class.ToLower().Trim() == className.ToLower().Trim())
   {
    contentType.EventReceivers[i].Delete();
   }
  }
  contentType.Update();
 }

 var eventReceiver = contentType.EventReceivers.Add();
 eventReceiver.Synchronization = eventReceiverSynchronization;
 eventReceiver.Type = eventReceiverType;
 eventReceiver.Assembly = assemblyName;
 eventReceiver.Class = className;

 eventReceiver.Update();
}

Note: Be sure to replace the event receiver namespace, class name, and content type ID in the above code!

Step 4: Configure Your Navigation Term Set & Site Collection

You'll want to set up your Site Collection & its associated Navigation Term Set prior to testing it out. In the above code snippits, I used a parent term with a the URL /blog. So make sure you have a Navigation Term directly under the root term with that URL:

Note: the target page for all of this terms children is set to ListItemDisplay.aspx. This is because we will be automatically directing all children terms to that page in Step 7, but for now - just trust me, and create the page ListItemDisplay.aspx with no associated friendly url.

Step 5: Deploy the Solution to your Site!

Deploy your solution to your site, and activate your feature - which should deploy the required site columns, content type, and event receivers. Now, simply create a list using the intended content type (in this case, a Blog Posts list) - and add our custom content type to the list. Now, you should see the 2 custom fields we created appearing in your list.

Now create a new item in the list you just created - and make sure the new item utilizes your custom content type, not Item or any other content type! Once you save the item, your event receiver should kick in, and you should now see an associated Friendly URL and Navigation Term ID in your list item metadata, similar to the following:

Step 6: Search Configuration

Since we're planning on using a display template and exposing our list item content via a Content Search Web Part, we'll need to configure Search to index the correct metadata. Now that we've added a "testing" list item in the previous step, we will need to run a full search crawl so that the appropriate site columns get indexed.

Once the full crawl is complete, go to the Managed Properties page (aka Search Schema in Sharepoint 2013) in the Search Service, and ensure that a Managed Property entitled ListItemNavigationTermIdOWSTEXT exists, and that it's mapped to the crawled property ows_q_TEXT_ListItemNavigationTermId (and if it doesn't, create it). Then, click into the property details and ensure the property is queryable, retrievable, and searchable (and safe for anonymous if you're planning on anonymously exposing your site). If you made any changes, you will likely need to run another full crawl before moving on.

Step 7: Finally! Configure ListItemDisplay.aspx

Lastly, we will need to set up the page which will be loaded when your user navigates to your newly generated friendly URL. Right now, it displays nothing but a blank publishing page…but that's about to change.

On the page, add a Content Search Web Part, and configure the web part to use the desired display template, along with the following query:

ContentTypeId:0x011000E831A1F5EC3A413D853C724DCDD94D4F* ListItemNavigationTermIdOWSTEXT:{Term.id}

Once you saved the page, assuming your list item has been properly indexed by search, now try navigating to your newly generated Friendly URL (in our case, /blog/test). If everything is configured correctly, you should see your list item content! (depending on your display template of choice)

This may not look like much, but if you look carefully, you're looking at an automatically generated Friendly URL displaying non-publishing list item metadata - and the end user won't have to configure anything, or have permissions to the navigation term set!

For more details, and to download the source code, please visit friendlyurlsforlistitemssp2013.codeplex.com