DEV Community

Cover image for How to customize searching in Umbraco list views
Søren Kottal
Søren Kottal

Posted on

How to customize searching in Umbraco list views

When dealing with large archives of content, like a blog section, product catalog or similar, using list views is a great way to declutter the content tree in Umbraco.

Simply put, enabling the list view on the archive document type, all the children of that doc type will be hidden from the tree, and in stead shown in a "content app", in form of a sortable list.

Umbraco List view of products

This makes it a lot easier to work with large archives, like the before mentioned blog section, product catalog etc.

But they don't help when you need to find your needle in the haystack of productnodes. Out of the box, Umbracos list view comes with a search function, but it is unfortunately limited to only search in the node names.

For something like a product catalog with thousands of products, it would be nice to be able to search for product nodes, by their SKUs. But this would require you to put the SKU in the node name. I would typically put the product name as the node name, and have the SKU on a property by itself.

Below you can see an example of the product catalog in the default starter kit of Umbraco, where I have searched for a products SKU. Nothing is found.

Umbraco List view where searching for a sku returns no product nodes

Luckily you can swap out the default search with your own, quite easily.

Using the magic of $http interceptors in AngularJS, you simply listen for requests to the default API endpoint for searching child nodes, and swapping it out, with your own endpoint.

Build your own search logic, by inheriting the default

For this, I have created a controller, inheriting from Umbracos own ContentController.

using System.Linq;
using Examine;
using Umbraco.Core;
using Umbraco.Core.Cache;
using Umbraco.Core.Configuration;
using Umbraco.Core.Logging;
using Umbraco.Core.Models;
using Umbraco.Core.Persistence;
using Umbraco.Core.Persistence.DatabaseModelDefinitions;
using Umbraco.Core.PropertyEditors;
using Umbraco.Core.Services;
using Umbraco.Web;
using Umbraco.Web.Editors;
using Umbraco.Web.Models.ContentEditing;

namespace skttl
{
    public class CustomListViewSearchController : ContentController
    {
        public CustomListViewSearchController(PropertyEditorCollection propertyEditors, IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper)
            : base(propertyEditors, globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper)
        {
        }

        public PagedResult<ContentItemBasic<ContentPropertyBasic>> GetChildrenCustom(int id, string includeProperties, int pageNumber = 0, int pageSize = 0, string orderBy = "SortOrder", Direction orderDirection = Direction.Ascending, bool orderBySystemField = true, string filter = "", string cultureName = "")
        {
            // get the parent node, and its doctype alias from the content cache
            var parentNode = Services.ContentService.GetById(id);
            var parentNodeDocTypeAlias = parentNode != null ? parentNode.ContentType.Alias : null;

            // if the parent node is not "products", redirect to the core GetChildren() method
            if (parentNode?.ContentType.Alias != "products")
            {
                return GetChildren(id, includeProperties, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter);
            }

            // if we can't get the InternalIndex, redirect to the core GetChildren() method, but log an error
            if (!ExamineManager.Instance.TryGetIndex("InternalIndex", out IIndex index))
            {
                Logger.Error<CustomListViewSearchController>("Couldn't get InternalIndex for searching products in list view");
                return GetChildren(id, includeProperties, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter);
            }

            // find children using Examine

            // create search criteria
            var searcher = index.GetSearcher();
            var searchCriteria = searcher.CreateQuery();
            var searchQuery = searchCriteria.Field("parentID", id);

            if (!filter.IsNullOrWhiteSpace())
            {
                searchQuery = searchQuery.And().GroupedOr(new [] { "nodeName", "sku" }, filter);
            }

            // do the search, but limit the results to the current page 👉 https://shazwazza.com/post/paging-with-examine/
            // pageNumber is not zero indexed in this, so just multiply pageSize by pageNumber
            var searchResults = searchQuery.Execute(pageSize * pageNumber);

            // get the results on the current page
            // pageNumber is not zero indexed in this, so subtract 1 from the pageNumber
            var totalChildren = searchResults.TotalItemCount;
            var pagedResultIds = searchResults.Skip((pageNumber > 0 ? pageNumber - 1 : 0) * pageSize).Select(x => x.Id).Select(x => int.Parse(x)).ToList();
            var children = Services.ContentService.GetByIds(pagedResultIds).ToList();

            if (totalChildren == 0)
            {
                return new PagedResult<ContentItemBasic<ContentPropertyBasic>>(0, 0, 0);
            }

            var pagedResult = new PagedResult<ContentItemBasic<ContentPropertyBasic>>(totalChildren, pageNumber, pageSize);
            pagedResult.Items = children.Select(content =>
                Mapper.Map<IContent, ContentItemBasic<ContentPropertyBasic>>(content))
                .ToList(); // evaluate now

            return pagedResult;

        }
    }
}

Enter fullscreen mode Exit fullscreen mode

By inheriting from ContentController, I can easily restore the default functionality when I don't need anything custom.

I have added a replication of the default GetChildren method from the ContentController, called GetChildrenCustom. It takes the same parameters, which enables me to just swap the url, when Umbraco is calling the API. But more on that later.

// get the parent node, and its doctype alias from the content cache
var parentNode = Services.ContentService.GetById(id);
var parentNodeDocTypeAlias = parentNode != null ? parentNode.ContentType.Alias : null;

// if the parent node is not "products", redirect to the core GetChildren() method
if (parentNode?.ContentType.Alias != "products")
{
    return GetChildren(id, includeProperties, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter);
}
Enter fullscreen mode Exit fullscreen mode

