Dealing with large number of items in the content tree

  • Mar 30, 2015
  • EPiServer
  • Page Tree
  • |

The content tree can grow really fast when content editors start using EPiServer. Especially when all old data (pages) needs to be inserted or imported in the CMS. As a developer you should think about this when setting up the content tree structure. So for example news items can be categorized on date, which is also a best practice. A news item for example can be found in the tree: Home –> News –> 2015 –> 01 –> 08 –> ‘ This is a news item’. Joel Abrahamsson wrote a blog about building large scale episerver sites. So when you’re building a site that will contains large volume of pages it’s a good idea to create a hierarchical content structure. EPiServer can handle large number of pages, but the content tree could perform slow if a node contains hundreds child nodes.

The easiest and probably the best solution would be to categorize your pages in the content tree. I was thinking about another solution, just don’t show the child items but give the content editor the opportunity to search for a specific item.

So what have I done? In the content tree I made sure that the child items aren’t loaded and displayed. Next I created a content system page (actually a content folder type) that will do nothing at all. If an editor clicks this ‘page’ then a new edit view is loaded where editors can search for a specific item. This is how it look likes in the CMS:

 

As you can see in the content tree below the News item the item ‘[Search]’ is displayed, when this item is clicked the new ‘edit’ view is loaded. In this view editors can search or show all pages underneath the News page.

Now for the code!

Content search item

Because I didn’t want to create a new content type, such as a container type, I just create a new content item based on the existing type ‘content folder’. This page actually doesn’t do anything except for opening the edit view. The ContentSearchItem inherits from IContent, exactly what all content items in EPiServer do. Some properties needs to implemented. ContentTypeID 3 is a EPiServer build in type ‘content folder’. I also set the ProviderName to a custom implementation of a content provider, later more about this provider.  The ID of the new content item is the same as the parent item, so for this example it would be the id of the News overview item.

public class ContentSearchItem : IContent
    {
        public ContentSearchItem(int id)
        {
            var contentLink = new ContentReference(id);
            contentLink.ProviderName = "contentSearchableProvider";
            contentLink.WorkID = 1;
     
            Name = "[Search]";
            ContentLink = contentLink;
            ParentLink = ContentReference.EmptyReference;
            ContentGuid = Guid.Empty;
            ContentTypeID = 3;
            IsDeleted = false;
             
        }
        public PropertyDataCollection Property
        {
            get { return new PropertyDataCollection(); }
        }
     
        public string Name { get; set; }
     
        public ContentReference ContentLink { get; set; }
     
        public ContentReference ParentLink { get; set; }
     
        public Guid ContentGuid { get; set; }
     
        public int ContentTypeID { get; set; }
     
        public bool IsDeleted { get; set; }
    }
Custom content provider

Because the ID of the content item is the same as the news overview item I need to create a content provider that prevents the children of the actually news overview items are loaded. Later I will explain why I used the same id.

public class ContentSearchableProvider : DefaultContentProvider
    {
        protected override IContent LoadContent(ContentReference contentLink, ILanguageSelector languageSelector)
        {
            return new ContentSearchItem(contentLink.ID);
        }
     
        protected override IList<GetChildrenReferenceResult> LoadChildrenReferencesAndTypes(ContentReference contentLink, string languageID, out bool languageSpecific)
        {
            languageSpecific = true;
            return new List<GetChildrenReferenceResult>();
        }
    }

The content provider inherits from the DefaultContentProvider and overrides two methods. First the LoadContent just returns the ContentSearchItem based on the ID, Secondly the LoadChidlrenReferencesAndTypes returns an empty list, because I don’t want to show the children of the new content item. If I don’t use a custom content provider EPiServer just load the children of the news overview item, because it’s contains the same ID. EPiServer knows to use this content provider for the new content item, because I set the ProviderName on the ContentSearchItem.

TreeChildrenQuery

The TreeChildrenQuery inherits from the GetChildrenQuery class. This class is responsible for loading the child items of a node in the content tree. Because I don’t want to display the child items of the news overview item, but the new content item ContentSearchItem, I use this custom implementation. In the GetContent method I check whether the passed ReferenceId of the parameters object is of type ISearchableContent. This interface does nothing but indicates if a page type like the news overview page type should use the search functionality. The advantage of this generic solution that it would also work for other page types, such as products. So if the passed item is of type ISearchableContent it will return the content item ContentSearchItem instead of a IEnumerable of the child items.

