DEV Community

Cover image for Bits of Xperience: Supercharged Page Custom Data Fields
Sean G. Wright for WiredViews

Posted on

Bits of Xperience: Supercharged Page Custom Data Fields

Kentico Xperience's custom Page Types let us model a site's content using a combination of powerful built-in Form Controls and the flexible structuring of information in a site's Content Tree.

Usually, with Page Type fields, each field maps to one value (as a column in a database table), and each Page Type has a unique set of fields. But, what if we want to store multiple fields in a single database column, or have multiple Page Types that store data in a single location, making it easy to query ๐Ÿค”?

There might not be an out-of-the-box solution, but fortunately, with a little code and configuration ๐Ÿค“, we can use Page "Custom Data" to achieve both of these things.

If you want to jump to the solution, check out our new NuGet package that does all the coding for you, Xperience Page Custom Data Control Extender.

๐Ÿ“š What Will We Learn?

  • What are Page "Custom Data" fields?
  • What do the "Custom Data" Page fields lack?
  • Using Global Events with Page "Custom Data"
  • Using Custom Form Controls and a Control Extender
  • Storing Page data directly in "Custom Data" fields

โ“ What are Page "Custom Data" fields?

Before we get going, let's level set. Page "Custom Data" fields are the DocumentCustomData column in the CMS_Document table and the NodeCustomData column in the CMS_Tree table.

Anything in, or related to, the CMS_Tree table is going to apply to all cultures for a Page in the Content Tree, and likewise, anything in, or related to, the CMS_Document table is going to be specific to a single culture. Many sites only have a single culture, so this distinction might not be something you're used to thinking about ๐Ÿคจ.

Read more about the Kentico Xperience Page database architecture ๐Ÿง.

All "Custom Data" database columns (there are many in non-Page ones Xperience) have an XML structure, and the C# API to interact with them works with a XmlData container behind the scenes, almost like a string-keyed dictionary.

"Custom Data" columns let us switch from a relational database architecture to more of a document structure where the schema of the data isn't defined in the database, but instead in our code.

It would be great ๐Ÿ‘๐Ÿพ to leverage this alternate way of storing data to achieve what was discussed earlier (multiple values per column and Pages storing field values for multiple Page Types in the same location, instead of separate Page Type database tables).

๐Ÿ—ƒ What do the Page "Custom Data" Page Fields Lack?

First, let's review why we can't ๐Ÿ˜ž use Page "Custom Data" fields as they currently exist in Xperience.

If we look at the the documentation on the features of the field editor, which is used for creating fields for custom Page Types, we can see there are a couple options for the "Field type". The one we are interested in is the "Page Field":

Page field โ€“ only available for page type fields. Allows you to choose a general page column from the CMS_Tree or CMS_Document table, and link it to the page field.

When creating a new Page Type field and selecting a Field type of Page field we can select either Page fields or Node fields for the Group. This equates to columns from the CMS_Document and CMS_Tree tables.

If we select Page fields and then pick the Field name of DocumentCustomData, we can start interacting directly with this value for each Page of the given Page Type ๐Ÿ˜€.

Page Type field editor using DocumentCustomData directly

Because the XML schema of these fields is flexible, there's no special "Custom Data" Form Control ๐Ÿ˜ฆ that let's us modify that XML in a way that is friendly to Content Managers.

The best we can do is a Rich Text editor:

Rich text editor showing plain text

But it falls way too short of something usable for Content Managers ๐Ÿ˜ฃ:

Rich text editor showing XML

So what are we going to do if we want to leverage the schema flexibility of "Custom Data" fields for a Page? What Form Control gives us a good Content Management experience? Do we have to build a bunch of custom Web Forms Form Controls โ˜ ?

๐Ÿ“… Using Global Events and Page "Custom Data"

Fortunately there's a couple different ways we can approach this problem ๐Ÿ˜….

We already have a bunch of pre-built Form Controls which are designed for ease-of-use for Content Managers. Let's make sure our solution includes those ๐Ÿ‘๐Ÿผ and doesn't require us to rewrite them!

Let's create a new field on our Page Type named ArticleIsInSitemap, using all the standard Page Type field functionality:

Page Type field dialog

If we create new fields on our Page Type and use the appropriate standard Form Controls for those fields, we can get a solid Content Management experience, but the values go into individual database columns in each Page Type's database table ๐Ÿคฆ๐Ÿฝโ€โ™€๏ธ instead of the Page "Custom Data" columns.

