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.

Content types list page UI

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 ๐Ÿ˜Ž.