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.
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.
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;
}
}
}
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);
}
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);
}
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);
}
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;
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);
}
};
});
}]);
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"
]
}
Restart your site, go to your product catalog, and voila. You can now search for product nodes, by typing SKUs.
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)
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));
}
Good Job