Thankfully, Kentico Xperience has a full set of Global Events that allow developers to react to things happening within the system ๐Ÿ‘จ๐Ÿฟโ€๐Ÿ”ฌ. We can use these events to copy data from our Page Type fields to the "Custom Data" XML structures of the Page.

Let's create a Custom Module that will give us a place to react to these events:

using CMS;
using CMS.DataEngine;
using CMS.DocumentEngine;
using CMSApp;

[assembly: RegisterModule(typeof(DocumentEventsModule))]

namespace CMSApp
{
    public class DocumentEventsModule : Module
    {
        public DocumentEventsModule() : 
            base(nameof(DocumentEventsModule)) { }

        protected override void OnInit()
        {
            base.OnInit();

            DocumentEvents
                .Insert
                .Before += Insert_Before;

            DocumentEvents
                .Update
                .Before += Update_Before;
        }

        private void Update_Before(
            object sender, DocumentEventArgs e) => 
            SetValuesInternal(e);

        private void Insert_Before(
            object sender, DocumentEventArgs e) =>
            SetValuesInternal(e);
    }
}
Enter fullscreen mode Exit fullscreen mode

Both of the event handlers above let us run our logic when any Page is inserted or updated with the following method:

private void SetValuesInternal(DocumentEventArgs e)
{
    if (!(e.Node is Article article))
    {
        return;
    }

    article
        .DocumentCustomData
        .SetValue(
            nameof(Article.Fields.IsInSitemap), 
            article.Fields.IsInSitemap);
}
Enter fullscreen mode Exit fullscreen mode

And that's it! Every time an Article is inserted into the Content Tree or updated, the value in ArticleIsInSitemap will be copied to an XML element in CMS_DocumentCustomData ๐Ÿ˜Š, which will look like this in the database:

<CustomData>
  <IsInSitemap>true</IsInSitemap>
</CustomData>
Enter fullscreen mode Exit fullscreen mode

What's the benefit here? Don't we already have the value in the DancingGoatCore_Article table's ArticleIsInSitemap column?

Well, for data that determines whether or not a Page is in the sitemap, we want to be able to query across all Pages of the site, not just Articles, so that we generate the correct XML sitemap.

If we have a column in each table for all of our custom Page Types, we'd end up with a real ugly SQL UNION to get all the Pages in the sitemap. By copying the value to DocumentCustomData, we ensure the full sitemap can be generated by only querying the CMS_Document table ๐Ÿ’ช๐Ÿป:

SELECT *
FROM CMS_Document
WHERE CAST(DocumentCustomData as XML).value('(//CustomData/IsInSitemap/text())[1]', 'bit') = 1
Enter fullscreen mode Exit fullscreen mode

Checkout Microsoft's documentation to read about querying XML in SQL

This is great ๐Ÿ‘๐Ÿพ! But it would be really great if we didn't have to have an extra database table column per-Page Type and duplicate this data. We would prefer to write directly to the Page "Custom Data" field ๐Ÿ˜.

๐ŸŽ› Custom Form Controls and Control Extenders

Lucky for us, Kentico Xperience provides a convenient feature in the CMS architecture - Control Extenders.

Control Extenders let us enhance the functionality of inherited Form Controls. But what does that mean?

We can create new Form Controls in the Administration application, with either new code or inheriting the code and functionality from an existing Form Control. The 2nd option is preferable because it means less work for us ๐Ÿ˜„!

When we create a new Form Control that inherits from another, we can apply a Control Extender to it. A Control Extender is a component that wraps the original Form Control and gets to intercept interactions with the Control ๐Ÿง.

This is a valuable feature for us, because it will let us source the Control's value from DocumentCustomData when it is read and write it to DocumentCustomData when the Control value is updated - all without modifying the code or functionality of the original control. We can also apply this Control Extender to any inheriting Form Control ๐Ÿ˜ฎ.

In summary, this is what we want to accomplish:

  • โœ” Create a Control Extender that redirects interactions with the Form Control's value to the DocumentCustomData field and store the value in an XML element with the same name as the field's name
  • โœ” Create a new Form Control that inherits from the standard Check box Form Control and apply the Control Extender to the new Form Control
  • โœ” Use the new Form Control as the control for our Article Page Type IsInSitemap field

๐Ÿ— Control Extender

The code for the Control Extender is pretty simple:

public class CustomDataControlExtender : 
    ControlExtender<FormEngineUserControl>
{
    public override void OnInit()
    {
        // logic here
    }
}
Enter fullscreen mode Exit fullscreen mode

There are multiple events the Control emits that we can register event handlers for and when the underlying Form Control initializes, we register our event handlers in Control_Init.