At first I get the parent node from the ContentService, and verifies that the parent node is the product catalog. If not, I simply return the GetChildren method from the ContentController, restoring the default functionality.

If I am in a context of a product catalog node, I can start doing my own logic.

// if we can't get the InternalIndex, redirect to the core GetChildren() method, but log an error
if (!ExamineManager.Instance.TryGetIndex("InternalIndex", out IIndex index))
{
    Logger.Error<CustomListViewSearchController>("Couldn't get InternalIndex for searching products in list view");
    return GetChildren(id, includeProperties, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter);
}
Enter fullscreen mode Exit fullscreen mode

At first I check I get the InternalIndex from Examine - if this fails, I revert to the default GetChildren again.

// find children using Examine

// create search criteria
var searcher = index.GetSearcher();
var searchCriteria = searcher.CreateQuery();
var searchQuery = searchCriteria.Field("parentID", id);

if (!filter.IsNullOrWhiteSpace())
{
    searchQuery = searchQuery.And().GroupedOr(new [] { "nodeName", "sku" }, filter);
}
Enter fullscreen mode Exit fullscreen mode

But in most cases, the InternalIndex works (I still have yet to see an Umbraco installation without the InternalIndex). I can then proceed with my search.

I'm using Examine here, because it's faster than going through the ContentService, when dealing with property values. In this example, query for nodes, where the parentId field matches my parent node id.

And if the filter parameter has a value (this is the search field in the interface), I add a search for that, looking in both the nodeName, and the sku fields.

// do the search, but limit the results to the current page 👉 https://shazwazza.com/post/paging-with-examine/
// pageNumber is not zero indexed in this, so just multiply pageSize by pageNumber
var searchResults = searchQuery.Execute(pageSize * pageNumber);

// get the results on the current page
// pageNumber is not zero indexed in this, so subtract 1 from the pageNumber
var totalChildren = searchResults.TotalItemCount;
var pagedResultIds = searchResults.Skip((pageNumber > 0 ? pageNumber - 1 : 0) * pageSize).Select(x => x.Id).Select(x => int.Parse(x)).ToList();
var children = Services.ContentService.GetByIds(pagedResultIds).ToList();

if (totalChildren == 0)
{
    return new PagedResult<ContentItemBasic<ContentPropertyBasic>>(0, 0, 0);
}

var pagedResult = new PagedResult<ContentItemBasic<ContentPropertyBasic>>(totalChildren, pageNumber, pageSize);
pagedResult.Items = children.Select(content =>
    Mapper.Map<IContent, ContentItemBasic<ContentPropertyBasic>>(content))
    .ToList(); // evaluate now

return pagedResult;
Enter fullscreen mode Exit fullscreen mode

Then onto the search. I don't want to return more nodes, than configured in the list view, so I implement paging on the search, as advised by Shannon in his blogpost.

At last I replicate some of the code from the default GetChildren method, returning similar results, but based on my examine search.

Getting the backoffice to use my search logic

As I mentioned earlier, AngularJS comes with a concept called $http interceptors. In this, you can listen and react to different things, when AngularJS handles http requests.

For this trick to work, I need to change requests for /umbraco/backoffice/UmbracoApi/Content/GetChildren (the default endpoint for child nodes), and change it to my newly created one, which is located at /umbraco/backoffice/api/CustomListViewSearch/GetChildrenCustom.

This is done easily by adding a js file containing an interceptor like this.

angular.module('umbraco.services').config([
   '$httpProvider',
   function ($httpProvider) {

       $httpProvider.interceptors.push(function ($q) {
           return {
               'request': function (request) {

                   // Redirect any requests for the listview to our custom list view UI
                   if (request.url.indexOf("backoffice/UmbracoApi/Content/GetChildren?id=") > -1)
                       request.url = request.url.replace("backoffice/UmbracoApi/Content/GetChildren", "backoffice/api/CustomListViewSearch/GetChildrenCustom");

                   return request || $q.when(request);
               }
           };
       });

   }]);
Enter fullscreen mode Exit fullscreen mode

Note how I left out /umbraco from the urls being searched for. Some people like to change the backoffice folder name from umbraco to something else - security by obscurity and the likes. By just looking at the latter part of the url, I can support both.

Lastly I have to make sure Umbraco finds and includes my interceptor. This is done with a package.manifest file in my App_Plugins folder.

{
  "javascript": [
    "/App_Plugins/CustomListViewSearch/CustomListViewSearch.js"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Restart your site, go to your product catalog, and voila. You can now search for product nodes, by typing SKUs.

Umbraco List View where searching for a SKU returns a product node

For completeness, you could also implement an ISearchableTree. This powers the global backoffice search, and enables your editors to simply press Ctrl+Space on their keyboard, and start searching.

I hope you liked this article, and if there is something you would like to know more about, feel free to comment, or tweet me :)


Photo by Peter Kleinau on Unsplash

Top comments (2)

Collapse
 
riccardodangelo profile image
riccardodangelo

Thanks Soren for your post.
Your approach is simple and effective.
I noticed that the order by custom field didn't work anymore. So I added these couple of line of code:

var children = Services.ContentService.GetByIds(pagedResultIds).ToList()
.OrderBy(o => o.GetValue(orderBy)); //added as default ascending orderBy

if (orderDirection == Direction.Descending)
{
children = children.OrderByDescending(o => o.GetValue(orderBy));
}

Collapse
 
lorenzbattaglia profile image
Lorenzo Battaglia

Good Job