The Filter method makes sure that the ContentSearchItem isn’t filtered out of the returned list. Normally EPiServer will check for a couple of things, like language, deleted or access rights.

At last very important the Rank property is overridden otherwise EPiServer will not always use the custom implementation of the GetChildrenQuery.

Now I will explain how I create a new edit view.

SearchContentView

When creating a new edit view, first the view needs to be defined. This can be done by inheriting from the ViewConfiguration. This class accepts a generic type. In this example I will use the IContentData as the generic type. In the constructor of the class I set a number of properties for defining the new view. The ControllerType is a path to a DOJO module, this also could be a MVC controller, but for this example I’m using DOJO.

[ServiceConfiguration(typeof(ViewConfiguration))]
    public class SearchContentView : ViewConfiguration<IContentData>
    {
        public SearchContentView()
        {
            Key = "searchContent";
            Name = "Search content view";
            Description = "Search content view";
            ControllerType = "app/editors/searchcontentview";
            HideFromViewMenu = true;
        }
    }
ContentSearchItemUIDescriptor

An UIDescriptor tells EPiServer more about the UI options for a specific content type. In this example I will do this for the content item ContentSearchItem. In the constructor I set the DefaultView property to the Key property of the ViewConfiguration (look at the previous section). And I disable the OnPageEditView and the AllPropertiesView, because my new content item isn’t really a page were things can be changed.

public class ContentSearchItemUIDescriptor : UIDescriptor<ContentSearchItem>
    {
        public ContentSearchItemUIDescriptor() 
        {
            DefaultView = "searchContent"; 
            AddDisabledView(CmsViewNames.OnPageEditView);
            AddDisabledView(CmsViewNames.AllPropertiesView);
        }
    }

So now I explained how the content tree can be adjusted and how you could define a custom edit view. Next the DOJO module.

DOJO search view

In two earlier blog post I explained how you could create your own custom property in DOJO. I won’t explain this again, so if you would like to learn more about this here are two links.

The example module contains a textfield and a search result table layout. The textfield has a change event, so the results will be instantly displayed in the results table. The module itself won’t do the actually search for content, a store will handle this.