public override void OnInit() => 
    Control.Init += Control_Init;

private void Control_Init(object sender, EventArgs e)
{
    Control.Form.OnGetControlValue += Form_OnGetControlValue;
    Control.Form.OnAfterDataLoad += Form_OnAfterDataLoad;

    Control.Form.FieldControls.Add(Control.Field, Control);
    Control.Data.ColumnNames.Add(Control.Field);
}
Enter fullscreen mode Exit fullscreen mode

We are going to be creating our Page Type field as a "Field without database representation", which means it won't be listed in the FieldControls or ColumnNames collections that get processed when we load/save our Form, so we explicitly add it.

This way the Form treats our field as though it needs to be persisted/retrieved just like the other ones.

private void Form_OnAfterDataLoad(object sender, EventArgs e)
{
    if (!(Control.Data is TreeNode page))
    {
        return;
    }

    Control.Value = page
        .DocumentCustomData
        .GetValue(Control.Field);
}

private void Form_OnGetControlValue(
    object sender, FormEngineUserControlEventArgs e)
{
    if (!(Control.Data is TreeNode page))
    {
        return;
    }

    if (e.ColumnName.Equals(
        Control.Field, 
        StringComparison.InvariantCultureIgnoreCase))
    {
        page.DocumentCustomData.SetValue(
            Control.Field, Control.Value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we define two event handlers - the first supplies the Form Control value from the correct Page "Custom Data" field when the value is loaded by the Control, and the second accepts the value coming from the Control and stores it in the correct Page "Custom Data" field.

After our interception, the Page gets created or saved and the field's value is saved along with it, except it's inside DocumentCustomData and not a Page Type specific database column ๐Ÿคฉ.

๐Ÿ‘ต๐Ÿฝ Inheriting a Form Control

Inheriting from an existing Form Control and applying a Control Extender only takes a few steps!

First, navigate to the "Administration interface" module in the CMS application:

Administration interface menu item

Then create a new "inheriting" Form Control, using the Check box Form Control as the source:

New Form Control dialog

Be sure to select the Control Extender you just created!

Finally, select the places where this Form Control can be used. For our example it will be for "Boolean (Yes/No)" fields for "Page Types":

Form Control assignment configuration

๐Ÿ“ƒ Using a Page "Custom Data" Form Control

Now we can create the new field for our Page Type ๐Ÿ˜Š!

Be sure to select "Field without database representation" for the Field type (otherwise the value will be saved in a newly created database table column for the Page Type ๐Ÿ˜ฌ) and use our new extended Form Control (otherwise the value won't be saved at all).

Whatever we name this new field will end up being the XML element that the value is stored in, so a field named ABC would end up as <CustomData><ABC>value</ABC></CustomData>:

New Page Type field dialog

And that's it! When we save a Page of this Page Type, this specific field will only be saved to DocumentCustomData.

We can add as many "Custom Data" fields to a Page Type as we want, and if we define the same field on multiple Page Types, they'll all put data of the same XML schema in our CMS_Document.DocumentCustomData database column.

๐Ÿ Conclusion

I hope by now both the motivation and process for using Page "Custom Data" fields as the backing store of Page Type fields are clear ๐Ÿ”.

There's a few steps to set everything up - create a Control Extender, define new inheriting Form Controls using the Control Extender, and add a fields to Page Types using the extended Form Control - but the initial setup definitely pays off. It's worth noting the first step (creating a Control Extender) only needs performed once, and the second (creating an extended Form Control) only once per Form Control type (eg Text Box, Check Box, Page Selector).

Our "Sitemap" example saves us from performing a large SQL UNION when we generate a site's XML sitemap, but that's not the only use case.

What about Open Graph metadata values for a Page - wouldn't it be nice to not have to create a separate database column for each value?

Or, a standard field inherited from a base Page Type that we aren't going to be likely to filter in SQL - like a 'primary image path'.

We could even make mixins, letting multiple Page Types share sets of fields and then access those field values across Page Types by querying the CMS_Document table only ๐Ÿง.

Are you thinking about implementing this yourself? Well, it's dangerous to go alone...

zelda meme

So, take this... the Xperience Page Custom Data Control Extender, a NuGet package containing an enhanced version of the above Control Extender with detailed setup instructions ๐Ÿ‘.

As always, thanks for reading ๐Ÿ™!

References


We've put together a list over on Kentico's GitHub account of developer resources. Go check it out!

If you are looking for additional Kentico content, checkout the Kentico or Xperience tags here on DEV.

#kentico

#xperience

Or my Kentico Xperience blog series, like:

Discussion (0)