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
orCMS_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 ๐.
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:
But it falls way too short of something usable for Content Managers ๐ฃ:
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:
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);
}
}
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);
}
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>
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
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 TypeIsInSitemap
field
๐ Control Extender
The code for the Control Extender is pretty simple:
public class CustomDataControlExtender :
ControlExtender<FormEngineUserControl>
{
public override void OnInit()
{
// logic here
}
}
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);
}
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);
}
}
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:
Then create a new "inheriting" Form Control, using the Check box Form Control as the source:
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":
๐ 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>
:
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...
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
- Xperience Page Custom Data Control Extender
- Creating new fields in the Kentico Xperience field editor
- Kentico Xperience Global Events
- Google - XML sitemaps Overview
- SQL Server - xml Data Type Methods
- Kentico Xperience - Control Extenders
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.
Or my Kentico Xperience blog series, like:
Top comments (1)
This is a great article, thanks for taking the time to put it together. I wish the functionality behind all this was out of the box. This really is helpful.