+1-888-365-2779
Try Now
More in this section
Categories
Bloggers
Blogs RSS feed

In the mean time series (part 4): BONUS TRACK - "Products" module - implementing search index

by Ivan Osmak

Feeling bad that we’ve let you wait so long for this series, though we’ve announced it back in the mid summer, I’ve decided to throw in a bonus track. You will not find this post on the roadmap, though it will eventually make it to the manual. In this post I will demonstrate how to implement search index for products module. This will give us an ability to offer the visitors of our site very narrow and specific search.

As always, this post is the part of a sequential series – so before starting to work with it please go to the introductory post and set up the project as it is explained there. You don’t need to go through all the stages of the series, but the first one is required.

NOTE: This project has been compiled with Sitefinity 3.5 SP 1, though it should work just fine on Sitefinity 3.5 fine as well.

“Products module” – implementing search index

Before we get into details, let’s take a moment and take a look at the high level overview of search in Sitefinity. As always, it is based on provider model, which will allow us to reuse great amount of functionality and implement only the parts specific to our products module. There are three things we will need to implement in order to get our search index up and running:

  • Index provider
  • Index settings control
  • Index view control

As you will see, all of these are rather simple to implement – and if you are familiar with the way we [Sitefinity team] implement controls – you’ll be done with this in less than an hour. Take a look at Figure 1 for the logical diagram of Sitefinity search index.


Figure 1: Diagram representing interplay between Sitefinity search service and products specific search classes

Now, we can see from the Figure 1 that Sitefinity needs two UI elements, namely:
  • ProductsIndexSettings control and
  • ProductsIndexView control

And in addition to that,we’ll need to provide the search service with the ProductsIndexProvider class.

So, that we have a better idea what are we talking about, let’s take a look how are the two controls used and how do they look like in Sitefinity. Take a look at Figure 2 to see how will ProductsIndexSettings control be used inside of Sitefinity administration area:


Figure 2: ProductsIndexSettings control

As you can see from the Figure 2, ProductsIndexSettings control is used by the end user in order to set up the index. We, as developers, can make this control any way we want and can demand all kinds of information from the end user that we may need to use later in provider. In this example, I am demanding the user to enter the Product Page url, so that in ProductsIndexProvider I can set the url of the each result item (e.g. user will set this to a page where ProductsView control is present).

Now, let’s move on to ProductsIndexView control. The purpose of this control to display the settings on already created index. Take a look at Figure 3 to see how this control looks like in Sitefinity:

Figure 3: ProductsIndexView control – visualizes the index settings

Admittedly, not the most complicated control, since we have only one setting for this control (Products Page).

The final part of our task will be develop ProductsIndexProvider. This will be much simpler than it seems, since we have based our module on Generic Content module and therefore can use a handy base class that comes with it.

Implementing IIndexingSupportModule interface and securing our module

Every module that will sport its own search index must implement IIndexingSupportModule interface. Since we will use some custom permission checking in this module, we’ll also make module secured (notice how we did not do this in the Permissions part, because Generic Content module controls did everything for us).

Securing the module will start in the products manager, so open the following file in your Telerik.Samples.Products project:

Telerik.Samples.Products.ProductsManager

And start by adding a static constructor to it, like this:

/// <summary>
/// Provides a static constructor of ProductsManager class.
/// </summary>
static ProductsManager()
{
foreach (GenericContentElement element in ConfigHelper.Handler.GenericContentProviders)
contentSettings.Add(element.ProviderName, element);
}

next, add the static SecurityRoots property:

/// <summary>
/// Gets key/value information for provider/global permissions. Preserves information
/// for all providers.
/// </summary>
public static Dictionary<String, ISecured> SecurityRoots
{
get
{
if (securityRoots == null)
{
securityRoots = new Dictionary<String, ISecured>(ContentManager.Providers.Count);
foreach (string name in contentSettings.Keys)
securityRoots.Add(name, new GlobalPermissions(name));
}
return securityRoots;
}
}
private static Dictionary<String, ISecured> securityRoots;

