Monday, November 17, 2014

Sitecore 7.2 with Solr: Search operator, boosting the most recent results, highlighting and proximity search.

Recently I found myself in need to add a few extensions to the Sitecore Solr Provider that I would like to share. For original implementation I added a "GroupBy" method that you can read about in my other blog post "Sitecore 7 and GroupBy method for Solr.". This time I had to add 4 more things:
  • passing of a search operator "AND";
  • boosting the most recent results to the top;
  • implementing highlighting;
  • and proximity search.
Because I already had a class where GroupBy extension was implemented, I used the same class to add query parameters for new functionality.

Passing of a search operator "AND"

Out-of-the-box Solr uses "OR" operator for all queries. To retrieve "AND" results for something like private equity phrase, I had to pass "AND" search operator. The Solr query should look like this:

?q={q.op=AND}((_template:(d84da6272e284cdb87869691dea4e692) OR _template:(e6ec2506eb4e4998aaa922b41372a8b7) OR _template:(d07c1b814d2546529c4738a0cc7a48dc)) AND people_sm:(296ee3f7692b48eba1f3f41387eee6c3))&rows=4500&fl=*,score&fq=((iscontent_b:(True) AND show_in_search_results_b:(True)) AND _latestversion:(True))&fq=_indexname:(sitecore_master_index)&sort=start_date_tdt desc,publication_date_tdt desc

or like this:

?q=((_template:(d84da6272e284cdb87869691dea4e692) OR _template:(e6ec2506eb4e4998aaa922b41372a8b7) OR _template:(d07c1b814d2546529c4738a0cc7a48dc)) AND people_sm:(296ee3f7692b48eba1f3f41387eee6c3))&rows=4500&fl=*,score&fq=((iscontent_b:(True) AND show_in_search_results_b:(True)) AND _latestversion:(True))&fq=_indexname:(sitecore_master_index)&q.op=AND&sort=start_date_tdt desc,publication_date_tdt desc

If you wish you can pass both.

To add this parameter to the Solr query I had to add an Operator parameter to my GroupResults method that returns grouped search results to indicate which operator should be used.

public static ExtendedSearchResults<TSource> GroupResults<TSource, TKey>(this IQueryable<TSource> source, IProviderSearchContext context, Expression<Func<TSource, TKey>> keySelector, int groupLimit, bool includSpellChecking, Operator op, string text, IDictionary<BoostField,string> boostFields = null)
        {
            if (source == null)
                throw new ArgumentNullException("source");

            var linqToSolr = new CustomLinqToSolrIndex((SolrSearchContext)context, (IExecutionContext)null);
            
            var param = "_template";
            MemberExpression member = keySelector.Body as MemberExpression;
            if (member != null)
            {
                PropertyInfo propInfo = member.Member as PropertyInfo;
                if (propInfo != null)
                {
                    var expressionParser = new ExpressionParser(typeof(TKey), typeof(TSource), linqToSolr.PublicFieldNameTranslator);
                    var methodInfo = expressionParser.GetType().GetMethod("Visit", BindingFlags.Instance | BindingFlags.NonPublic, Type.DefaultBinder, new Type[] { typeof(Expression) }, null);
                    if (methodInfo != null)
                    {
                        object classInstance = Activator.CreateInstance(typeof(TSource), null);
                        QueryNode queryNode = methodInfo.Invoke(expressionParser, new object[] { keySelector.Body }) as QueryNode;
                        FieldNode fieldNode = queryNode as FieldNode;
                        if (fieldNode != null)
                        {
                            param = fieldNode.FieldKey;
                        }
                    }                                    
                }
            }
            var extendedQuery = ExtendNativeQuery((IHasNativeQuery)source, groupLimit, param, includSpellChecking, op, text, boostFields);
            return linqToSolr.Execute<ExtendedSearchResults<TSource>>(extendedQuery);            
        }

This method calls ExtendNativeQuery method, passing Operator parameter. In the ExtendNativeQuery method the assembling of query parameters happens:

private static ExtendedCompositeQuery ExtendNativeQuery(IHasNativeQuery hasNativeQuery, int groupLimit, string expression, bool includSpellChecking, Operator op, string text, IDictionary<BoostField,string> boostFields = null)
        {            
            var query = (SolrCompositeQuery)hasNativeQuery.Query;
            query.Methods.Add((QueryMethod)new GetResultsMethod(GetResultsOptions.Default));

            var options = new QueryOptions()
                {
                    Grouping = new GroupingParameters()
                    {
                        Fields = new[] { expression },
                        Format = GroupingFormat.Grouped,
                        Limit = groupLimit,
                    },
                    Highlight = GetHighlightParameter()
                };

            var localParams = new LocalParams();
            
            if (op == Operator.AND)
            {
                localParams.Add("q.op", "AND");
            }
            return new ExtendedCompositeQuery(query.Query, query.Filter, query.Methods, query.VirtualFieldProcessors, query.FacetQueries, options, localParams);
        }

Here I am adding creating a LocalParams object, add q.op parameter to it and call constructor for ExtendedCompositeQuery passing newly created localParams object. The ExtendedCompositeQuery class inherits SolrCompositeQuery and is very simple.

public class ExtendedCompositeQuery : SolrCompositeQuery
    {
        public QueryOptions QueryOptions { get; set; }
        public LocalParams LocalParams { get; set; }
        public ExtendedCompositeQuery(AbstractSolrQuery query, AbstractSolrQuery filterQuery, IEnumerable<Sitecore.ContentSearch.Linq.Methods.QueryMethod> methods, IEnumerable<IFieldQueryTranslator> virtualFieldProcessors, IEnumerable<FacetQuery> facetQueries, QueryOptions options, LocalParams localParams = null)
            : base(query, filterQuery, methods, virtualFieldProcessors, facetQueries)
        {
            QueryOptions = options;
            LocalParams = localParams;
        } 
}

The only difference between SolrCompositeQuery and ExtendedCompositeQuery is the LocalParams property.

After the query object is assembled, an Execute method is being called on CustomLinqToSolrIndex object, which is a class extending LinqToSolrIndex one from Sitecore original code. In the extension class I added the following code block to the internal Execute method:

if (compositeQuery.LocalParams != null)
{
   SearchLog.Log.Info("Serialized Query - ?q=" + compositeQuery.LocalParams.ToString() + q + "&" + string.Join("&", Enumerable.ToArray(Enumerable.Select<KeyValuePair<string, string>, string>(loggingSerializer.GetAllParameters(options), (Func<KeyValuePair<string, string>, string>)(p => string.Format("{0}={1}", (object)p.Key, (object)p.Value))))), (Exception)null);
   return solrOperations.Query(compositeQuery.LocalParams + q, options);
}

It adds the local parameters to the query if LocalParams property of the composite query is defined. This is the point where a string representation of the query is being generated and passed to Solr.

Boosting the most recent results

The second requirement that I needed to accommodate was boosting of the most recent results to the top of the result set. For Solr to return most recent results first the query should look something like this:

?q={!boost b=recip(ms(NOW,publication_date_tdt),3.16e-11,1,1) q.op=AND}((_template:(d84da6272e284cdb87869691dea4e692) OR _template:(e6ec2506eb4e4998aaa922b41372a8b7) OR _template:(d07c1b814d2546529c4738a0cc7a48dc)) AND people_sm:(296ee3f7692b48eba1f3f41387eee6c3))&rows=4500&fl=*,score&fq=((iscontent_b:(True) AND show_in_search_results_b:(True)) AND _latestversion:(True))&fq=_indexname:(sitecore_master_index)&q.op=AND&sort=start_date_tdt desc,publication_date_tdt desc

To add this parameter to the query I added one more parameter to the ExtendNativeQuery method responsible for putting together the composite query object. In the body of the method I added a check for the parameter being null, and if it is not, I am adding "boost b" parameter to localParams object.

if (boostFields != null)
{
    foreach (var field in boostFields.Keys)
    {
        switch (field)
        {
           case BoostField.DateLatestFirst:
                localParams.Add("boost b", string.Format("recip(ms(NOW,{0}),3.16e-11,1,1)", boostFields[field]));
                break;
           default:
                break;
        }
     }
}

The rest gets taken care of by the CustomLinqToSolrIndex class that you saw in the search operator implementation.

Proximity Search

You probably have seen in Sitecore Search documentation an example of proximity search Linq syntax. For one reason or another it didn't really work for me. First it didn't wrap the word I was passing to the Like method in quotes, which is needed for the Solr query to return an proximity search results, so I had to force the quotes. In addition to that when I tried to pass an integer of 1000, it kept converting the value to ~0.5, which was wrong. Only when I converted it into a float, it appended ~1000, which I was trying to do. I think should be the opposite, and reflected code seems to check for int, but for whatever reason it worked in reverse.

At the end the code looked like this:

float slop = 1000F;
query = query.Where(p =>
    p.FirstName.MatchWildcard(correctSpelling) ||
    p.LastName.MatchWildcard(correctSpelling) ||
    p.OfficeName.MatchWildcard(correctSpelling) ||
    p.Title.Like("\"" + correctSpelling + "\"", slop) ||
    p.ReferenceItem.Like("\"" + correctSpelling + "\"", slop) ||
    p.SiteContent.Like("\"" + correctSpelling + "\"", slop) ||
    p.PageContent.Like("\"" + correctSpelling + "\"", slop));

Highlighting of the keywords in the search results.

Implementing of highlighting was the most evolved. Not only I had to pass the query with all highlighting parameters, but I also had to extract the highlights section from the Solr response.

