Filter content with taxonomies and reusable field schemas
Advanced content series
This guide is the second one of the Advanced content series and the examples build on top of the previous guide.
Taxonomies in Xperience by Kentico provide a powerful tool for your customers to categorize and organize content.
Each taxonomy is a group of related tags (e.g., PC features: external GPU, disc drive, tower size…). Content editors assign these tags to content items with a field allowing the given taxonomy. Then they can, for example, configure a widget to display a list of content items with a specific set of tags.
In this tutorial, you will learn how to empower your content editors to do just that - filter content items by taxonomy tags in a widget listing.
In the process, you will also discover that you can easily query items of different content types as long as they share a reusable field schema. Let’s dive in!
Before you start
This guide requires the following:
- Familiarity with C#, .NET Core, Dependency injection, and the MVC pattern.
- A running instance of Xperience by Kentico, preferably 29.2.0 or higher.Some features covered in the Training guides may not work in older versions.
- Basic experience with Page Builder widgets and reusable field schemas.
Code samples
You can find a project with completed, working versions of code samples from this guide and others in the finished branch of the Training guides repository.
The main branch of the repository provides a starting point to code along with the guides.
The code samples in this guide are for .NET 8 only.
They come from a project that uses implicit using directives. You may need to add additional using
directives to your code if your project does not use this feature.
Consider the scenario
In the previous guide, we explored reusable field schemas through the lens of content model evolution. You have adjusted your project to work with two new Article content types that share an Article schema, alongside with a deprecated one.
Perhaps you recall that when you added the Article list widget to your Home page to check your progress and selected a parent page from the content tree, the widget rendered articles of all content types, both old and new.
Now imagine that your customer’s content editors want more control. They’re asking for the ability to filter articles in the widget by specific tags, so they can display only the articles that match the categories they’re interested in.
Examine the current state
Take a look at the Article schema in Xperience administration. Notice the ArticleSchemaCategory field with the Taxonomy data type. This field allows editors to assign one or more tags from the Article category taxonomy to any content item implementing this schema.
The Article category taxonomy already exists in our project and includes two tags: Animals and Plants. You can find it in the Taxonomies application under the Configuration category.
When you examine any of the Article (general) or Article (interview) items in the Content hub, you’ll see they all have tags or an option to add tags in the Category field.
Implement the filtering
With this setup in place, we can implement filtering based on the Article category taxonomy.
Add new widget property
First, let’s add a tag selector to the widget UI to so that editors can select the desired tags.
Find the definition of the Article list widget properties in the Training guides repository.
Add a new IEnumerable<TagReference>
property called Tags
between the existing ContentTreeSection
and TopN
. Decorate the property with TagSelectorComponent
attribute.
Specify allowed taxonomy passing our taxonomy code name, ArticleCategory
, as the first parameter.
To refresh your memory on defining widget properties revisit our Build a simple call-to-action widget guide.
...
using CMS.ContentEngine;
namespace TrainingGuides.Web.Features.Articles.Widgets.ArticleList;
public class ArticleListWidgetProperties : IWidgetProperties
{
[WebPageSelectorComponent(
Label = "Select the content tree section",
MaximumPages = 1,
Sortable = true,
Order = 10)]
public IEnumerable<WebPageRelatedItem> ContentTreeSection { get; set; } = Enumerable.Empty<WebPageRelatedItem>();
// new property definition
[TagSelectorComponent(
"ArticleCategory",
Label = "Filter to categories",
ExplanationText = "Select 0, 1 or more Article Type tags. Shows all if none are selected",
Order = 15)]
public IEnumerable<TagReference> Tags { get; set; } = Enumerable.Empty<TagReference>();
...
}
...
Run the project and navigate to Page Builder mode of any page that contains Article list widget. In the widget configuration you should see the new tag selector control allowing you to pick Animals or Plants tags.
Adjust widget view component
Now, let’s add some logic to the widget view component. It needs to account for the tags the editor has chosen when retrieving content.
Take a look at the ArticleListWidgetViewComponent.cs file.
The InvokeAsync
method calls RetrieveArticlePages
to fetch the relevant article pages from the content hub, passing in the editor’s content tree selection.
Then, it orders and further filters the articles before constructing a view model and returning the widget view.
...
public async Task<ViewViewComponentResult> InvokeAsync(ArticleListWidgetProperties properties)
{
var model = new ArticleListWidgetViewModel();
if (!properties.ContentTreeSection.IsNullOrEmpty())
{
var articlePages = await RetrieveArticlePages(properties.ContentTreeSection.First());
model.Articles = (properties.OrderBy.Equals("OldestFirst", StringComparison.OrdinalIgnoreCase)
? (await GetArticlePageViewModels(articlePages)).OrderBy(article => article.CreatedOn)
: (await GetArticlePageViewModels(articlePages)).OrderByDescending(article => article.CreatedOn))
.Take(properties.TopN)
.ToList();
model.CtaText = properties.CtaText;
}
return View("~/Features/Articles/Widgets/ArticleList/ArticleListWidget.cshtml", model);
}
...
We’ll adjust the private method RetrieveArticlePages
to consider the new Tags
property in the following way:
...
// add IEnumerable of tag references as a new parameter
private async Task<IEnumerable<ArticlePage>> RetrieveArticlePages(WebPageRelatedItem parentPageSelection, IEnumerable<TagReference> tags)
{
var selectedPageGuid = parentPageSelection.WebPageGuid;
var selectedPage = await genericPageRetrieverService.RetrieveWebPageByGuid(selectedPageGuid);
string selectedPageContentTypeName = await GetWebPageContentTypeName(selectedPageGuid);
string selectedPagePath = selectedPage?.SystemFields.WebPageItemTreePath ?? string.Empty;
// if no tags are specified, retrieve the Article pages in the same way as before
if (tags.IsNullOrEmpty())
{
return await articlePageRetrieverService.RetrieveWebPageChildrenByPath(
selectedPageContentTypeName,
selectedPagePath,
3);
}
// otherwise process the tags and retrieve the appropriate pages accordingly
else
{
// code to retrieve article pages based on tags will go here
}
}
...
TagReference
class.
...
using CMS.ContentEngine;
...
Now there is one issue we need to solve:
Our widget expects Article page content items to construct its view model. However, the property holding our taxonomy tags is on the reusable content item linked by the page.
Because of the current limitations of the content query API, we will do this in two steps:
- Retrieve the IDs of all reusable content items that implement Article schema and contain the specified tags.
- Retrieve Article page content items on the specified path that link these reusable items.
Implement the service methods
Let’s go implement these service methods in our ContentItemRetrieverService.cs file .
Retrieve reusable content items by schema and tags
Our ContentItemRetrieverService
class has an existing RetrieveContentItems
method that calls the content query API:
public class ContentItemRetrieverService : IContentItemRetrieverService
{
...
private async Task<IEnumerable<IContentItemFieldsSource>> RetrieveContentItems(Action<ContentQueryParameters> contentQueryParameters,
Action<ContentTypesQueryParameters> contentTypesQueryParameters)
{
var builder = new ContentItemQueryBuilder();
builder.ForContentTypes(contentTypesQueryParameters)
.Parameters(contentQueryParameters);
return await contentQueryExecutor.GetMappedResult<IContentItemFieldsSource>(builder);
}
...
}
We will utilize it to retrieve the reusable content items that contain:
- GUIDs of specific tags, using the WhereContainsTags extension method.
- our Article schema, using the OfReusableSchema extension method.
The data we need to perform this query will become method parameters:
- the code name of the reusable field schema
- the name of the database column where we expect to find the tags
- the GUIDs of the required tags
Declare the method in the IContentItemRetrieverService
interface and implement it in the ContentItemRetrieverService
class.
public interface IContentItemRetrieverService
{
...
public Task<IEnumerable<IContentItemFieldsSource>> RetrieveContentItemsBySchemaAndTags(
string schemaName,
string taxonomyColumnName,
IEnumerable<Guid> tagGuids);
}
...
public class ContentItemRetrieverService : IContentItemRetrieverService
{
...
public async Task<IEnumerable<IContentItemFieldsSource>> RetrieveContentItemsBySchemaAndTags(string schemaName, string taxonomyColumnName, IEnumerable<Guid> tagGuids)
{
Action<ContentQueryParameters> contentQueryParameters = parameters
=> parameters.Where(where => where.WhereContainsTags(taxonomyColumnName, tagGuids));
Action<ContentTypesQueryParameters> contentTypesQueryParameters = parameters
=> parameters.OfReusableSchema(schemaName);
return await RetrieveContentItems(contentQueryParameters, contentTypesQueryParameters);
}
...
}
Retrieve Article pages by path and reference
Next, we need a method that will fetch all child Article pages on a specific path which also link any of specified reusable content items.
Our genericContentItemRetrieverService<T>
already has the RetrieveWebPageChildrenByPath
method (which our widget currently uses).
...
public class ContentItemRetrieverService<T> : IContentItemRetrieverService<T>
{
...
public async Task<IEnumerable<T>> RetrieveWebPageChildrenByPath(
string parentPageContentTypeName,
string parentPagePath,
int depth = 1)
{
var builder = new ContentItemQueryBuilder()
.ForContentType(
parentPageContentTypeName,
config => config
.ForWebsite(webSiteChannelContext.WebsiteChannelName, [PathMatch.Children(parentPagePath)])
.WithLinkedItems(depth))
.InLanguage(preferredLanguageRetriever.Get());
var queryExecutorOptions = new ContentQueryExecutionOptions
{
ForPreview = webSiteChannelContext.IsPreview
};
var pages = await contentQueryExecutor.GetMappedWebPageResult<T>(builder, queryExecutorOptions);
return pages;
}
...
}
...
We can reuse the functionality – all we need is an additional method call in content query parameters to pass in the references.
Add a new private overload of the RetrieveWebPageChildrenByPath
. Additional a new nullable customContentTypeQueryParameters
parameter to pass in an anonymous method.
...
public class ContentItemRetrieverService<T> : IContentItemRetrieverService<T>
{
...
private async Task<IEnumerable<T>> RetrieveWebPageChildrenByPath(
string parentPageContentTypeName,
string parentPagePath,
Action<ContentTypeQueryParameters>? customContentTypeQueryParameters,
int depth = 1) { }
...
}
...
Extract all the code from the public RetrieveWebPageChildrenByPath
method.
Add a condition to construct the contentQueryParameters
based on whether the caller did or did not pass in any customContentTypeQueryParameters
.
...
public class ContentItemRetrieverService<T> : IContentItemRetrieverService<T>
{
...
private async Task<IEnumerable<T>> RetrieveWebPageChildrenByPath(
string parentPageContentTypeName,
string parentPagePath,
Action<ContentTypeQueryParameters>? customContentTypeQueryParameters,
int depth = 1)
{
Action<ContentTypeQueryParameters> contentQueryParameters = customContentTypeQueryParameters != null
// if there are any custom parameters, add them to the contentQueryParameters
? config => customContentTypeQueryParameters(config
.ForWebsite(webSiteChannelContext.WebsiteChannelName, [PathMatch.Children(parentPagePath)])
.WithLinkedItems(depth)
)
// otherwise, keep the contentQueryParameters as they were in the original method
: config => config
.ForWebsite(webSiteChannelContext.WebsiteChannelName, [PathMatch.Children(parentPagePath)])
.WithLinkedItems(depth);
var builder = new ContentItemQueryBuilder()
.ForContentType(
parentPageContentTypeName,
contentQueryParameters
)
.InLanguage(preferredLanguageRetriever.Get());
var queryExecutorOptions = new ContentQueryExecutionOptions
{
ForPreview = webSiteChannelContext.IsPreview
};
var pages = await contentQueryExecutor.GetMappedWebPageResult<T>(builder, queryExecutorOptions);
return pages;
}
...
}
Now change the public RetrieveWebPageChildrenByPath
method code to simply call this new private method:
...
public class ContentItemRetrieverService<T> : IContentItemRetrieverService<T>
{
...
public async Task<IEnumerable<T>> RetrieveWebPageChildrenByPath(
string parentPageContentTypeName,
string parentPagePath,
int depth = 1
// calling the new private method
) => await RetrieveWebPageChildrenByPath(parentPageContentTypeName, parentPagePath, null, depth);
...
}
...
Last but not least, implement a new public method that retrieves pages from a given path which link to the specified content items. As far as parameters go, in addition to page content type name, path and depth, it needs the IEnumerable of content item IDs and the code name of the reference field.
Call our new private RetrieveWebPageChildrenByPath
method and pass in custom content type query parameters - an anonymous function calling the Linking extension method.
public class ContentItemRetrieverService<T> : IContentItemRetrieverService<T>
{
...
public async Task<IEnumerable<T>> RetrieveWebPageChildrenByPathAndReference(
string parentPageContentTypeName,
string parentPagePath,
string referenceFieldName,
IEnumerable<int> referenceIds,
int depth = 1
) => await RetrieveWebPageChildrenByPath(
parentPageContentTypeName,
parentPagePath,
config => config.Linking(referenceFieldName, referenceIds),
depth);
...
}
...
Remember to also declare the new method in the generic IContentItemRetrieverService
interface.
...
public interface IContentItemRetrieverService<T>
{
...
public Task<IEnumerable<T>> RetrieveWebPageChildrenByPathAndReference(
string parentPageContentTypeName,
string parentPagePath,
string referenceFieldName,
IEnumerable<int> referenceIds,
int depth = 1
);
...
}
...
See the full ContentItemRetrieverService file in the finished branch of our Training guides repo.
Put it all together
Our backend work is done. Let’s utilize the new methods in the ArticleListWidgetViewComponent.
Find the empty else
statement inside the RetrieveArticlePages
method. Add logic for the case when the editor did specify the tags to filter by:
...
private async Task<IEnumerable<ArticlePage>> RetrieveArticlePages(WebPageRelatedItem parentPageSelection, IEnumerable<TagReference> tags)
{
...
else
{
// first extract the list of tag GUIDs
var tagGuids = tags.Select(tag => tag.Identifier).ToList();
// retrieve the IDs of Article schema reusable content items
// to specify the schema, use the REUSABLE_FIELD_SCHEMA_NAME constant from the generated IArticleSchema interface
// pass in the name of the ArticleSchemaCategory field
var taggedArticleIds = (
await genericPageRetrieverService.RetrieveContentItemsBySchemaAndTags(
IArticleSchema.REUSABLE_FIELD_SCHEMA_NAME,
nameof(IArticleSchema.ArticleSchemaCategory),
tagGuids)
).Select(article => article.SystemFields.ContentItemID);
// retrieve and return Article page content items on specified path that reference the reusable content items
// pass in the name of the ArticlePageArticleContent field, which we use to link reusable article item to Article page
return await articlePageRetrieverService.RetrieveWebPageChildrenByPathAndReference(
selectedPageContentTypeName,
selectedPagePath,
nameof(ArticlePage.ArticlePageArticleContent),
taggedArticleIds,
3);
}
}
...
RetrieveArticlePages
method from InvokeAsync
to retrieve the article pages.
...
public async Task<ViewViewComponentResult> InvokeAsync(ArticleListWidgetProperties properties)
{
var model = new ArticleListWidgetViewModel();
if (!properties.ContentTreeSection.IsNullOrEmpty())
{
// call the updated method passing in the Tags property
var articlePages = await RetrieveArticlePages(properties.ContentTreeSection.First(), properties.Tags);
model.Articles = (properties.OrderBy.Equals("OldestFirst", StringComparison.OrdinalIgnoreCase)
? (await GetArticlePageViewModels(articlePages)).OrderBy(article => article.CreatedOn)
: (await GetArticlePageViewModels(articlePages)).OrderByDescending(article => article.CreatedOn))
.Take(properties.TopN)
.ToList();
model.CtaText = properties.CtaText;
}
return View("~/Features/Articles/Widgets/ArticleList/ArticleListWidget.cshtml", model);
}
...
See the results
Rebuild and run your project. Navigate to a page (e.g., Home) where you added the Article list widget. Configure the widget to show articles with different tags, and see the filtered results when you Apply the changes.
Find the complete and working implementation of this guide in the finished branch of the Training guides repository . If you are experiencing any issues or errors, double-check that your references, methods, parameters and using
directives match the ones in the repo.
What’s next?
In this series, we have explored reusable field schemas and taxonomies in the context of content model evolution using the expand and contract approach.
We have talked mostly about the ‘expand’ part – adjusting your application to work with both the new and old content types. The next step would be to ‘contract’ your solution – removing all the deprecated code and items. Stay tuned for an upcoming guide on how to migrate your old data to complete the process.
If you are interested in the expand and contract approach, read this Community portal blog on the topic.
To learn more about taxonomies from the content modeling perspective, check out our Model a reusable article guide. If you’d like to get a better perspective on how business users and editors work with taxonomies in Xperience, we recommend you take a look at our Work with taxonomies business guides.