Now, what exactly are the “SecurityRoots”? Well, nothing more than permission objects for providers. In the future, this will make more sense as we start introducing more granular permission (per item and so on), but for now, it’s enough to know that we are basically registering every provider in the module as a permission object that can be secured.

Let’s head to the ProductsModule class, the main class of our module, and change the base class from WebModule to SecuredModule and implement the IIndexingSupportModule interface on it as well. The module class declaration should look like this:

public class ProductsModule : SecuredModule, IIndexingSupportModule

Since we have changed the base class, and inherited from the SecuredModule abstract class, we’ll need to implement SecuredModule members. There are two only, and here is how you can implement them:

public override ISecured SecurityRoot
{
get
{
return SecurityRoots[Configuration.ConfigHelper.Handler.DefaultProvider];
}
}
public override IDictionary<string, ISecured> SecurityRoots
{
get
{
return ProductsManager.SecurityRoots;
}
}

So, what we are doing here is providing the base SecuredModule class with the providers and associated GlobalPermission objects which base class will then use to secure the providers. The plumbing itself is not of our concern at the moment.

We’ve also implemented IIndexingSupportModule interface on the module and the interface mandates one member, so let’s add that one too:

public IServiceClientInfo[] GetSeviceClients()
{
return new IServiceClientInfo[]
{
new IndexingServiceClientInfo("ProductsIndex"
, typeof(ProductsIndexProvider)
, typeof(ProductsIndexSettingsControl).FullName
, typeof(ProductsIndexViewControl).FullName
, "ProductsIndexProvider")
};
}

Now, this function simply returns array of IServiceClientInfo objects implemented by this module. Yes, that means that we can actually have more than one IndexingService per module. We won’t complicate things here, and we’ll only return one IndexingServiceClientInfo object. Since we have not implemented ProducsIndexProvider, ProductsIndexSettingsControl and ProductsIndexViewControl yet, Visual Studio will probably try to warn you about this – but you can safely ignore it for now. We’ll come back to this later on.

Implementing ProductsIndexProvider class

The ProductsIndexProvider class is in charge of providing the search service with the data to index. Since products module is based on Generic Content module, we will reuse the ContentIndexProvider class and just modify few things which are different for our module – like provider name and settings. Add following file to your Telerik.Samples.Products project (at the root):

ProductsIndexProvider.cs

and paste following code into it:

using System;
using System.Collections.Generic;
using Telerik.Cms.Engine;
using System.Globalization;
namespace Telerik.Samples.Products
{
/// <summary>
/// Represents information for news to be indexed.
/// </summary>
public class ProductsIndexProvider : ContentIndexProvider
{
/// <summary>
/// Defines the name of the provider. This name is used to manage providers within Indexing Service.
/// </summary>
public override string Name
{
get { return "ProductsIndex"; }
}
/// <summary>
/// Provides detailed description of the provider
/// </summary>
public override string Description
{
get { return "Provides indexing of products"; }
}
/// <summary>
/// Gets the content item key implicitly. This property should be overridden in the
/// derived classes so that 2 different controls deriving from this one can be used together.
/// </summary>
/// <value>The content item key implicitly.</value>
protected override string ContentItemKeyImpl
{
get
{
return "ProductItem";
}
}
/// <summary>
/// Gets the content provider key implicitly. This property should be overridden in the
/// derived classes so that 2 different controls deriving from this one can be used together.
/// </summary>
/// <value>The content provider key implicitly.</value>
protected override string ContentProviderKeyImpl
{
get
{
return "ProductProv";
}
}
/// <summary>
/// Gets the name of the default news data provider.
/// </summary>
/// <returns>The name of the default news data provider.</returns>
protected override string GetDefaultContentProvider()
{
if (!string.IsNullOrEmpty(productsProvider))
return productsProvider;
return new ProductsManager().DefaultProvider;
}
/// <summary>
/// Gets the filter expression - specifying the given filter will ignore culture on the
/// provider level and search will index all content, regardless of localization. You
/// are of course free to modify the filter expression as you wish.
/// </summary>
public override string FilterExpression
{
get
{
return "Name='InVariantCulture';";
}
}
/// <summary>
/// Gets the URL to a single content item. This method is invoked for every item that
/// is binding to the list. By default the item ID is appended to the query string portion
/// of the URL.
/// </summary>
/// <returns>String containing the URL address.</returns>
/// <param name="contentItem">
/// <param name="singleItemUrl">Url of the page where single products are displayed</param>
/// <param name="culture">Culture of the item</param>
/// The <see cref="Telerik.Cms.Engine.IContent">IContent</see> item that is currently
/// binding.
/// </param>
protected override string GetItemUrl(IContent contentItem, string singleItemUrl, CultureInfo culture)
{
if (String.IsNullOrEmpty(singleItemUrl))
{
int idx = productsPageUrl.IndexOf('.');
if (idx != -1)
productsPageUrl = productsPageUrl.Substring(0, idx);
singleItemUrl = productsPageUrl;
}
return base.GetItemUrl(contentItem, singleItemUrl, culture);
}
/// <summary>
/// Initializes this instance with the settings provided.
/// </summary>
/// <param name="settings"><c>IDictionary<string, string></c> representing key value collection of settings.</param>
/// <remarks>
/// The settings dictionary you see here, will be populated through the ProductsIndexSettings control.
/// </remarks>
public override void Initialize(IDictionary<string, string> settings)
{
productsPageUrl = settings["ProductPageUrl"];
settings.TryGetValue("ProductsProvider", out productsProvider);
if (String.IsNullOrEmpty(productsPageUrl))
throw new ArgumentException(String.Format("CannotCreateAbsoluteUri"));
base.Initialize(settings);
}
private string productsProvider;
private string productsPageUrl;
}
}

