Deep dive - New customization options in Kentico CMS 4.1
As I promised, I bring the details of new customization features of Kentico CMS 4.1. Just don't read this post if you don't have any previous experience, because it could just confuse you. At least study the basics and see the API before you start.
Hi everybody,
During the development of version 4.1, we have cooperated with some of our larger partners who did an extensive integrations with our solution. Their part of the cooperation was to tell us what they need for the smooth integration, while ours was to design and implement new features that would provide this kind of easy customization.
So far, I told you how you can integrate your custom code to the admin interface, which was already covered in the previous versions quite well, but as we were discussing the lower levels of API, it has come to mind that some features that would need customization might require quite a lot of changes in the source code even for quite simple achievements. When this happens, you have to deal with these changes on every upgrade, which is not as smooth as we all wish.
The main topics were:
- How to customize our system objects and extend them with additional structured data and fields - This was sort of possible before, but only with system tables, which limits the developer to only SQL field types and specific objects within the CMS.
- If someone needs to completely move one database table to another storage (not necessarily SQL DB), how to deal with existing foreign keys to such table and provide valid views that use this table. - A typical example is the table of users, which is joined to many views for specific reasons (typically listings)
- How to be able to preprocess the queries and postprocess the results with custom code - Since some parts of the UI work with DataSets and not objects, the same type of customization as in point 1 may be needed for the entire DataSets of object data.
Luckily, we found quite handy solutions for all of them, making the solution better customizable than ever before.
Before you start, you should at least know something about current ways of customization, see
How to effectively customize Kentico CMS UI for details.
Customize any system objects with your data and objects
This is probably the best customization improvement that we did, because it has a lot of potential in it. Making the objects customizable was something that most of the integrations needed, because you usually need to ship your own, related objects with ours.
The magic key to do this since version 4.1 is the property named
RelatedData (object) and interface
IRelatedData. If the object implements this interface, you can use this property to connect just any object to the CMS object and ship your data with it.
How to use it? Very simply, just assign the object with the data, and retrieve it (if you are working with the same object later).
Not only that, you are even able to make this object behave quite as a native part of the CMS object and retrieve it's properties as it were the CMS object properties. If your custom object assigned to RelatedData implements the interface
IDataContainer, it is automatically connected to the methods
GetValue and
SetValue of the parent object.
Let's see some examples, I will customize the SiteInfo object (which you cannot customize using system tables), and my extension
SiteRegistration will add two new fields and one method:
- SiteOwner - String, just the name of the site owner (let's pretend I have a hosting company)
- SiteValidUntil - DateTime until which the site is valid (the hosting fee is prepaid)
- bool IsValid() - Returns true if the site is valid at current time
Here is my
SiteRegistration class code:
SiteRegistration.cs
Now, if i can define the values, I can insert them to the
SiteInfo object and use them like this (just put this into the layout of the page to validate it is working):
<asp:Label runat="server" id="lblSiteInfo" />
<script runat="server">
protected void Page_PreRender(object sender, EventArgs e)
{
CMS.SiteProvider.SiteInfo currentSite = CMSContext.CurrentSite;
if (currentSite.RelatedData == null)
{
// Get the related data from your external storage (make sure it implements IDataContainer. I will just create some on-the-fly
SiteRegistration sr = new SiteRegistration();
sr.SiteOwner = "Martin Hejtmanek";
sr.SiteValidUntil = DateTime.Now.AddDays(1);
currentSite.RelatedData = sr;
}
this.lblSiteInfo.Text = String.Format("Site '{0}' is valid until {1} and owned by {2}.", currentSite.DisplayName, currentSite.GetValue("SiteValidUntil"), currentSite.GetValue("SiteOwner"));
}
</script>
The result is:
Site 'Corporate Site' is valid until 9/15/2009 1:09:50 PM and owned by Martin Hejtmanek.
So this is the case where I can access the physical object before I need to access the value. But that is not that often, let's see how to do that before anything accesses the data. This example is sort of stupid, because you can access the data within the context of execution, this gives you just some sort of caching for this data (within the context object). The important thing here is that after I fill the object with data I may work with the data as it were native object data (through
GetValue,
SetValue methods).
Where this becomes interesting is the point, where you can
bind the related data dynamically on request to any object because then you can deliver them to just any code that uses such object. Let's see the example:
There is an event
OnLoadRelatedData (among other) which you can use for specific object types. The event is part of the type information object referenced from the object info class. So just create a new event like this and register it in the
AfterApplicationStart event in ~/App_Code/Global/CMS/CMSApplication.cs (just move the SiteRegistration initialization code in there):
///
/// Fires after the application start event
///
public static void AfterApplicationStart(object sender, EventArgs e)
{
// Add your custom actions
CMS.SiteProvider.SiteInfo.TYPEINFO.OnLoadRelatedData += new CMS.SettingsProvider.TypeInfo.ObjectLoadRelatedDataEventHandler(SiteInfo_OnLoadRelatedData);
}
static object SiteInfo_OnLoadRelatedData(CMS.SettingsProvider.IInfoObject infoObj)
{
// Get the related data from your external storage (make sure it implements IDataContainer. I will just create some on-the-fly
SiteRegistration sr = new SiteRegistration();
sr.SiteOwner = "Martin Hejtmanek";
sr.SiteValidUntil = DateTime.Now.AddDays(1);
return sr;
}
Now any site object whenever you ask it about the property and if it already doesn't have the related data fires this event, and you give the object the data. You can check it if you change your code in the layout to following:
<asp:Label runat="server" id="lblSiteInfo" />
<script runat="server">
protected void Page_PreRender(object sender, EventArgs e)
{
CMS.SiteProvider.SiteInfo currentSite = CMSContext.CurrentSite;
this.lblSiteInfo.Text = String.Format("Site '{0}' is valid until {1} and owned by {2}.", currentSite.DisplayName, currentSite.GetValue("SiteValidUntil"), currentSite.GetValue("SiteOwner"));
}
</script>
See how nicely it works? Now we can get rid of the C# code in our layout, because we ensured that the site object always provides these additional fields. Let's replace this with just a macro. Put a static text web part to your page template and set it's text property to:
Site '{%CMSContext.CurrentSite.SiteDisplayName%}
And the result is the same as the one produced by our previous code! This is exactly how you can use your own related data in the properties of your web parts by using macros. You can do the same with users / products / just anything.
The method IsValid is for you, I didn't use it on purpose so you may try to play around with it. You use it like this: ((SiteRegistration)CMSContext.CurrentSiteName.RelatedData).IsValid(), what you use it for is completely up to you, I would recommend redirecting to some special page in case the site is not valid or so :-)
As you can see, I din't do any changes to the source code, and I used the AfterApplicationStart event which is completely upgrade-proof. Everything else was just custom code (and later just text) within the page template.
And that's all about shipping your objects with ours. I know this example doesn't do much, it would be too long if it would, so just try to think if you can use something like this for your integration and let us other know how you did.
How to preprocess / postprocess queries
This basically covers both last points from our list of topics, because in case of missing foreign keys and data in our tables, you can have the default views without the data, and fill the missing data to the result in the result post-processing.
Before you start with this part,
backup you database and project, if you plan to use it further! You may break something while trying different things with these. And if you use this, backup that also before any other significant change of your project.
I don't recommend you to use this unless this is absolutely necessary, there are other things you can do and this is by far the lowest level customization (side by side with your own data provider). I just wanted you to know that something like this is available.
There are two events for this in the SqlHelperClass:
OnBeforeExecuteQuery and
OnAfterExecuteQuery
They do pretty much what their names are. The first one is executed before any query is executed and you may influence the query behavior and code dynamically on the fly, here is the delegate definition for it.
/// <summary>
/// Query execution event handler
/// </summary>
/// <param name="query">Executed query</param>
/// <param name="conn">Connection</param>
public delegate void BeforeExecuteQueryEventHandler(QueryParameters query, IDataConnection conn);
You may modify the QueryParameters object, which contains not only the query name (so you can change the particular query only, but also the query itself which you can modify). Here is some example of how you use it (again, you register it in the AfterApplicationStart event):
///
/// Fires after the application start event
///
public static void AfterApplicationStart(object sender, EventArgs e)
{
// Add your custom actions
CMS.SettingsProvider.SqlHelperClass.OnBeforeExecuteQuery += new CMS.SettingsProvider.SqlHelperClass.BeforeExecuteQueryEventHandler(BeforeExecuteQuery);
}
static void BeforeExecuteQuery(CMS.SettingsProvider.QueryParameters query, CMS.IDataConnectionLibrary.IDataConnection conn)
{
if (query.Name != null)
{
switch (query.Name.ToLower())
{
case "cms.user.selectall":
query.Text = query.Text.Replace("CMS_User", "View_CMS_User");
break;
}
}
}
I didn't come up with other example that would work with default database, so I am just switching selection from table CMS_User to view View_CMS_User which contains the table (to not break anything), if you are experienced user with specific needs, just thing about something useful you could need to do this way (if you really can't do that another way).
The other handler is probably the one you may use more often, it has signature like this:
/// <summary>
/// Query execution event handler
/// </summary>
/// <param name="query">Executed query</param>
/// <param name="conn">Connection</param>
/// <param name="result">Query result</param>
public delegate void AfterExecuteQueryEventHandler(QueryParameters query, IDataConnection conn, ref DataSet result);
And as you can see, you may change the result of the query. As you can see, it is a DataSet, so it is available only for the calls using ExecuteQuery, not others (like ExecuteReader etc.)
I have several modifications that I could use as an example, I will show you how you can globally format the user data coming from the database (not that it would be useful, just so you can see you can modify the resulting data). Again, I am binding the event in the AfterApplicationStart event:
///
/// Fires after the application start event
///
public static void AfterApplicationStart(object sender, EventArgs e)
{
// Add your custom actions
CMS.SettingsProvider.SqlHelperClass.OnAfterExecuteQuery += new CMS.SettingsProvider.SqlHelperClass.AfterExecuteQueryEventHandler(AfterExecuteQuery);
}
static void AfterExecuteQuery(CMS.SettingsProvider.QueryParameters query, CMS.IDataConnectionLibrary.IDataConnection conn, ref DataSet result)
{
if (query.Name != null)
{
switch (query.Name.ToLower())
{
case "cms.user.selectall":
if (result != null)
{
DataTable dt = result.Tables[0];
foreach (DataRow dr in dt.Rows)
{
dr["FullName"] = dr["FirstName"] + " " + dr["MiddleName"] + " " + dr ["LastName"];
}
}
break;
}
}
}
This code basically gives dynamically generated full name instead of the one that is set in the user settings. It may not be present in the admin UI, since it is only replacing the results of this one query and admin UI uses more than that, but it should give you decent example of what you can do with it.
And that is pretty much all for now. I hope it wasn't too low-level for you, and that you get what you needed.
See you next time.