This post was written for Xperience by Kentico v29.0.0. Please consult the documentation for any feature or API changes when using a different version. Be sure to check the System Requirements in the product documentation.
Xperience by Kentico supports building fully customized applications within the administration UI using UI Pages. We can even create custom object types and build pages to list, create, update, and delete these objects.
What if we just want to extend something that's already in the product ๐ค?
As an example, lets consider the Content types list page within the Xperience administration UI.
Managing content types
The content types list page is where the implementation of content modeling is managed in Xperience by Kentico solutions.
In the Dancing Goat sample application we can see its various content types, which all have specific uses - Reusable content, Pages, and Email.
There is a simple search box at the top of the listing, but if we have many content types in our solution (depending on our content modeling approach) we might want to quickly see all the reusable content types, or headless and email content types.
Let's see how easily we can add a filter to this page to quickly limit the content types list to the various uses ๐๐ฝ.
Extend the page
Xperience supports extending administration UI pages with page extenders - simple C# classes that inherit from the PageExtender
class.
Here's a page extender for the ContentTypeList
page.
[assembly: PageExtender(typeof(ContentTypeListExtender))]
namespace DancingGoat.Admin.UIPages;
public class ContentTypeListExtender : PageExtender<ContentTypeList>
{
public override Task ConfigurePage()
{
_ = base.ConfigurePage();
return Task.CompletedTask;
}
}
I'm overriding the ConfigurePage()
method because that's where we can assign the custom filter we want to appear on the page.
A simple filter
To create the filter, we'll need a simple C# class to contain all the filter properties.
public class ContentTypeListFilter
{
[DropDownComponent(
Label = "Content Type Use",
Placeholder = "Any",
Options = "Reusable\r\nWebsite\r\nEmail\r\nHeadless")]
[FilterCondition]
public string? ClassContentTypeType { get; set; }
}
The DropDownComponent attribute assigns the UI form component that lists the values we can filter by. The FilterCondition
attribute lets Xperience know this property's value is meant to filter the pages results.
Filters can be more complex ๐ค than a single field!
There are some friendly conventions that filters use to reduce boilerplate and configuration. If we name our filter class C# property the exact same as the database column name we are filtering on, we don't have to set the [FilterCondition]
attribute ColumnName
property. ClassContentTypeType
is the column name in the Xperience database that holds the value we want to filter against ๐.
If you want to see what we are filtering against then run the following SQL against an Xperience by Kentico database.
SELECT DISTINCT ClassContentTypeType
FROM CMS_Class
Now, we need to assign an instance of our ContentTypeListFilter
class to the ContentTypeListExtender
page configuration.
public class ContentTypeListExtender : PageExtender<ContentTypeList>
{
public override Task ConfigurePage()
{
_ = base.ConfigurePage();
Page.PageConfiguration.FilterFormModel = new ContentTypeListFilter();
return Task.CompletedTask;
}
}
What we end up with a nice drop filter on the side of our search box ๐, which will filter the results to only those that match the "Use" we select.
That's an improvement but we can do better ๐ฏ ...
A multiselect filter
Xperience by Kentico includes a GeneralSelectorComponent which can be used as a multiselect UI Form Component. It requires a little more setup, but I think you'll agree that the outcome is nice ๐.
First, let's create a new filter class (that way you can switch between the two and explore their implementation ๐ง ).
public class ContentTypeListMultiFilter
{
[GeneralSelectorComponent(
dataProviderType: typeof(ContentTypeTypeGeneralSelectorDataProvider),
Label = "Content Type Uses",
Placeholder = "Any"
)]
[FilterCondition(
BuilderType = typeof(ContentTypeTypeWhereConditionBuilder),
ColumnName = nameof(ContentTypeInfo.ClassContentTypeType)
)]
public IEnumerable<string> ClassContentTypeTypes { get; set; }
}
We need to also create the ContentTypeTypeGeneralSelectorDataProvider
which populates the multiselect options and determines which values have been selected.
public class ContentTypeTypeGeneralSelectorDataProvider
: IGeneralSelectorDataProvider
{
private ObjectSelectorListItem<string> reusable;
private ObjectSelectorListItem<string> website;
private ObjectSelectorListItem<string> email;
private ObjectSelectorListItem<string> headless;
private ObjectSelectorListItem<string> Reusable => reusable ??= new()
{
Value = ClassContentTypeType.REUSABLE,
Text = ClassContentTypeType.REUSABLE,
IsValid = true
};
private ObjectSelectorListItem<string> Website => website ??= new()
{
Value = ClassContentTypeType.WEBSITE,
Text = ClassContentTypeType.WEBSITE,
IsValid = true
};
private ObjectSelectorListItem<string> Email => email ??= new()
{
Value = ClassContentTypeType.EMAIL,
Text = ClassContentTypeType.EMAIL,
IsValid = true
};
private ObjectSelectorListItem<string> Headless => headless ??= new()
{
Value = ClassContentTypeType.HEADLESS,
Text = ClassContentTypeType.HEADLESS,
IsValid = true
};
private static ObjectSelectorListItem<string> InvalidItem => new() { IsValid = false };
public Task<PagedSelectListItems<string>> GetItemsAsync(string searchTerm, int pageIndex, CancellationToken cancellationToken)
{
IEnumerable<ObjectSelectorListItem<string>> items =
[
Reusable,
Website,
Email,
Headless
];
if (!string.IsNullOrEmpty(searchTerm))
{
items = items.Where(i => i.Text.StartsWith(searchTerm, StringComparison.OrdinalIgnoreCase));
}
return Task.FromResult(new PagedSelectListItems<string>()
{
NextPageAvailable = false,
Items = items
});
}
public Task<IEnumerable<ObjectSelectorListItem<string>>> GetSelectedItemsAsync(IEnumerable<string> selectedValues, CancellationToken cancellationToken)
{
return Task.FromResult(selectedValues?.Select(v => GetSelectedItemByValue(v)) ?? []);
}
private ObjectSelectorListItem<string> GetSelectedItemByValue(string contentTypeTypeValue)
{
return contentTypeTypeValue switch
{
ClassContentTypeType.REUSABLE => Reusable,
ClassContentTypeType.WEBSITE => Website,
ClassContentTypeType.EMAIL => Email,
ClassContentTypeType.HEADLESS => Headless,
_ => InvalidItem
};
}
}
The first half of this class defines some cached properties we'll use for populating the options and comparing to selected results.
The second half implements the GetItemsAsync()
and GetSelectedItemsAsync()
methods, which drive the behavior of the selector.
ClassContentTypeType
is a built-in type in Xperience that defines the various types (uses) of content types - website, email, reusable, headless. Its const
fields have the same values as the database column we'll be comparing against.
Speaking of database comparison, we need to define our ContentTypeTypeWhereConditionBuilder
which translates the value returned by the GeneralSelectorComponent
into a a valid SQL WHERE
condition.
public class ContentTypeTypeWhereConditionBuilder : IWhereConditionBuilder
{
public Task<IWhereCondition> Build(string columnName, object value)
{
if (string.IsNullOrEmpty(columnName))
{
throw new ArgumentException(
$"{nameof(columnName)} cannot be a null or an empty string.");
}
var whereCondition = new WhereCondition();
if (value is null || value is not IEnumerable<string> contentTypeUses)
{
return Task.FromResult<IWhereCondition>(whereCondition);
}
_ = whereCondition.WhereIn(columnName, contentTypeUses.ToArray());
return Task.FromResult<IWhereCondition>(whereCondition);
}
}
Above, we first perform some validation to make sure the parameters are usable. Then, we check if any options have been selected in the selector, returning early with no custom IWhereCondition
when the filter is empty.
Finally, we call .WhereIn()
on a new WhereCondition
which will generate a SQL WHERE
clause like WHERE ClassContentTypeType IN ('Website', 'Email')
๐ง when the listing queries for results.
The last step is to update the filter assignment in our ContentTypeListExtender
.
public class ContentTypeListExtender : PageExtender<ContentTypeList>
{
public override Task ConfigurePage()
{
_ = base.ConfigurePage();
Page.PageConfiguration.FilterFormModel = new ContentTypeListMultiFilter();
return Task.CompletedTask;
}
}
When the listing pages loads, the filter has an even better user experience, letting us select multiple content type uses at once ๐๐พ.
It's nice to know we did all of this "by the book" ๐, without needing to hack away at or modify any of Xperience's code.
What happens when a future Refresh adds a filter to the content types list page out-of-the-box ๐ซค? If we want to prevent our code from overriding what's provided by the product, we can add a guard when assigning our custom filter.
public class ContentTypeListExtender(IEventLogService log) : PageExtender<ContentTypeList>
{
private readonly IEventLogService log = log;
public override Task ConfigurePage()
{
_ = base.ConfigurePage();
if (Page.PageConfiguration.FilterFormModel is null)
{
Page.PageConfiguration.FilterFormModel = new ContentTypeListMultiFilter();
}
else
{
log.LogWarning(
nameof(ContentTypeListExtender),
"DUPLICATE_FILTER",
loggingPolicy: LoggingPolicy.ONLY_ONCE);
}
return Task.CompletedTask;
}
}
Additionally, we have a log entry using the IEventLogService
that will warn us when Xperience starts including a filter for this page so that we can remove ๐งน our extra code.
Without specifying the loggingPolicy
above we'd see an Event Log entry every time this page loads, but LoggingPolicy.ONLY_ONCE
is a special option that ensures "events are logged only once per application lifetime."
Now we have the best of both worlds - an improved UX today and an easy update to newer versions in the future ๐ช๐ผ.
Wrap up
Xperience by Kentico's administration UI is extensible without forcing teams to take complete ownership over every page they want to customize. The PageExtender
type provides a convenient extension point for UI Pages and Xperience's built-in UI components make authoring custom listing filters a breeze ๐.