define([
         "dijit",
         "dojo",
         "dojo/_base/declare",
         "dojo/when",
         "dojo/dom-construct",
         "dojo/_base/array",
         "dojo/query",
         "dojo/text!./templates/searchcontenttemplate.html",
     
         "dijit/_Widget",
         "dijit/_TemplatedMixin",
         "dijit/_WidgetsInTemplateMixin",
     
         "epi/dependency",
         "epi/routes"
    ], function (
        dijit,
     
        dojo,
        declare,
        when,
        domConstruct,
        array,
        query,
        template,
     
        _Widget,
        _TemplatedMixin,
        _WidgetsInTemplateMixin,
     
        dependency,
        routes
    ) {
        return declare("app.editors.searchcontentview", [
            _Widget, _TemplatedMixin, _WidgetsInTemplateMixin
        ], {
            _tableNode: null,
            templateString: template,
            intermediateChanges: false,
            store: null,
            contentStore: null,
            currentContentId: 1,
     
            postCreate: function () {
                this.tableNode = domConstruct.create("table", {
                    className: "pagesTable",
                    id: "pagesTable"
                });
     
                var colgroup = domConstruct.create("colgroup");
                colgroup.appendChild(domConstruct.create("col", {
                    width: "400"
                }));
                colgroup.appendChild(domConstruct.create("col", {
                    width: "150"
                }));
                colgroup.appendChild(domConstruct.create("col", {
                    width: "150"
                }));
                this.tableNode.appendChild(colgroup);
     
                var tableHead = domConstruct.create("thead");
                var tableBody = domConstruct.create("tbody");
                var headerRow = domConstruct.create("tr");
                headerRow.appendChild(domConstruct.create("th", { innerHTML: "Page" }));
                headerRow.appendChild(domConstruct.create("th", { innerHTML: "Created by" }));
                headerRow.appendChild(domConstruct.create("th", { innerHTML: "Created" }));
     
                tableHead.appendChild(headerRow);
                this.tableNode.appendChild(tableHead);
                this.tableNode.appendChild(tableBody);
                this.resultList.appendChild(this.tableNode);
     
                this.inputWidget.set("intermediateChanges", true);
     
                var registry = dependency.resolve("epi.storeregistry");
                registry.create("app.searchpages", routes.getRestPath({ moduleArea: "app", storeName: "searchpages" }));
                this.store = registry.get("app.searchpages");
     
                this.contentStore = registry.get("epi.cms.content.light");
     
                this.connect(this.inputWidget, "onChange", this._onInputWidgetChanged);
                this.connect(this.showAllLink, "onclick", this._onShowAllLinkClicked);
     
                var contextService = epi.dependency.resolve("epi.shell.ContextService");
                var currentContext = contextService.currentContext;
                var res = currentContext.id.split("_");
     
                this.currentContentId = res[0];
            },
     
            _clearTableBody: function() {
                var tableBody = query("tbody", this.tableNode)[0];
     
                domConstruct.empty(tableBody);
            },
     
            _onShowAllLinkClicked: function (event) {
                event.preventDefault();
                this._clearTableBody();
                 
                dojo.when(this.store.query({id: this.currentContentId, value: "" }), this._buildRows);
            },
     
            // Event handler for the changed event of the input widget         
            _onInputWidgetChanged: function (value) {
                this._clearTableBody();
     
                dojo.when(this.store.query({ id: this.currentContentId, value: value }), this._buildRows);
            },
     
            _buildRows: function (result) {
                var tableNode = dojo.byId("pagesTable");
                var tableBody = query("tbody", tableNode)[0];
     
                array.forEach(result, function (item) {
                    var newRow = domConstruct.create("tr");
                    var pageColumn = domConstruct.create("td", {
                        innerHTML: "<a class="page-item-link" href="&quot; + item.url + &quot;">" + item.name + "</a>"
                    });
                    var createdByColumn = domConstruct.create("td", {
                        innerHTML: item.createdBy
                    });
                    var createdAtColumn = domConstruct.create("td", {
                        innerHTML: item.created
                    });
     
                    newRow.appendChild(pageColumn);
                    newRow.appendChild(createdByColumn);
                    newRow.appendChild(createdAtColumn);
     
                    tableBody.appendChild(newRow);
                }, this);
            }
        });
    });
SearchPagesStore

A store is RestController and can be used for DOJO modules retrieving data. I’ve created a store for doing the search on pages. The Get method of the store accepts two parameters, first the id of the current content. As mentioned earlier the new content item has the same id as the news overview item. So when the new content item is selected and the new edit view is opened the current context can retrieve this id. This id is passed to the store so the store knows from which parent item (news overview) the search needs to be done. The second argument of the Get method is the search criteria. For now I’m doing a simple search against all descendant items of the news overview item. Currently I’m working to extend this example with a EPiServer Find solution, I keep you posted.

[RestStore("searchpages")]
    public class SearchPagesStore : RestControllerBase
    {
        private readonly IContentRepository _contentRepository;
        private readonly UrlResolver _urlResolver;
     
        public SearchPagesStore(IContentRepository contentRepository)
        {
            _contentRepository = contentRepository;
            _urlResolver = UrlResolver.Current;
        }
     
        public RestResult Get(int id, string value)
        {
            if (id == 0)
            {
                id = ContentReference.StartPage.ID;
            }
            var references = _contentRepository.GetDescendents(new ContentReference(id));
     
            var pages = _contentRepository.GetItems(references, LanguageSelector.AutoDetect());
     
            var result = pages.Where(p => ((string.IsNullOrEmpty(value) || p.Name.IndexOf(value, StringComparison.InvariantCultureIgnoreCase) != -1) && p is PageData))
                .OrderByDescending(p => ((PageData)p).Created)
                .Select(p => new
                {
                    Name = p.Name,
                    Url = string.Format("/EPiServer/Cms/#context=epi.cms.contentdata:///{0}", p.ContentLink.ID),
                    Created = ((PageData)p).Created.ToString("dd-MM-yyyy hh:mm"),
                    CreatedBy = ((PageData)p).CreatedBy
                });
     
            return Rest(result);
        }
    }

So all of this makes up the solution for dealing with large items in the content tree. The full source code can be find at my GitHub. Again currently I’m working on a solution that using EPiServer Find for finding pages, also I will use facets so content editors can easily find a content item. I will keep you posted!

Comments