There are couple of things worth noting here – first notice how we are overriding DefaultContentProvider property so that we can return the DefaultContentProvider for the Products module and not for Generic Content (in base class). Secondly, notice that we also override the Initialize method and initialize the values we need in our provider (productsPageUrl) from the settings. That’s all really there is to it – the rest, like getting the content and adding it to index, will be done for us by the base class.

Implementing ProductsIndexSettingsControl class

As it was explained at the beginning of this post, IndexSettings controls are created so that we can collect settings from end users that we will use later in our IndexProvider class. If you go back to the last paragraph, you’ll see that on Initialize we are getting the ProductPageUrl setting from the settings object. In order to let user set this property, we need to create ProductsIndexSettingsControl class and provider user with the UI elements to enter the value of that setting.

In your Telerik.Samples.Products project create a following folder:

Telerik.Samples.Products.WebControls.Search

and add following file to this folder:

ProductsIndexSettingsControl.cs

Paste following code in the the file:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Web.UI;
using System.Web.UI.HtmlControls;
using System.Web.UI.WebControls;
using Telerik.Cms.Engine.Security;
using Telerik.Cms.Web.UI;
using Telerik.Framework.Search;
using Telerik.Framework.Web;
using Telerik.Samples.Products.Configuration;
using Telerik.Security.Permissions;
namespace Telerik.Samples.Products.WebControls.Search
{
/// <summary>
/// The ProductsSettingsControl implements the settings control. This control is used for setting ProductsIndex properties.
/// </summary>
public class ProductsIndexSettingsControl : CompositeControl, ISettingsControl
{
#region Properties
/// <summary>
/// Gets or sets the Control Template path
/// </summary>
public string ControlTemplatePath
{
get
{
object o = ViewState["ControlTemplatePath"];
if (o == null)
return "~/Sitefinity/Admin/ControlTemplates/Products/ProductsIndexSettingsControl.ascx";
return (string)o;
}
set
{
ViewState["ControlTemplatePath"] = value;
}
}
/// <summary>
/// Gets or sets the control template
/// </summary>
public ITemplate ControlTemplate
{
get
{
if (controlTemplate == null)
controlTemplate = ControlUtils.GetTemplate<DefaultTemplate>(ControlTemplatePath);
return controlTemplate;
}
set
{
controlTemplate = value;
}
}
/// <summary>
/// Gets or Sets the name of the provider
/// </summary>
public string ProviderName
{
get
{
object o = ViewState["ProviderName"];
if (o == null)
return String.Empty;
return (string)o;
}
set
{
ViewState["ProviderName"] = value;
}
}
/// <summary>
/// Gets or Sets the page Url for displaying the news item
/// </summary>
public string SingleProductUrl
{
get
{
if (ctrlContainer.SingleProductUrl != null)
return ctrlContainer.SingleProductUrl.Text;
return string.Empty;
}
set
{
ctrlContainer.SingleProductUrl.Text = value;
}
}
#endregion
#region Inherited Methods
/// <summary>
/// Overriden. Cancels the rendering of a beginning HTML tag for the control.
/// </summary>
/// <param name="writer">The HtmlTextWriter object used to render the markup.</param>
public override void RenderBeginTag(HtmlTextWriter writer)
{
}
/// <summary>
/// Overriden. Cancels the rendering of an ending HTML tag for the control.
/// </summary>
/// <param name="writer">The HtmlTextWriter object used to render the markup.</param>
public override void RenderEndTag(HtmlTextWriter writer)
{
}
/// <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()
{
ctrlContainer = new ControlContainer(this);
ControlTemplate.InstantiateIn(ctrlContainer);
properties = TypeDescriptor.GetProperties(this);
PropertyDescriptor desc = properties.Find("SingleProductUrl", false);
editorDialog = new PropertyEditorDialog();
editorDialog.TypeContainer = this;
editorDialog.PropertyChanged += editorDialog_PropertyChanged;
Controls.Add(editorDialog);
string[] provNames = null;
int count = ConfigHelper.Handler.GenericContentProviders.Count;
if (count > 1)
{
int i = 0;
provNames = new string[count];
foreach (GenericContentElement element in ConfigHelper.Handler.GenericContentProviders)
{
GlobalPermission perm = new GlobalPermission(
(GlobalPermissions)ProductsManager.SecurityRoots[element.ProviderName], CrudRights.View);
if (perm.CheckDemand())
{
provNames[i++] = element.ProviderName;
}
}
}
ListControl list = ctrlContainer.ProviderName;
if (provNames != null)
{
foreach (string name in provNames)
{
if (!String.IsNullOrEmpty(name))
{
ListItem item = new ListItem(name.Replace('_', ' '), name);
list.Items.Add(item);
}
}
}
ctrlContainer.ProviderNameLi.Visible = (list.Items.Count > 1);
ctrlContainer.SelectProductUrl.CommandName = "Telerik.Cms.Web.UI.PageIndexUrlWebEditor, Telerik.Cms";
ctrlContainer.SelectProductUrl.CommandArgument = desc.Name;
ctrlContainer.SelectProductUrl.Command += SelectSingleProductUrl_Command;
ctrlContainer.ProviderName.SelectedIndexChanged += ProviderName_SelectedIndexChanged;
if (settings != null && settings.Count > 0)
{
ctrlContainer.ProviderName.SelectedValue = ProviderName = settings["ProductsIndexProvider"];
ctrlContainer.SingleProductUrl.Text = settings["ProductPageUrl"];
}
Controls.Add(ctrlContainer);
}
void ProviderName_SelectedIndexChanged(object sender, EventArgs e)
{
ProviderName = ((ListControl)sender).SelectedValue;
}
void SelectSingleProductUrl_Command(object sender, CommandEventArgs e)
{
object data;
string name = (string)e.CommandArgument;
PropertyDescriptor desc = properties.Find(name, false);
data = desc.Converter.ConvertToInvariantString(this);
editorDialog.Show(name, e.CommandName, data, this);
}
void editorDialog_PropertyChanged(object source, PropertyValueChangedEventArgs e)
{
string[] resultValue = ((string)e.PropertyValue).Split(';');
SetProperty(this, properties, e.PropertyName, resultValue[1]);
}
private void SetProperty(object component, PropertyDescriptorCollection _properties, string name, object value)
{
PropertyDescriptor desc = _properties.Find(name, false);
desc.SetValue(component, value);
}
/// <summary>
/// It initializes the _settings.
/// </summary>
/// <param name="_settings">
/// The parameter contains the default values when a new provider is created (in IRssProviderModule implementation)
/// If the provider has already been created, the parameter contains the values saved in the database.
/// </param>
public void InitSettings(IDictionary<string, string> _settings)
{
settings = _settings;
}
/// <summary>
/// Gets the newly edited settings.
/// </summary>
/// <returns> an object of type Dictionary that contains strings</returns>
public IDictionary<string, string> GetSettings()
{
settings = new Dictionary<string, string>();
settings["ProductIndexProvider"] = ProviderName = ctrlContainer.ProviderName.SelectedValue;
settings["ProductPageUrl"] = ctrlContainer.SingleProductUrl.Text;
return settings;
}
#endregion
#region Private Fields
private PropertyDescriptorCollection properties;
private ITemplate controlTemplate;
private ControlContainer ctrlContainer;
private IDictionary<string, string> settings;
private PropertyEditorDialog editorDialog;
#endregion
#region DefaultTemplate
/// <summary>
/// The default template class
/// </summary>
protected class DefaultTemplate : ITemplate
{
/// <summary>
/// Instantiates the specified control in the default template
/// </summary>
/// <param name="container">accepts a parameter of type Control</param>
public void InstantiateIn(Control container)
{
}
}
#endregion
#region Container
/// <summary>
/// The control container class
/// </summary>
protected class ControlContainer : GenericContainer<ProductsIndexSettingsControl>
{
/// <summary>
/// Constructs the control container
/// </summary>
/// <param name="owner">accepts a parameter of type RssSettingsControl</param>
public ControlContainer(ProductsIndexSettingsControl owner)
: base(owner)
{
}
/// <summary>
/// Gets the control which displays the Provider Name.
/// </summary>
public ListControl ProviderName
{
get
{
if (providerName == null)
providerName = FindRequiredControl<ListControl>("productsProvider");
return providerName;
}
}
/// <summary>
/// Gets the control which displays the single product url which could be edited.
/// </summary>
public IEditableTextControl SingleProductUrl
{
get
{
if (singleProductUrl == null)
singleProductUrl = (IEditableTextControl)FindControl(typeof(IEditableTextControl), "singleProductUrl", true);
return singleProductUrl;
}
}
/// <summary>
/// Gets the control which displays the post url in a link button which could be selected.
/// </summary>
public IButtonControl SelectProductUrl
{
get
{
if (selectProductUrl == null)
selectProductUrl = (IButtonControl)FindControl(typeof(IButtonControl), "selectSingleProductUrl", true);
return selectProductUrl;
}
}
/// <summary>
/// Gets the control which displays the "li" tag with information about the Provider Name
/// </summary>
public HtmlGenericControl ProviderNameLi
{
get
{
if (providerNameLi == null)
providerNameLi = FindRequiredControl<HtmlGenericControl>("providerNameLi");
return providerNameLi;
}
}
private HtmlGenericControl providerNameLi;
private IButtonControl selectProductUrl;
private ListControl providerName;
private IEditableTextControl singleProductUrl;
}
#endregion
}
}