Once again in the same ExtendNativeQuery method where I added other parameters to the composite query, a Hightlight property of QueryOptions was added.



var options = new QueryOptions()
{
   Grouping = new GroupingParameters()
   {
       Fields = new[] { expression },
       Format = GroupingFormat.Grouped,
       Limit = groupLimit,
   },
   Highlight = GetHighlightParameter()
};

Method responsible for generating Highlight parameter:


private static HighlightingParameters GetHighlightParameter()
{
    var snippetCount = WeilConfig.SiteProperties.PropertyValue<int>("SolrHighlightsNumberOfSnippets");
    var fields = WeilConfig.SiteProperties.PropertyValues<string>("SolrHighlightsFields");
    return new HighlightingParameters
        {
            Fields = fields != null ? fields.ToArray() : new[] { "pagecontent_t" },
            Fragmenter = SolrHighlightFragmenter.Regex,
            RegexPattern = @"\w[^|;.!?]{50,400}[|;.!?]",
            Fragsize = 300,
            RegexSlop = 0.2
        };
}
The GroupResults method returns an extension of Sitecore SearchResults object called ExtendedSearchResults, which was done for the GroupBy implementation, I can just add processing of the Highlights section to the same class.

The actual processing of Highlights section of the Solr response is done in CustomLinqToSolrIndex class in GetExtendedResults method:

internal TResult GetExtendedResults<TResult, TDocument>(ExtendedCompositeQuery compositeQuery, SolrSearchResults<TDocument> processedResults, SolrQueryResults<Dictionary<string, object>> results)
        {
            object obj = default(TResult);
            IEnumerable<Linq.GroupedResults<TDocument>> groups = processedResults.GetGroupedResults();
            FacetResults facetResults = this.FormatFacetResults(processedResults.GetFacets(), compositeQuery.FacetQueries);
            IEnumerable<SearchHit<TDocument>> searchResults = processedResults.GetSearchHits();
            var spellcheckedResponse = processedResults.GetSpellCheckedResults();
            obj = Activator.CreateInstance(typeof(TResult), (object)searchResults, (object)groups, (object)processedResults.NumberFound, spellcheckedResponse, processedResults.Highlights, (object)facetResults);
            return (TResult)Convert.ChangeType(obj, typeof(TResult));
        }

The SolrQueryResults class is responsible for Solr response processing and holds processed values in it's properties. I added a new highlights properties to this class to hold the Highlights response portion.