The control looks like any other control we’ve implemented in Sitefinity so far, so I will not go over containers, templates and other things that have been explained many times throughout our developer’s manual. There are only two important things to note here. Control must implement ISettingsControl interface and this interface mandates following member:

/// <summary>
/// Gets the newly edited settings.
/// </summary>
/// <returns> an object of type Dictionary that contains strings</returns>
public IDictionary<string, string> GetSettings()
{
settings = new Dictionary<string, string>();
settings["ProductIndexProvider"] = ProviderName = ctrlContainer.ProviderName.SelectedValue;
settings["ProductPageUrl"] = ctrlContainer.SingleProductUrl.Text;
return settings;
}

Now, you remember how I said that ProductPageUrl setting will be populated by the ProductIndexSetting control? This is how it does it – everything else in this control is just UI programming.

Since, this is a control we’ll need a template for it. You can get the templates for search controls from the project that accompanies this article. Templates are located in:

~/Sitefinity/Admin/ControlTemplates/Products

folder. NOTE: Do not forget to copy the resource files from the App_LocalResources folder.

Implementing ProductsIndexView control

One last thing to do and we’re done. We need to implement ProductsIndexView control. To do so, add

ProductsIndexViewControl.cs

file to Telerik.Samples.Products project in this folder:

Telerik.Samples.Products.WebControls.Search

Then paste the following code into it:

using System;
using System.Collections.Generic;
using System.Web.UI.WebControls;
using System.Web.UI;
using Telerik.Samples.Products.Resources;
namespace Telerik.Samples.Products.WebControls.Search
{
using Framework.Search;
using Framework.Web;
using Cms.Web.UI;
/// <summary>
/// This control visualizes the settings of the Products Index which have already been saved in the database.
/// </summary>
public class ProductsIndexViewControl : CompositeControl, ISearchViewControl
{
/// <summary>
/// Gets or sets path of the external template that defines child controls of this control.
/// </summary>
public string TemplatePath
{
get
{
object o = this.ViewState["TemplatePath"];
if (o == null)
return "~/Sitefinity/Admin/ControlTemplates/Products/ProductsIndexViewSettingsControl.ascx";
return (string)o;
}
set
{
this.ViewState["TemplatePath"] = value;
}
}
/// <summary>
/// Gets or sets the template of this control.
/// </summary>
public ITemplate ItemTemplate
{
get
{
if (this.itemTemplate == null)
this.itemTemplate = ControlUtils.GetTemplate<DefaultTemplate>(this.TemplatePath);
return this.itemTemplate;
}
}
/// <summary>
/// Overriden. Called to populate the child control hierarchy. This is the main
/// method to render the control's markup, since it is a CompositeControl and contains
/// child controls.
/// </summary>
protected override void CreateChildControls()
{
this.container = new ControlContainer(this);
this.ItemTemplate.InstantiateIn(this.container);
this.container.ResultsPageUrl.Text = this.GetValue("ProductPageUrl", this.settings["ProductPageUrl"]);
this.Controls.Add(this.container);
}
private string GetValue(string key, string value)
{
switch (key)
{
case "ProductPageUrl":
return String.IsNullOrEmpty(value) ? Messages.NotSet : value;
default:
return "";
}
}
/// <summary>
/// Initializes settings for the control
/// </summary>
/// <param name="settings">a dictionary of strings</param>
public void InitializeSettings(IDictionary<string, string> settings)
{
this.settings = settings;
}
#region Private Fields
private ControlContainer container;
private ITemplate itemTemplate;
private IDictionary<string, string> settings;
#endregion
#region Default Template
/// <summary>
/// Implements the ITemplate interface
/// </summary>
protected class DefaultTemplate : ITemplate
{
/// <summary>
/// Instantiates the specified control in the default template
/// </summary>
/// <param name="container">accepts a parameter of type Control</param>
public void InstantiateIn(Control container)
{
}
}
#endregion
#region Container
/// <summary>
/// The control container class
/// </summary>
protected class ControlContainer : GenericContainer<ProductsIndexViewControl>
{
public ControlContainer(ProductsIndexViewControl owner)
: base(owner)
{
}
public ITextControl ResultsPageUrl
{
get
{
if (this.resultsPageUrl == null)
this.resultsPageUrl = (ITextControl)base.FindRequiredControl<Control>("resultsPageUrl");
return this.resultsPageUrl;
}
}
private ITextControl resultsPageUrl;
}
#endregion
}
}

This control is even simpler than the previous one. All it has to do is display the saved settings for the provider.

This control also requires the template and you can get them from the project that accompanies this article. Templates are located in:

~/Sitefinity/Admin/ControlTemplates/Products

folder. NOTE: Do not forget to copy the resource files from the App_LocalResources folder.

Conclusion

You are done. Build your project, create few products, head to administration and create a new search index for products. Start indexing, place the SearchBox and SearchResults controls (available in the toolbox) somewhere on your pages and test the index out.

You can download the whole project from here.
If you have any questions, suggestions or run into problems – leave a comment.

Leave a comment