public struct SolrSearchResults<TElement>
    {
        private readonly SolrSearchContext context;
        private readonly SolrQueryResults<Dictionary<string, object>> searchResults;
        private readonly IDictionary<string, SolrNet.GroupedResults<Dictionary<string, object>>> groupedSearchResults;
        private readonly SolrIndexConfiguration solrIndexConfiguration;
        private readonly IIndexDocumentPropertyMapper<Dictionary<string, object>> mapper;
        private readonly SelectMethod selectMethod;
        private readonly IEnumerable<IFieldQueryTranslator> virtualFieldProcessors;
        private readonly int numberFound;
        private readonly string spellCheckerResults;
        private readonly IDictionary<string, HighlightedSnippets> highlights;
        private readonly IEnumerable<IExecutionContext> executionContexts;
    
        public int NumberFound
        {
            get
            {
                return this.numberFound;
            }
        }
        
        public SolrSearchResults(SolrSearchContext context, SolrQueryResults<Dictionary<string, object>> searchResults, SelectMethod selectMethod, IEnumerable<IExecutionContext> executionContexts, IEnumerable<IFieldQueryTranslator> virtualFieldProcessors)
        {
            this.context = context;
            this.solrIndexConfiguration = (SolrIndexConfiguration)this.context.Index.Configuration;
            this.executionContexts = executionContexts;

            OverrideExecutionContext<IIndexDocumentPropertyMapper<Dictionary<string, object>>> executionContext = this.executionContexts != null ? Enumerable.FirstOrDefault<IExecutionContext>(this.executionContexts, (Func<IExecutionContext, bool>)(c => c is OverrideExecutionContext<IIndexDocumentPropertyMapper<Dictionary<string, object>>>)) as OverrideExecutionContext<IIndexDocumentPropertyMapper<Dictionary<string, object>>> : (OverrideExecutionContext<IIndexDocumentPropertyMapper<Dictionary<string, object>>>)null;
            this.mapper = (executionContext != null ? executionContext.OverrideObject : (IIndexDocumentPropertyMapper<Dictionary<string, object>>)null) ?? this.solrIndexConfiguration.IndexDocumentPropertyMapper;
            
            this.selectMethod = selectMethod;
            this.virtualFieldProcessors = virtualFieldProcessors;
            this.numberFound = searchResults.NumFound;
            this.searchResults = SolrSearchResults<TElement>.ApplySecurity(searchResults, context.SecurityOptions, context.Index.Locator.GetInstance<ICorePipeline>(), context.Index.Locator.GetInstance<IAccessRight>(), ref this.numberFound);
            this.groupedSearchResults = SolrSearchResults<TElement>.ApplyGroupSecurity(this.searchResults.Grouping, context.SecurityOptions, context.Index.Locator.GetInstance<ICorePipeline>(), context.Index.Locator.GetInstance<IAccessRight>(), ref this.numberFound);
            this.spellCheckerResults = SolrSearchResults<TElement>.GetSpellCheckedString(searchResults.SpellChecking);
            this.highlights = searchResults.Highlights;
        }
...


The next step is generating of ExtendedSearchResults object to be returned by GroupResults method. If highlights parameter is passed to the constructor, created object will have the Highlights property populated.


    public class ExtendedSearchResults<TSource>
    {
        public string CorrectedSpelling { get; set; }
        public IDictionary<string, IList<TSource>> SimilarResults { get; set; }
        public int TotalSearchResults { get; private set; }
        public IEnumerable<SearchHit<TSource>> Hits { get; private set; }
        public IEnumerable<Linq.GroupedResults<TSource>> Groups { get; private set; }
        public FacetResults Facets { get; private set; }
        public IDictionary<string, HighlightedSnippets> Highlights { get; private set; }

        public ExtendedSearchResults(IEnumerable<SearchHit<TSource>> results, int totalSearchResults)
        {
            if (results == null)
                throw new ArgumentNullException("results");
            this.Hits = results;
            this.TotalSearchResults = totalSearchResults;
        }

        public ExtendedSearchResults(IEnumerable<SearchHit<TSource>> results, int totalSearchResults, FacetResults facets = null)
            : this(results, totalSearchResults)
        {
            this.Facets = facets;
        }

        public ExtendedSearchResults(IEnumerable<Linq.GroupedResults<TSource>> results, int totalSearchResults, FacetResults facets = null)
            : this(results, totalSearchResults)
        {
            this.Facets = facets;
        }

        public ExtendedSearchResults(IEnumerable<SearchHit<TSource>> results, IEnumerable<Linq.GroupedResults<TSource>> groups, int totalSearchResults, string spellcheckedString, FacetResults facets = null)
            : this(results, totalSearchResults)
        {
            this.Facets = facets;
            this.Groups = groups;
            this.CorrectedSpelling = spellcheckedString;
        }

        public ExtendedSearchResults(IEnumerable<SearchHit<TSource>> results, IEnumerable<Linq.GroupedResults<TSource>> groups, int totalSearchResults, string spellcheckedString, IDictionary<string, HighlightedSnippets> highlights, FacetResults facets = null)
            : this(results, totalSearchResults)
        {
            this.Facets = facets;
            this.Groups = groups;
            this.CorrectedSpelling = spellcheckedString;
            this.Highlights = highlights;
        }

        public ExtendedSearchResults(IEnumerable<Linq.GroupedResults<TSource>> results, int totalSearchResults)
        {
            if (results == null)
                throw new ArgumentNullException("results");
            this.Groups = results;
            this.TotalSearchResults = totalSearchResults;
        }

    }

At the end yo get the ExtendedSearchResults object that includes Highlights property that you can use to highlight keywords in the search results on the front end.


You have an option of choosing to display multiple highlighted sections and use Regex. The way you construct the highlighting query parameters is absolutely up to you.

Tuesday, October 21, 2014

Sitecore SPEAK How To: Weil Brochure Builder Application

This document provides step by step instructions on how to build Brochure Builder Sitecore SPEAK application. The controls that were used to create this application include a custom SearchDataSource, SelectedItemsList that allows users see selected for brochure items and change their order in the list, another custom control that is responsible for calling an MVC controller to trigger the brochure build and execute Sitecore rules upon completion. You will see how custom controls with custom properties can be created and how those properties can be accessed in the JavaScript code.

If you prefer reading this document in PDF format, click here to download a PDF version.

Contents

An Overview of Application functionality

Those of you who attended my presentation “Creating Dynamic Brochures with Sitecore PXM Module” at Sitecore Symposium 2014 in Las Vegas or Barcelona have seen a demo of this application. For those of you who have not seen it, here is a screenshot and description of the final product.
Brochure Builder Application
Brochure Builder Application
As you can see there are a few controls on the application page. All of them except “Selected for Brochure” one on the left are Sitecore out-of-the-box ones. However, because Weil.com solution uses Solr as a search engine, I had to implement a custom data source for the main ListControl that list all available for selection items. I also had to make sure that we only display items that are allowed to be rendered in a PDF brochure since this is a Brochure Builder application.
Let’s try to build this application from scratch. If you are new to SPEAK, I would suggest reading Martina Welander’s blogs at http://mhwelander.net/category/speak/ first. It will give you basics of SPEAK as well as great detail on the overall structure and hidden jams of it.

Building the Application

Creating Application Page

Now you are familiar with how the application should look like. It is time to start building it!
First of all I need to create an application page.  To do that I’m going to open Sitecore Rocks in Visual Studio, connect to core database in a Sitecore instance and add a new item named ContentSelection under “/sitecore/client/[WeilApp]/Brochure Builder”. You can choose a different location for your application if you would like. [WeilApp] can be replaced with the name of the folder you decide to keep all your custom applications in.


I chose to use a Dialog ListPage for the application that I created for the symposium, so I’ll do the same here.
When the ListPage is created from a branch, there are a few renderings already assigned to the layout settings of the newly created page. PageSettings item is also created automatically. To see what those layout settings are, you can hit [Ctrl+U] or right click on your list page and go to Tasks > Design Layout like you see on the screen shot below.

How to view the layout settings for an application page.
When the Layout settings window opens you should see something like this:

Initial Renderings on a ListPage.
There are quite a few renderings are already assigned to the page.  Now I can start assigning new renderings that I need for the Brochure Builder. But before I do that, let’s take a look at how initial page looks like.

Initial view of Brochure Builder application.
Not much there, right? If you have done some SPEAK development in the past, you have seen this page many times before.

Customize PageCode if needed

If application page is expected to take parameters, and in a case of a Dialog that is often the case you can create a class in your solution that would be responsible for processing passed in parameters. You can also create a custom JavaScript file to extend default page functionality.


The code in a back end class for a dialog page would look something like this:

 
public class BuildBrochureDialog : PageCodeBase
{
   public Rendering DataSource { get; set; }
   public override void Initialize()
   {
       this.ReadQueryParamsAndUpdatePlaceholders();
   }

   private void ReadQueryParamsAndUpdatePlaceholders()
   {
       string queryString1 = WebUtil.GetQueryString("ro");
       string queryString2 = WebUtil.GetQueryString("hdl");
       if (!string.IsNullOrEmpty(queryString1) && queryString1 != "{0}")
           this.DataSource.Parameters["RootItemId"] = !ItemUri.IsItemUri(queryString1) ? (ClientHost.Items.GetItem(queryString1) ?? Clien-tHost.Databases.Database.GetRootItem()).ID.ToString() : (Clien-tHost.Items.GetItem(ItemUri.Parse(queryString1).GetPathOrId()) ?? Clien-tHost.Databases.Database.GetRootItem()).ID.ToString();
            // put your code here
   }
}

If query string parameters can be passed to your application, you can process them in this manner and assign the values to controls you need. When the class is created you can update the PageCode rendering PageCodeTypeName property to use it.





If you need to extend JavaScript functionality of the PageCode rendering, you can create your own .js file that would have the code similar to the one below.

define(["sitecore", "jquery"], function (Sitecore, jQuery) {
    var BrochureBuilderPageCode = Sitecore.Definitions.App.extend({
        initialized: function () {
        },
        doSomething: function () {
            //custom logic goes here
        }        
    });
    return BrochureBuilderPageCode;
});

In the PageCode rendering properties window you will need to update the PageCodeScriptName property to point to the .js file.


Adding ScrollBar

The first rendering that I am going to add is the ScrollBar. It will enable the Scrollbar behavior on the ListControl I’m going to add a little later.


Changing Page Title

Since I am building a “Brochure Builder” application and not just “List”, I need to make the page title say so. I can do it in two ways, and you’ll see me do the same Text change for other renderings using both approaches, but for this control I am going to choose the data source approach and to add a Text item under PageSettings with the HeaderTitle name.



In newly created HeaderTitle item I am going to enter “Brochure Builder” in the Text field.



The next step is to point the Header Title rendering to the new HeaderTitle
item as a Data Source. I’ll also need to make sure that the Text field on the rendering is empty; otherwise it will override the value from the Data Source.


As a result the title update, the header now says “Brochure Builder”. Very nice!


Changing OKButton to say “Build Brochure”

There are two buttons in the header section of the dialog – OK and Cancel. “Cancel” fits right in, however “OK” doesn’t represent the action that it is supposed to trigger – building of a brochure. So, let’s rename this button to “Build Brochure” and make it start brochure building process.
First I’m going to add a new Text item under PageSettings and call it BuildButton. In the item Text field I’m going to enter “Brochure Builder”.The next step is renaming the OKbutton to be BuildButton and updating its properties.


I need to point the Data Source to the BuildButton item under the PageSettings and specify the click event. I’m going to enter app.BrochureBuilderObserver.build() JavaScript function in the click field. I’m going to add the BrochureBuilderObserver control later in Chapter 2.16.5. After all updates are made, the rendering properties windows should have the following settings:




Notice that the Text field is empty, Id is changed to BuildButton and Click event now has “javascript:app.BrochureBuilderObserver.build()”.


Build Completion Message Rendering

To inform user about completion of brochure build, I added a Text rendering called BuildCompleteText. It doesn’t have a data source. Message text is stored in Text field directly in rendering properties.





IsVisible property is set to False to hide the message when page initially loads. The placeholder for this rendering is set to be DialogContent.Main to display the message in the main section of application window. Later I’m going to add two Rule renderings called BuildIsDoneRule
and BuildFailedRule that will update the text property value of the BuildCompleteText depending on whether the build succeeded or failed.

Download Brochure button

When brochure is built and ready for viewing, I need to display a button that would allow user to open up created PDF file. For that I am going to add a DownloadButton to the list of renderings.


In properties window I will add the following settings:
  • ButtonType is set to Default
  • IsVisible is False to hide the button when page initially loads
  • Text for the button is “Open Brochure”
  • Click event – javascript:app.BrochureBuilderObserver.openBrochure(). This is a JavaScript event that is implemented in the BrochureBuilderObeserver rendering that I’m going to add later.

BuildAnotherButton Rendering

When build is done I would like to offer a user an option to build a new brochure. For that I’m going to add a new Button rendering called BuildAnotherButton and assign the following properties to it.




In the Text field I’m going to add “Build New Brochure” to be displayed on the button. I am also going to choose “Primary” in the ButtonType field to make it red. The button should not be visible when the page initially loads, so I need to set IsVisible to False. And the last, but not least, the property value that will make application initial page to be loaded when the button is clicked. The click property value needs to be set to:
javascript:window.location=’/sitecore/client/WeilApps/Brochure Builder/Content Selection’


Search Panel

To create a layout structure for the search controls, I’m going to add a SearchPanel rendering called Search to the list of renderings in the DialogContent.Main placeholder.

CustomSearchDataSource Rendering

Weil.com solution uses Solr as a search engine, and I need to make sure I use the same search engine as the rest of the solution does. To accommodate that I am going to create a custom SearchDataSource rendering that would go through the same pipes as the web site pages do to retrieve the content based on the search criteria.



First I am going to copy the SearchDataSource rendering from /sitecore/client/Speak/Layouts/Renderings/Data/SearchDataSource into my application and rename it to be CustomSearchDataSource. If you open CustomSearchDataSource item, you’ll see that it has a Path field that point to the rendering .cshtml file. Since I copied this rendering from SearchDataSource one, it still points to the SearchDataSource.cshtml file. I need to create my own rendering view to make it truly custom. So, I am going to open my solution explorer and add a new view file under /sitecore/shell/client/Speak/Layouts/Renderings/Data/CustomSearchDataSources folder and call it CustomSearchDataSource.cshtml.

The next step is to copy the code from SearchDataSouse.cshtml into my view and change the control name to match mine.

@using Sitecore.Mvc
@using Weil.SC.Client.Speak.UI.Controls.CustomSearchDataSources
@model Sitecore.Mvc.Presentation.RenderingModel
@Html.Sitecore().Controls().CustomSearchDataSource(this.Model.Rendering)

As you might have noticed I also added a reference to Weil.SC.Client.Speak.UI.Controls.CustomSearchDataSources. Well, to make @Html.Sitecore().Controls()CustomSearchDataSource(this.Model.Rendering) line work I had to add two classes:

  • CustomSearchDataSource.cs – responsible for generating html output for my control;
  • ControlExtensions.cs – contains html helper methods for my control.

Below is the code for the CustomSearchDataSourc.cs class.

    public class CustomSearchDataSource : ItemDataSourceBase, IPageble
    {
        public string DatabaseName { get; set; }
        public string LanguageName { get; set; }
        public ID FacetsRootItemId { get; set; }
        public string Formatting { get; set; }
        public int PageIndex { get; set; }
        public string PageIndexBinding { get; set; }
        public int PageSize { get; set; }
        public string PageSizeBinding { get; set; }
        public ID RootItemId { get; set; }
        public string SearchConfigItemId { get; set; }
        public string Sorting { get; set; }
        public string Text { get; set; }
        protected string RootItemIdBinding { get; set; }
        protected string SelectedFacets { get; set; }
        protected string SelectedFacetsBinding { get; set; }
        protected string TextBinding { get; set; }

        public CustomSearchDataSource()
        {
            this.Requires.Script("controls", "customsearchdatasource.js");
        }
        
    public CustomSearchDataSource(RenderingParametersResolver parametersResolver): base(parametersResolver) {             Assert.ArgumentNotNull((object)parametersResolver, "parametersResolver");                    
            this.Requires.Script("controls", "customsearchdatasource.js");
            this.Text = parametersResolver.GetString("Text", "text");
            this.FacetsRootItemId = parametersResolver.GetId("FacetsRootItemId", (ID)null);
            this.DatabaseName = parametersResolver.GetString("Database", "database");
            this.LanguageName = parametersResolver.GetString("Language", "language");
            this.RootItemId = parametersResolver.GetId("RootItemId", "rootItemId", (ID)null);
            this.SearchConfigItemId = parametersResolver.GetString("SearchConfigItemId");
            this.SelectedFacets = parametersResolver.GetString("SelectedFacets", "selectedFacets");
            this.Formatting = parametersResolver.GetString("Formatting", "formatting");
            this.Sorting = parametersResolver.GetString("Sorting", "sorting");
            this.ResolvePagingParameters(parametersResolver, (IPageble)this);
            this.TextBinding = parametersResolver.GetString("TextBinding");
            this.RootItemIdBinding = parametersResolver.GetString("RootItemIdBinding");
            this.SelectedFacetsBinding = parametersResolver.GetString("SelectedFacetsBinding");
        }

        protected override void PreRender()
        {
            base.PreRender();
            if (!string.IsNullOrEmpty(this.Text))
                this.SetAttribute("data-sc-text", this.Text);
            if (!ID.IsNullOrEmpty(this.FacetsRootItemId))
                this.SetAttribute("data-sc-facets-root-id", this.FacetsRootItemId.ToString());
            if (!ID.IsNullOrEmpty(this.RootItemId))
                this.SetAttribute("data-sc-root-id", this.RootItemId.ToString());
            if (!string.IsNullOrEmpty(this.LanguageName))
            {
                string strA = this.LanguageName;
                if (string.Compare(strA, "$context_language", StringComparison.InvariantCultureIgnoreCase) == 0)
                    strA = Context.Language.Name;
                this.SetAttribute("data-sc-language", strA);
            }
            if (!string.IsNullOrEmpty(this.DatabaseName))
            {
                string strA = this.DatabaseName;
                if (string.Compare(strA, "$context_database", StringComparison.InvariantCultureIgnoreCase) == 0)
                    strA = Context.Database.Name;
                else if (string.Compare(strA, "$context_contentdatabase", StringComparison.InvariantCultureIgnoreCase) == 0)
                    strA = Context.ContentDatabase.Name;
                this.SetAttribute("data-sc-database", strA);
            }
 if (!string.IsNullOrEmpty(this.SearchConfigItemId))
                this.SetAttribute("data-sc-searchconfig", this.SearchConfigItemId);
            if (!string.IsNullOrEmpty(this.Formatting))
                this.SetAttribute("data-sc-formatting", this.Formatting);
            if (!string.IsNullOrEmpty(this.Sorting))
                this.SetAttribute("data-sc-sorting", this.Sorting);
            if (this.PageSize > 0)
                this.SetAttribute("data-sc-pagesize", this.PageSize.ToString());
            if (this.PageIndex > 0)
                this.SetAttribute("data-sc-pageindex", this.PageIndex.ToString());
            this.SetAttribute(HtmlTextWriterAttribute.Type, "text/x-sitecore-customsearchdatasource");
            if (!string.IsNullOrEmpty(this.TextBinding))
                this.AddBinding("text", this.TextBinding);
            if (!string.IsNullOrEmpty(this.RootItemIdBinding))
                this.AddBinding("rootItemId", this.RootItemIdBinding);
            if (!string.IsNullOrEmpty(this.SelectedFacetsBinding))
                this.AddBinding("selectedFacets", this.SelectedFacetsBinding);
            if (!string.IsNullOrEmpty(this.PageSizeBinding))
                this.AddBinding("pageSize", this.PageSizeBinding);
            if (string.IsNullOrEmpty(this.PageIndexBinding))
                return;
            this.AddBinding("pageIndex", this.PageIndexBinding);
        }

        protected override void Render(HtmlTextWriter output)
        {
            this.AddAttributes(output);
            output.RenderBeginTag(HtmlTextWriterTag.Script);
            output.RenderEndTag();
        }
    }


And here is the code for the extension file:

    public static class ControlsExtension
    {
        public static HtmlString CustomSearchDataSource(this Sitecore.Mvc.Controls controls, Sitecore.Mvc.Presentation.Rendering rendering)
        {
            Assert.ArgumentNotNull((object)controls, "controls");
            Assert.ArgumentNotNull((object)rendering, "rendering");
            return new HtmlString(new CustomSearchDataSource(controls.GetParametersResolver(rendering)).Render());
        }

        public static HtmlString CustomSearchDataSource(this Sitecore.Mvc.Controls controls, string controlId, object parameters = null)
        {
            Assert.ArgumentNotNull((object)controls, "controls");
            Assert.ArgumentNotNull((object)controlId, "controlId");
            CustomSearchDataSource searchDataSource = new CustomSearchDataSource((RenderingParametersResolver)controls.GetParametersResolver(parameters));
            searchDataSource.ControlId = controlId;
            return new HtmlString(searchDataSource.Render());
        }
    }

Now I can build the solution and add new view path to my CustomSearchDataSource item. The value for that field is:

/sitecore/shell/client/Speak/Layouts/Renderings/Data/CustomSearchDataSources/CustomSearchDataSource.cshtml.

But wait; didn't I forget one more piece? I did. I forgot to add the JavaScript file that does all the magic, the customsearchdatasource.js. My .cs file has the code that adds the reference to it in the control.

public CustomSearchDataSource()
{
    this.Requires.Script("controls", "customsearchdatasource.js");
}

So, I need to go back to my solution and add a .js file in the same level where my view lives, under the /sitecore/shell/client/Speak/Layouts/Renderings/Data/CustomSearchDataSources and name it CustomSearchDataSource.js.
My control should be working almost the same as the out-of-the-box SearchDataSource, but it needs to retrieve data from Solr. So, I’m going to copy the JavaScript code from SearchDataSource and make a few changes to make it work.



Here is what the final code looks like:

define(["sitecore"], function (Sitecore) {
    "use strict";

    var model = Sitecore.Definitions.Models.ComponentModel.extend(
      {
          initialize: function (attributes) {
              this._super();

              this.set("text", "");
              this.set("searchConfig", null);
              this.set("rootItemId", null);
              this.set("pageSize", 0);
              this.set("pageIndex", 1);
              this.set("totalItemsCount", 0);
              this.set("items", null);
              this.set("selectedFacets", []);
              this.set("facets", []);
              this.set("facetsRootItemId", null);
              this.set("formatting", "");
              this.set("sorting", "");
              this.set("language", "");
              this.set("database", "");
              this.set("pagingMode", "appending");
              this.set("isBusy", false);
              this.set("hasItems", false);
              this.set("hasNoItems", true);
              this.set("hasMoreItems", false);
              this.set("showHiddenItems", false);

              this.on("change:searchType change:text change:pageSize change:pageIndex change:selectedFacets change:rootItemId change:searchConfig change:sorting change:showHiddenItems", this.refresh, this);

              this.isReady = false;
              this.pendingRequests = 0;
              this.lastPage = 1;
          },

          refresh: function () {

              this.set("pageIndex", 1);
              this.lastPage = 1;
              this.getItems();
          },

          next: function () {
              this.lastPage++;
              this.getItems();
          },

          buildBrochure: function () {
              if (!this.isReady) {
                  return;
              }
          },

          getItems: function () {
              if (!this.isReady) {
                  return;
              }

              var search = this.get("text"),
                options = this.getOptions(),
                url,
                selectedFacets;

              if (!search && !options.root && !options.searchConfig) {
                  return;
              }

              url = "/api/sitecore/SearchApi/Get";

              selectedFacets = this.get("selectedFacets");
              var selectedFacetsParam;
              if (selectedFacets != null && selectedFacets.length > 0) {
                  selectedFacetsParam = this.getFacets(selectedFacets);
              }

              var database = this.get("database");
              this.pendingRequests++;
              this.set("isBusy", true);

              _sc.debug("CustomSearchDataSource request: '", url, "', options:", options);

              var data = $.ajax({
                  url: selectedFacetsParam ? url + "?" + selectedFacetsParam : url,
                  data: { searchstring: this.get("text"), pagenum: options.pageIndex, pagesize: options.pageSize, facetsRootItemId: options.facetsRootItemId, root: options.root },
                  context: this,
                  success: function (data) {
                      this.completed(data.result.items, data.result.totalCount, data.result);
                  }
              });
              
          },

          getFacets: function (selectedFacets) {
              var result = "";

              var facets = {};
              _.each(selectedFacets, function (facet) {
                  if (!facets[facet.name]) {
                      facets[facet.name] = [];
                  }

                  facets[facet.name].push(facet.value);
              }, this);

              _.each(_.keys(facets), function (name) {
                  var s = "";
                  _.each(facets[name], function (i) {
                      s += (s != "" ? "," : "") + i;
                  }, this);

                  result += (result != "" ? "&" : "") + name + "=" + s;
              }, this);             

              return result;
          },

          getOptions: function () {
              var options = {}, fields;
              var pageSize = this.get("pageSize");
              if (pageSize) {
                  options.pageSize = pageSize;

                  if (this.get("pagingMode") == "appending") {
                      options.pageIndex = this.lastPage;
                  }
                  else {
                      options.pageIndex = this.get("pageIndex");
                  }
              }

              fields = this.get("fields");
              if (fields && fields.length > 0) {
                  options.fields = fields;
              }
              else {
                  options.payLoad = "full";
              }

              options.root = this.get("rootItemId");
              options.language = this.get("language");
              options.facetsRootItemId = this.get("facetsRootItemId");
              options.searchConfig = this.get("searchConfig");

              if (this.get("formatting") != "") {
                  options.formatting = this.get("formatting");
              }

              if (this.get("sorting") != "") {
                  options.sorting = this.get("sorting");
              }
              if (this.get("showHiddenItems")) {
                  options.showHiddenItems = true;
              }

              return options;
          },

          completed: function (items, totalCount, result) {
              _sc.debug("CustomSearchDataSource received: ", result);

              // logic for parsing dates when formatting==$send_localized_dates 
              if (this.get("formatting") == "$send_localized_dates") {
                  _.each(items, function (item) {
                      var formatedFields = [];
                      _.each(item.$fields, function (field) {
                          var fieldType = field.type ? field.type.toLowerCase() : '';
                          if (fieldType === "datetime" || fieldType === "date") {
                              formatedFields[field.fieldName] = {
                                  type: field.type,
                                  formattedValue: field.formattedValue,
                                  longDateValue: field.longDateValue,
                                  shortDateValue: field.shortDateValue
                              };
                          }
                      });
                      //extend item with formated fields
                      item.$formatedFields = formatedFields;
                  });

              }

              if (this.get("pagingMode") == "appending" && this.lastPage > 1) {
                  items = this.get("items").concat(items);
                  this.set("items", items, { force: true });
              }
              else {
                  this.set("items", items, { force: true });
                  this.set("facets", result.facets ? result.facets : []);
              }

              this.set("totalItemsCount", totalCount);
              this.set("hasItems", items && items.length > 0);
              this.set("hasNoItems", !items || items.length === 0);
              this.set("hasMoreItems", items.length < totalCount);

              this.pendingRequests--;
              if (this.pendingRequests <= 0) {
                  var self = this;
                  self.set("isBusy", false);

                  this.pendingRequests = 0;
              }

              this.trigger("itemsChanged");
          }
      }
    );

    var view = Sitecore.Definitions.Views.ComponentView.extend(
      {
          listen: _.extend({}, Sitecore.Definitions.Views.ComponentView.prototype.listen, {
              "refresh:$this": "refresh",
              "next:$this": "next"
          }),

          initialize: function (options) {
              this._super();

              var pageIndex, pageSize, fields;

              pageSize = parseInt(this.$el.attr("data-sc-pagesize"), 10) || 0;
              this.model.set("pageSize", pageSize);

              pageIndex = parseInt(this.$el.attr("data-sc-pageindex"), 10) || 0;
              this.model.set("pageIndex", pageIndex);

              if (this.$el.is("[data-sc-fields]")) {
                  fields = $.parseJSON(this.$el.attr("data-sc-fields"));
                  this.model.set("fields", fields);
              }
              else {
                  this.model.set("fields", null);
              }

              this.model.set("language", this.$el.attr("data-sc-language"));
              this.model.set("database", this.$el.attr("data-sc-database") || "core");
              this.model.set("facetsRootItemId", this.$el.attr("data-sc-facets-root-id"));
              this.model.set("formatting", this.$el.attr("data-sc-formatting"));
              this.model.set("sorting", this.$el.attr("data-sc-sorting"));
              this.model.set("rootItemId", this.$el.attr("data-sc-root-id"));
              this.model.set("text", this.$el.attr("data-sc-text") || "");
              this.model.set("searchConfig", this.$el.attr("data-sc-searchconfig"));
              this.model.set("showHiddenItems", this.$el.data("sc-showhiddenitems"));
              this.model.set("pagingMode", this.$el.attr("data-sc-pagingmode") || "appending"); // or paged

              this.model.isReady = true;
          },

          afterRender: function () {
              this.refresh();
          },

          refresh: function () {
              this.model.refresh();
          },

          next: function () {
              this.model.next();
          }
      }
    );

    Sitecore.Factories.createComponent("CustomSearchDataSource", model, view, "script[type='text/x-sitecore-customsearchdatasource']");
});

I changed the getItems() and the getFacets() methods and renamed of the component in Sitecore.Factories.createComponent("CustomSearchDataSource", model, view, "script[type='text/x-sitecore-customsearchdatasource']"); line.
In the getItems() method I am calling a service url that returns the search results. The service uses the same business and data layers as the rest of the site to retrieve search results from Solr. The only difference between the service method that returns the search results for the Ajax driven pages and the method that the Brochure Builder SPEAK application uses is the returned format.


Here is the code for the Search Api Controller for the SPEAK application.

public class SearchApiController : Controller
    {
        public virtual JsonResult Get()
        {
            var result = new SearchResult() { statusCode = 200, result = new Result() { items = new List(), facets = new List() } };
            
            NameValueCollection querystring = new NameValueCollection(Context.Request.QueryString);
            try
            {
                querystring.Remove("facetsRootItemId");
                querystring.Remove("root");
                var globalSearchCriteria = ModelService.GenerateSearchCriteria(querystring);
                var correctedSpelling = string.Empty;
                var facetsRootItemId = Context.Request.QueryString["facetsRootItemId"];
                Item facetFolder = null;
                if (!string.IsNullOrEmpty(facetsRootItemId))
                {
                    facetFolder = Factory.GetDatabase("core").GetItem(facetsRootItemId);
                }
                var searchResults = SearchManager.SearchForBrochure(globalSearchCriteria, facetFolder, Sitecore.Context.Item);
                var items = new List();
                if (searchResults.Hits.Any())
                {
                    result.result = new Result()
                    {
                        totalCount = searchResults.TotalSearchResults,
                        resultCount = searchResults.Hits.Count(),
                        facets = GetFacets(searchResults.Facets, facetFolder)
                    };
                    foreach (var res in searchResults.Hits)
                    {
                        var sitecoreItem = res.Document.GetItem();
                        if (sitecoreItem != null)
                        {
                            var customItem = ItemFactory.GetCustomItem(sitecoreItem);
                            if (customItem is PersonItem)
                            {
                                var item = (PersonItem)customItem;
                                items.Add(new SearchResultItem()
                                {
                                    ID = item.ID.ToString(),
                                    Name = item.PreferredFullName ?? item.InnerItem.DisplayName,
                                    Icon = item.Image.MediaItem != null ? MediaManager.GetThumbnailUrl(item.Image.MediaItem) : GetIcon(item),
                                    MediaUrl = item.Image.MediaItem != null ? MediaManager.GetMediaUrl(item.Image.MediaItem) : GetIcon(item),
                                    LongID = item.InnerItem.Paths.LongID,
                                    Path = item.InnerItem.Paths.FullPath,
                                    Template = item.InnerItem.Template.InnerItem.Paths.ParentPath,
                                    TemplateId = item.InnerItem.TemplateID.ToString(),
                                    TemplateName = item.InnerItem.TemplateName,
                                    Language = item.InnerItem.Language.Name,
                                    Version = item.InnerItem.Version.Number,
                                    Url = item.Url,
                                    Category = "Person",
                                    Database = item.Database.Name,
                                    DisplayName = item.InnerItem.DisplayName,
                                    HasChildren = item.InnerItem.HasChildren,
                                    itemId = item.ID.ToString()
                                });
                            }
                            else if (customItem is OfficeItem)
                            {
                                var item = (OfficeItem)customItem;
                                items.Add(new SearchResultItem()
                                {
                                    ID = item.ID.ToString(),
                                    Name = item.OfficeName,
                                    Icon = item.Image.MediaItem != null ? MediaManager.GetThumbnailUrl(item.Image.MediaItem) : GetIcon(item),
                                    MediaUrl = item.Image.MediaItem != null ? MediaManager.GetMediaUrl(item.Image.MediaItem) : GetIcon(item),
                                    LongID = item.InnerItem.Paths.LongID,
                                    Path = item.InnerItem.Paths.FullPath,
                                    Template = item.InnerItem.Template.InnerItem.Paths.ParentPath,
                                    TemplateId = item.InnerItem.TemplateID.ToString(),
                                    TemplateName = item.InnerItem.TemplateName,
                                    Language = item.InnerItem.Language.Name,
                                    Version = item.InnerItem.Version.Number,
                                    Url = item.Url,
                                    Category = "Office",
                                    Database = item.Database.Name,
                                    DisplayName = item.InnerItem.DisplayName,
                                    HasChildren = item.InnerItem.HasChildren,
                                    itemId = item.ID.ToString()
                                });
                            }
                            else if (customItem is IArticle)
                            {
                                var item = (CustomItem)customItem;
                                var articleItem = (IArticle)customItem;
                                items.Add(new SearchResultItem()
                                {
                                    ID = item.ID.ToString(),
                                    Name = articleItem.Title,
                                    Icon = GetIcon(item),
                                    LongID = item.InnerItem.Paths.LongID,
                                    Path = item.InnerItem.Paths.FullPath,
                                    Template = item.InnerItem.Template.InnerItem.Paths.ParentPath,
                                    TemplateId = item.InnerItem.TemplateID.ToString(),
                                    TemplateName = item.InnerItem.TemplateName,
                                    Language = item.InnerItem.Language.Name,
                                    Version = item.InnerItem.Version.Number,
                                    Url = articleItem.Url,
                                    Category = "Article",
                                    Database = item.Database.Name,
                                    DisplayName = item.InnerItem.DisplayName,
                                    HasChildren = item.InnerItem.HasChildren,
                                    itemId = item.ID.ToString()
                                });
                            }
                            else if (customItem is IServiceLanding)
                            {
                                var item = (CustomItem)customItem;
                                var experienceItem = (IServiceLanding)customItem;
                                items.Add(new SearchResultItem()
                                {
                                    ID = item.ID.ToString(),
                                    Name = experienceItem.Title,
                                    Icon = GetIcon(item),
                                    LongID = item.InnerItem.Paths.LongID,
                                    Path = item.InnerItem.Paths.FullPath,
                                    Template = item.InnerItem.Template.InnerItem.Paths.ParentPath,
                                    TemplateId = item.InnerItem.TemplateID.ToString(),
                                    TemplateName = item.InnerItem.TemplateName,
                                    Language = item.InnerItem.Language.Name,
                                    Version = item.InnerItem.Version.Number,
                                    Url = experienceItem.Url,
                                    Category = "Experience",
                                    Database = item.Database.Name,
                                    DisplayName = item.InnerItem.DisplayName,
                                    HasChildren = item.InnerItem.HasChildren,
                                    itemId = item.ID.ToString()
                                });
                            }
                        }
                    }
                }
                result.result.items = items;
                return Json(result, JsonRequestBehavior.AllowGet);
            }
            catch
            {
                result.statusCode = 500;
                return Json(result, JsonRequestBehavior.AllowGet);
            }
        }

        /// 
        /// Gets the facets.
        /// 
        /// The facet results.
        /// 
        private IEnumerable GetFacets(Sitecore.ContentSearch.Linq.FacetResults facetResults, Item facetFolder = null)
        {
            var facets = new List();
            if (facetResults != null && facetResults.Categories != null)
            {
                foreach (var facet in facetResults.Categories)
                {
                    if (facetFolder != null && facetFolder.Children.Any())
                    {
                        facets.Add(new FacetItem() { Name = facetFolder.Children.FirstOrDefault(f => f["FieldName"] == facet.Name).DisplayName, Values = GetFacetValues(facet.Values) });
                    }
                    else
                    {
                        facets.Add(new FacetItem() { Name = "Template", Values = GetFacetValues(facet.Values) });
                    }
                }
            }
            return facets;
        }

        ///         /// Gets the facet values.
        ///         /// The list.
        /// 
        private IEnumerable GetFacetValues(List list)
        {
            var facetValues = new List();
            if (list != null && list.Any())
            {
                foreach (var itm in list)
                {
                    if (itm.AggregateCount > 0)
                    {
                        var item = Factory.GetDatabase("master").GetItem(ShortID.Parse(itm.Name).ToID());
                        if (item != null)
                        {
                            facetValues.Add(new FacetValueItem() { DisplayText = item.DisplayName, Count = itm.AggregateCount, Text = item.Name, Value = itm.Name });
                        }
                    }
                }
            }
            return facetValues;
        }

        ///         /// Gets the icon.
        ///         /// The item.
        /// 
        private static string GetIcon(CustomItem item)
        {
            return Themes.MapTheme(item.InnerItem.Appearance.Icon);
        }       
    }   

To be able to use the ListControl rendering to display results from the CustomSearchDataSource I need to match the service response format to the format that Sitecore Item Api returns. For that purpose I have added the following classes to the solution to generate proper response format.

    public class SearchResult
    {
        public int statusCode { get; set; }
        public Result result { get; set; }
    }

    public class Result
    {
        public int totalCount { get; set; }
        public int resultCount { get; set; }
        public IEnumerable items { get; set; }
        public IEnumerable facets { get; set; }
    }

    public class SearchResultItem
    {
        public string Category { get; set; }
        public string Database { get; set; }
        public string DisplayName { get; set; }
        public bool HasChildren { get; set; }
        public string ID { get; set; }
        public string itemId { get; set; }
        public string Language { get; set; }
        public string LongID { get; set; }
        public string MediaUrl { get; set; }
        public string Icon { get; set; }
        public string Name { get; set; }
        public string Path { get; set; }
        public string Template { get; set; }
        public string TemplateId { get; set; }
        public string TemplateName { get; set; }
        public string Url { get; set; }
        public int Version { get; set; }
        public string[] Fields { get; set; }
    }

    public class FacetItem
    {
        public string Name { get; set; }
        public IEnumerable Values { get; set; }
    }

    public class FacetValueItem
    {
        public int Count { get; set; }
        public string DisplayText { get; set; }
        public string Text { get; set; }
        public int Priority { get; set; }
        public string Value { get; set; }
    }

Now I can add my CustomSearchDataSource rendering to the layout settings in my application to Page.Body placeholder.


Next step is to point newly created rendering to the CustomSearchDataSource item under PageSettings as a Data Source item:


After that I need to update the Data Source property in the CustomSearchDataSource rendering properties.


Search Results ListControl

To display search results on the page I need to add a ListControl to the list of renderings.

The search results table will have three columns, for that I’m adding three column items: IconField, ItemName and Template. In each I need to specify a field name and for icon field I have to enter format in which the output should be rendered.


Formatted IconField Column
When you choose to use Html Template to format the output of your column, DataField
field value must be empty.  In Html Template field for the IconField column I’m going to enter <img src=”{{Icon}}” />  where Icon matches the object property in the service results.

"items": [
  {
   "Category":"Person",
   "Database":"web",
   "DisplayName":"Aabha Reddy",
   "HasChildren":true,
   "ID":"{C492C197-60DB-456F-BECB-04E0478E7D56}",
   . . . 
   "Icon":"~/media/3754d6ab6dc94a02a881301480191623.ashx",
   "Name":"Aabha Sharma",
   "Path":"[item path]",       
   . . .
  },

Below is the screenshot of the IconField item.



ItemName Column
The next column is the ItemName one. I need to update the DataField filed on this item with “Name” value to make it display item name.



I also want to display the item type in the search results column, so user can easily identify it. For that I’m going to add a Template column that would display item’s TemplateName property value.




Now I need to create the Data Source item for the CustomSearchDataSource rendering. It needs to be based on SearchDataSource Parameters template and contain configurations settings like page size, page index, language, database, etc.



Once data source item is created, I can update the Data Source property for the ResultsListControl rendering.
I need to also specify Scrollbar Behavior, ViewMode to be DetailList and Items in Data Binding section to have a value of {Binding DataSource.Items} to bind my ListControl with CustomSearchDataSource I created earlier.



To show the status of the search results returning progress I am going to add ProgressIndicator rendering to the DialogContent.Main placeholder, the same placeholder where the ResultsListControl is located. In the rendering properties windows I need to specify that Delay is 0, bind it to DataSource.IsBusy property, set AutoShow and AutoShowTimeout values to 0 and enter ResultsListControl in the Target Control field like it is shown below.



To define the facets for the search results, I am adding a Facets folder under the PageSettings and as a child of this folder item, I’m going to add a Template
item that is based on Facet template. In the FieldName field I need to enter _template to indicate that the facets should be based on the item template.



The next step is to add the FilterControl rendering with the name of ContentFilterControl. I am going to place it into DialogContent.Navigation placeholder to display it on the left rail in of the application window. In the Facets field, I am going to specify {Binding DataSource.Facets} to bind this control to the Data Source control’s Facets property.




The SearchTextBox doesn’t have any special properties except for the placeholder where it lives in. I added it to the Search.Searches placeholder to make it show up above the ResultListControl.



Update CustomSearchDataSource to use facets and the value from the search text box.

Now as I have Facets data source item, ContentFilterControl and SearchTextBox renderings configured, I can go back to the CustomSearchDataSource rendering and update its SelectedFacets and Text search properties.
In the FacetsRootitemId I’ll enter the ID for the Facets folder I created earlier. SelectedFacets I am binding to the ContentFilerControl.SelectedFacets, Text to SearchTextBox.Text property.



Adding Paging rendering

To enable paging for the ResultsListControl, I need to add a Show More button below the list of results.
First, I’m going to add Border type rendering to DialogContent.Main placeholder and name it PagerBorder.



In the rendering properties I’m going to specify Content Alignment to be “Center” and remove the border (uncheck ShowBorder).



Then I’m going to add a Button to the PagerBorder.Content placeholder with the name of PagerButton.



In the rendering properties window I am going to set the following property values:




When CustomSearchDataSource service doesn’t return any items, I need to display “No items found” message. For that I am going to create a folder called Messages under PageSettings. As a child of Messages item I’ll create an item based on Notification Message template and call it No items found.
Now I need to add a MessageBar
rendering to the DialogContent.Main placeholder. I’ll call it the MessageBar.





In rendering properties window for the newly created rendering I am going to enter the No items found item’s ID to Messages field and {Binding DataSource.HasNoItems} to IsVisible one.



Now when CustomDataSource doesn't have any items to show, I’ll have the “No items found” message displayed.


Review the work

Most of the pieces seem to be in place, now we can check how the page looks like.


Not bad. I get my data returned and displayed, Facets are also showing up. But there are a few things missing. First, the SearchTextBox doesn’t have a search button to trigger the search. Let’s add that in.



I am adding an IconButton with the name of SearchIconButton to the Search.Searches
placeholder and specifing the following properties in the rendering properties window:
  • ImageUrl: /sitecore/client/~/media/C2D806C76EA94DEE940AE949D9AFD77A.ashx
  • BackgroundPosition: -47px 0
  • Tooltip: Search
  • Click: javascript:app.DataSource.refresh() that would trigger refreshing of the CustomDataSource control passing the value from SearchTextBox to be searched for.



Now if we refresh the application page, you’ll see that there is a button with a magnifying glass next to the search input box. If I enter search string into that box and click on the new button, the data refreshes and displays the relevant content.

Notice that the facets on the left also reflect the change in data and display new counts for each template. Try checking one of the facet checkboxes. The list of search result items should change to display only items that are based on the template you have selected.




I’m going to change the Navigation title above the facets to say “Filter Search Results” and remove Action control from the left rail to make it look even better.



Let’s refresh the page.



Everything seems to be in place. I can search the content and filter the search results. Paging also works. Now I can move on to creating a rendering that would list all items that have been selected to be included into Brochure and trigger the PDF generation.

Header above ResultsListControl

To create a title above the ResultsListControl, I added a Text rendering to the Search.Filters placeholder with the name ItemListText and specified the following properties in its rendering properties window:



Custom Control for displaying selected for brochure items.

Next step is adding a new control that would display all selected for brochure items where user can reorder and delete any of them. This control is the data source for the list of items to be included in the brochure.






First I am going to add a Text rendering to the DialogContent.Navigation placeholder and name it SeletedTitle.



In Text field of this rendering I am going to add “Selected for Brochure” and in TypeDivider.  IsVisible property needs to be set to True.




There is no control that would have the functionality I need for the SelectedItems
rendering, so I have to create a new one. Because it is a control that lists items, I’ll call it SelectedItemsListControl. In Visual Studio I need to navigate to the directory where the source files to be located, right clicked and chose “Add > New Item”. I’ll choose “SPEAK Component with JavaScript” in the templates and enter “SelectedItemsListControl” in the file name field.


In the next dialog window I need to specify the location where I would like the new control item to be created.


As a result there will be two files created in my solution:




SelectedItemsListControl item was also added to the sitecore tree under PageSettings in the Brochure Builder application.
To make my custom control work properly and provide custom rendering properties, I need to create a template for control parameters. Under SelectedItemsListControl
item I am going to add a new Template item and call it Control Parameters. The next step is switching to Sitecore content editor to assign ConstrolBase base template to the Control Parameters template item. Next step is to return to Visual Studio and open up the Control Parameters template for editing. I need to add new template section called Selected Items and an Items field of Single-Line text type. When you edit a template in Sitecore Rocks, you see a Build button next to each field. To the left of that field there is an input box for the field source. To make a template field show up in a Selected Items section in rendering properties window, I need to assign the bindmode=read to the source field. To enter that value you can either type it in or click on the Build button then select edit Bind Mode (Speak) and then choose appropriate binding. Having Standard Values created wouldn’t hurt either, so I am going to create that too.



After that work is done I can go back to SelectedItemsListControl item under PageSettings and change the Parameters Template field value to point to the Control Parameters template I just created.



The next step is assigning of the SelectedItemsListControl rendering to the page.



Below is the rendering properties window with SelectedItemsListControl1 settings, default and custom (Notice “Selected Items” section).



In the .cshtml file I am going to add the following code:

@using Sitecore.Mvc
@using Sitecore.Mvc.Presentation
@using Sitecore.Web.UI.Controls.Common.UserControls
@model RenderingModel
@{
  var userControl = Html.Sitecore().Controls().GetUserControl(Model.Rendering);
  userControl.Class = "sc-selecteditemslistcontrol";
  userControl.DataBind = "visible: isVisible";
  userControl.Requires.Script("client", "selecteditemslistcontrol.js");

  var text = userControl.GetString("TextBinding", "Text");

  var htmlAttributes = userControl.HtmlAttributes;
}       

In the .js file I will have the following:

define(["sitecore", "knockout"], function (_sc, ko) {
    var model = _sc.Definitions.Models.ControlModel.extend({
        initialize: function () {
            this._super();

            this.set("selectedItems", []);
            this.set("items", []);

            this.on("change:selectedItems", this.changeSelectedItems, this);

            this.viewModel.selectedItemList = new ko.observableArray(null);
            this.updating = false;
        },

        changeSelectedItems: function () {
            if (arguments.length > 0) {
                this.updateSelectedItems(arguments[0]);
            } else {
                this.set("items", this.get("selectedItems"));
            }
        },

        updateSelectedItems: function (newItem) {
            var selectedItems = this.get("items");
            var selectedItemList = [];
            var newItemModel = newItem.viewModel;
            var alreadyExists = false;
            var newId = newItemModel.ID();
            _.each(selectedItems, function (item) {
                if (newId == item.ID()) {
                    alreadyExists = true;
                }
                selectedItemList.push(item);
            }, this);
            if (!alreadyExists) {
                selectedItemList.push(newItemModel);
            }
            this.viewModel.selectedItemList(selectedItemList);
            this.set("items", selectedItemList);
        },

        removeItem: function (id) {
            var selectedItems = this.get("items");

            selectedItems = _.reject(selectedItems, function (i) {
                return i.ID() === id;
            }, this);

            this.updating = true;
            this.viewModel.selectedItemList(selectedItems);
            this.set("items", selectedItems);
        },
        moveUp: function (id) {
            var selectedItems = this.get("items");
            var itemIndex;
            _.each(selectedItems, function (item, idx) {
                if (item.ID() === id) {
                    itemIndex = idx;
                }
            }, this);
            if (itemIndex - 1 >= 0) {
                var temp = selectedItems[itemIndex - 1];
                selectedItems[itemIndex - 1] = selectedItems[itemIndex];
                selectedItems[itemIndex] = temp;
            }
            this.updating = true;
            this.viewModel.selectedItemList(selectedItems);
            this.set("items", selectedItems);
        },
        moveDown: function (id) {
            var selectedItems = this.get("items");
            var itemIndex;
            _.each(selectedItems, function (item, idx) {
                if (item.ID() === id) {
                    itemIndex = idx;
                }
            }, this);
            if (itemIndex + 1 < selectedItems.length) {
                var temp = selectedItems[itemIndex + 1];
                selectedItems[itemIndex + 1] = selectedItems[itemIndex];
                selectedItems[itemIndex] = temp;
            }
            this.updating = true;
            this.viewModel.selectedItemList(selectedItems);
            this.set("items", selectedItems);
        }
    });

    var view = _sc.Definitions.Views.ControlView.extend({
        initialize: function () {
            this._super();
        },
        removeItem: function (data, event) {
            var itemId = data.ID();
            this.model.removeItem(itemId);
        },
        moveUp: function (data, event) {
            var itemId = data.ID();
            this.model.moveUp(itemId);
        },
        moveDown: function (data, event) {
            var itemId = data.ID();
            this.model.moveDown(itemId);
        }
    });
    _sc.Factories.createComponent("SelectedItemsListControl", model, view, ".sc-selecteditemslistcontrol");
});


Adding items to the SelectedItemsListControl

Each time an item is clicked on in ResultsListControl, the same item should be added to the list of items in SelectedItemsListControl. To make that happen I am going to add a Rule rendering to the Page.Body placeholder and a data source item for it called ResultsListControlRule based on the RuleDefinition template to PageSettings.



In the Rules field of ResultsListControlRule item I will enter the following conditions and actions:


Custom Action: CallComponentFunction

The first condition and action in the Rule 1 and Rule 2 are out-of-the-box ones, but the “call component function with parameters” is a custom rule called CallComponentFunction I had to create under /sitecore/client/Speak/Layouts/Renderings/Resources/Rule/Rules/Actions.
The value I entered in the Text field is:

call component [targetControlId,,,name]  function [functionName,,,functionName] with parameters [sourceControlId,,,name] [sourceProperty,,,sourceProperty]



I also added a .js file to implement the action functionality. It had to be placed in /sitecore/shell/client/Speak/Layouts/Renderings/Resources/Rules/ConditionsAndActions/Actions
folder and named CallComponentFunction.js.

Below is the code for this .js file.

define([], function () {
  var action = function (context, args) {

      var targetControl = context.app[args.targetControlId],
          functionName = args.functionName,
          sourceControl = context.app[args.sourceControlId],
          selectedItemsPropertyName = "selectedItem",
          sourceProperty = args.sourceProperty,
          selectedItem,
          sourceValue;

    if (targetControl == null) {
      throw "targetControl not found";
    }
    if (!functionName) {
        throw "functionName is not set";
    }
    if (sourceControl.get(selectedItemsPropertyName) &&
    "attributes" in sourceControl.get(selectedItemsPropertyName)) {
        selectedItem = sourceControl.get(selectedItemsPropertyName);
        sourceValue = sourceControl.get(selectedItemsPropertyName).attributes[sourceProperty];
    } else {
        console.debug("Unable to get the property to set");
        return;
    }
    console.log(functionName);
    targetControl.trigger(functionName, selectedItem);
  };
  return action;
});

Now back to the rule rendering. In the properties for the newly created ResultsListControlRule I need to update the RuleItemId property to point to the RuleDefinition item I created under PageSettings. The TargetControl property was updated to ResultsListControl and the Trigger one to change:selectedItem.




When I refresh the page and click on any item in the search results, I see the same item being added to the Selected for Brochure and Build Button gets enabled.



Looks great, but a few things are still missing. I need to be able to provide a way for end user to enter the title for my brochure, and once brochure is built; user should have a way to open created pdf file.

Book Title

To provide for end user a way to enter a book title for the brochure, I am going to add two renderings to my application presentation settings: a Text and a TextBox.



First one is the Text rendering with the name of BookTitleLabel. I need to add it to the FirstRowBorderSpan10.Content placeholder and enter “Book Title: “ into the Text field.

The second rendering is the TextBox one with the name of BookTitleTextBox. It should be added to the same placeholder as the Text rendering. Below you can see the properties for this rendering.



BuildButton click event

The next big piece of functionality that needs to be implemented is the BuildButton
click event handling and providing a way to open up created by Sitecore PMX module pdf file. How PXM modules is used to create PDF Brochures is a subject for another conversation, and if you are interested in that, you can check out my blog post on PXM at http://sitecoreexperiences.blogspot.com/2013/07/sitecore-and-aps-saga-configurations.html to see how that part is done.


The next rendering to be added is a ProgressIndicator that would be displayed while the brochure is being built. I am going to name it BuildProgressIndicator and assign it to Page.Body placeholder.





To process the click event I need to add a Rule rendering called BuildButtonRule to the Page.Body placeholder. I’ll also add a RuleDefinition item called BuildButtonRuleDefinition under PageSettings and enter the following condition/action combination into the Rules field:



This rule essentially turns the visibility of the BuildProgressIndicator control to True.
In the rendering properties for the BuildButtonRule rendering the following values need to be added:



Notice RuleItemId, TargetControl and Trigger property values.



The next rule that I need to add to the Page.Body placeholder is the BuildIsDoneRule. This rule is responsible for the actions that are performed upon completion of the PDF build. As with any Rule I have to create a RuleDefinition item under PageSettings and specify the following rules:



Below are the properties for the BuildIsDoneRule rule rendering. Notice Target Control, RuleItemId and Trigger field values. Trigger Control property points to BrochureBuilderObserver control you’ll see in the next section.





If the build fails, I need to provide the user with a message about it. To accomplish that, I am going to add a Rule rendering to Page.Body placeholder with the name of BuildFailedRule. The rendering needs a RuleDefinition, so I’m going to add a BuildFailedRuleDefinition item to the PageSettings one in my application tree. The BuildFailedRuleDefinition item should have the following condition and actions in the Rules field:



Now I need to update the properties for the BuildFailedRule rendering. There are three fields that require changing:
  • RuleItemId
  • Target Control
  • Trigger
The screenshot below shows the values that need to be set in these fields.





With all rules and ProgressIndicator in place I can now create a control that will be responsible for calling the service to trigger PDF build and initiate rules execution upon success or failure of that build. For that purpose I’m going to create a new custom control called BrochureBuildObserver. I’m going to add this control to my solution under /sitecore/shell/client/Speak/Layouts/Renderings/Data/.




When Visual Studio asks for the location of the Sitecore item for the control, I am going to specify the PageSettings item. As a result Sitecore will create an item that you can see below:



Under BrochureBuilderObserver item that was just created I am going to add a new Template with the name of Parameters. This template must have ControlBase as a base template, so I’ll switch to Sitecore Content Editor and assign that base template to the Parameters template. Once that is done, I can go back to Sitecore Rocks and start editing the template. I need to add three custom properties that would be available in rendering properties window:                             
  • BookTitle – Text field value from the BookTitleTextBox control. I’ll be able to specify the binding in the properties windows between BookTitle property of BrochureBuildeObserver and the Text property of the BookTitleTextBox
  • SelectedItems – it will hold the binding between SelectedItemsListControl.Items and this property; 
  • ProgressIndicatorControl – name of the ProgressIndicator control that should become visible and spread over entire page once the build has started. Technically the BuildButtonRule will take care of visibility of this control, but I couldn't get the ProgressIndicator spread over the whole page, hence the property.

All three fields should have bindmode=readwrite value in the source field to become visible in the rendering properties window.




The next step is to update the Parameters template field in the BrochureBuilderObserver item under PageSettings and point it to the Parameters template item. When that is done, it is time to add the BrochureBuilderObserver to the list of page renderings.


Below are the property settings for the new BrochureBuilderObserver. Notice the custom section with BookTitle, SelectedItems and ProgressIndicatorControl properites.



The last step is to update the control code to read the custom properties, execute the build action and provide event handler for the DownloadButton
.
 In the JavaScript file enter the following code:

define(["sitecore"], function (Sitecore) {
    "use strict";

    var model = Sitecore.Definitions.Models.ComponentModel.extend(
      {
        initialize: function () {
            this._super();
            this.set("brochureUrl", "");
            this.set("error", "");
            this.set("booktitle", "");
            this.set("selecteditems", "");
            this.set("progressindicatorcontrol", "");
        },
        build: function () {
            var app = this.viewModel.app;

            var progressIndicatorControl = this.attributes["progressindicatorcontrol"];
            var title = this.attributes["booktitle"];
            var selectedItems = this.attributes["selecteditems"];
            var progressIndicator = app[progressIndicatorControl];
            if (progressIndicator != null) {
                progressIndicator.viewModel.height("100%");
            }
            var ids = [];
            _.each(selectedItems, function (item) {
                ids.push(item.ID());
            }, this);
            var idString = ids.join(",");

            jQuery.ajax({
                type: "GET",
                dataType: "json",
                url: "/api/sitecore/Print/Brochure",
                data: { ids: idString, title: title },
                context: this,
                cache: false,
                success: function (data) {                    
                    this.set("brochureUrl", data);
                },
                error: function (data) {
                    this.set("error", data);
                    console.log("There was an error. Try again please!");
                }
            });
        },
        openBrochure: function () {
            var url = this.get("brochureUrl");
            if (url) {
                window.open(url);
            } else {
                console.log("Brochure url is empty");
            }
        }
    });

    var view = Sitecore.Definitions.Views.ComponentView.extend(
      {
        initialize: function () {
            this._super();
            this.model.set("progressindicatorcontrol", this.$el.data("sc-progressindicatorcontrol"));
        }
    });
    Sitecore.Factories.createComponent("BrochureBuilderObserver", model, view, "script[type='text/x-sitecore-brochurebuilderobserver']");
}); 


The .cshtml file should have the following:

@using Sitecore.Mvc
@using Weil.SC.Client.Speak.UI.Controls.BrochureBuilderObserver
@model Sitecore.Mvc.Presentation.RenderingModel
@Html.Sitecore().Controls().BrochureBuilderObserver(this.Model.Rendering)

As you can see from the code above the view outputs the control by rendering a C# class. The name of that class is BrochureBuilderObserver. Below you can see the source code for it.

    public class BrochureBuilderObserver : ComponentBase
    {
        public string BookTitle { get; set; }
        public string SelectedItems { get; set; }
        public string ProgressIndicatorControl { get; set; }
        
        protected string BookTitleBinding { get; set; }
        protected string SelectedItemsBinding { get; set; }

        public BrochureBuilderObserver()
        {
            this.Requires.Script("controls", "brochurebuilderobserver.js");
        }

        public BrochureBuilderObserver(RenderingParametersResolver parametersResolver)
            : base(parametersResolver)
        {
            Assert.ArgumentNotNull((object)parametersResolver, "parametersResolver");
            this.Requires.Script("controls", "brochurebuilderobserver.js");
            
            this.BookTitle = parametersResolver.GetString("BookTitle", "booktitle");
            this.SelectedItems = parametersResolver.GetString("SelectedItems", "selecteditems");
            this.ProgressIndicatorControl = parametersResolver.GetString("ProgressIndicatorControl", "progressindicatorcontrol");
            
            this.BookTitleBinding = parametersResolver.GetString("BookTitleBinding");
            this.SelectedItemsBinding = parametersResolver.GetString("SelectedItemsBinding");
        }

        protected override void PreRender()
        {
            base.PreRender();
            if (!string.IsNullOrEmpty(this.ProgressIndicatorControl))
                this.SetAttribute("data-sc-progressindicatorcontrol", this.ProgressIndicatorControl);
            this.SetAttribute(HtmlTextWriterAttribute.Type, "text/x-sitecore-brochurebuilderobserver");
            if (!string.IsNullOrEmpty(this.BookTitleBinding))
                this.AddBinding("booktitle", this.BookTitleBinding);
            if (!string.IsNullOrEmpty(this.SelectedItemsBinding))
                this.AddBinding("selecteditems", this.SelectedItemsBinding);
        }

        protected override void Render(HtmlTextWriter output)
        {
            this.AddAttributes(output);
            output.RenderBeginTag(HtmlTextWriterTag.Script);
            output.RenderEndTag();
        }
    }

I am also going to add an MVC helper for this control:

    public static class ControlsExtension
    {
        public static HtmlString BrochureBuilderObserver(this Sitecore.Mvc.Controls controls, Sitecore.Mvc.Presentation.Rendering rendering)
        {
            Assert.ArgumentNotNull((object)controls, "controls");
            Assert.ArgumentNotNull((object)rendering, "rendering");
            return new HtmlString(new BrochureBuilderObserver(controls.GetParametersResolver(rendering)).Render());
        }

        public static HtmlString CustomSearchDataSource(this Sitecore.Mvc.Controls controls, string controlId, object parameters = null)
        {
            Assert.ArgumentNotNull((object)controls, "controls");
            Assert.ArgumentNotNull((object)controlId, "controlId");
            BrochureBuilderObserver control = new BrochureBuilderObserver((RenderingParametersResolver)controls.GetParametersResolver(parameters));
            control.ControlId = controlId;
            return new HtmlString(control.Render());
        }
    }




To make sure Cancel button at the top of the screen closes the window when clicked, I will add a CancelButtonRule rendering to the Page.Body placeholder. The rule definition item with the name CancelButtonRuleDefinition for this rule was is added under PageSettings. In the Rules field of the CancelButtonRuleDefinition item I am going to add:



In the properties window I will update RuleItemId, TargetControl and Trigger fields.


Conclusion

After all necessary renderings are added to the page, you should have page layout settings look like this:


When application page is loaded, you should be able to see the list of items returned from the controller, search through the content, filter search results using facets, select items for brochure, build brochure PDF (if you have PXM module installed and configured) and download generated PDF file. If everything works, you will see three screens that are shown below:





This concludes this overview of Brochure Building SPEAK application creation process. I hope you have found it useful. Happy SPEAKing!