Editor Experience is a particular passion of ours at Offroadcode, and one that I like to think I'm at the forefront of. Maybe it's because I love my work, or because I like to make people smile, or because despite coding, I spend a lot of my time in the Umbraco backoffice. I try to make the assumption that if something seems difficult, it is, and it needs to be simplified. And I know, as someone intimately familiar with Umbraco over many years of working with it, if doing a task during a development stage frustrates me, then it absolutely will frustrate the site's actual content editors. When that happens, I flag a pain point and try to fix it - this is one of those cases.
The "Problem"
In version 7.1.0, Umbraco introduced the ability to change a node's Document Type in the backoffice UI. This is an incredibly useful feature and, with all the useful features released from the start of v7 to it's current version (7.10.4 as of the writing of this post), one of my favorites. It's the small things that matter, folks ;) I use this all the time, especially when developing new features that might require a more customized version of a different Document Type on a site that we've been maintaining through multiple iterations, rather than an initial build.
It was in a case like this that I recently ran into one of those pain points I mentioned. Our client wanted to move a bunch of standard text pages (headline, small banner image, rich text editor content, sidebar sub-menu) into landing pages (headline, more customizable banner image - see our TextOverImageEditor package, grid content, no sub-menu). They have more SEO/Settings properties, but those are on a composition, so they'll always map. As you can see, the only (non-composition) fields on these two Document Types that match are the headline, and that's not ideal for swapping them in a large section. Plus, while there's a certain part of the site they're targeting this change for, I knew that implementing it meant it would likely be used in the future when they realized how easy it will be to migrate from Text Page to Landing Page.
When I started my implementation, I quickly discovered that having to compare the body content, copy it into the grid, re-pick the banner image and add the headline content over it for the TextOverImageEditor was annoying, and I only did it on one page. If they have to do it for (minimum) 20 more... that's a frustration.
Pain point, targeted. Time to lance it.
The Solution
Umbraco has a lot of events (no, I don't mean meetups and festivals!) in the application that can be hooked into to manipulate content behind the scenes. I love this! It gives me so much flexibility to improve things for my editors and also lowers room for error. If something gets copied into one section from another and it requires specific settings to be set? I do it in the code. The more you remove people's need to think about extra steps, the more brainspace they have to solve problems in other areas. If my work can make an editor less stressed simply by making them think less? That's a win. Umbraco events help make that happen. In this case, I'm going to be talking about the ContentService.Saving event, but you can read about all of them in Umbraco's documentation.
The reason we're using ContentService.Saving? At the time of this post, there is no event that wires in for a Document Type change. I did some testing, swapping DocTypes and seeing which events were triggered when it happened so I could target what I needed, and ContentService.Saving is it. I would really love for there to be an event for DocType changes, so I've put in a feature request on the Umbraco Issue Tracker. If this is something you're interested in, please go thumb it up or make a PR (I'm hoping I'll find time to do it sometime...)!
So let's get coding!
Step 1: Set up the Converters
I like to code from the bottom up, so for me, the first step is to set up the conversions. In our case, there are two:
- Convert the Banner, which is a Media Picker to the Text Over Image Editor
- Convert the Content, which is a Rich Text Editor to Grid content.
In both cases, I take the original value and move them into the JSON for each property editor. I could serialize and deserialize from models, but because parsing text is fast and I can make sure it matches exactly what the Umbraco cache has, I prefer to do it this way. It makes comparing my data to the saved umbraco.config easier. You can, of course, code it however you like.
Note: Because we are using JSON, remember to escape any pasted text you have - I tend to copy an example right out of the umbraco.config file so I know it's accurate!
Text Over Image Editor
For the Media Picker to Text Over Image Editor, we're pulling an ID in and converting it into the JSON. But because the Text Over Image Editor also has options for a Headline, Sub-Headline, and Link, we're going to also take the Headline property off our Text Page and move it into the Headline of the Text Over Image Editor. This is done like so:
public static string ConvertBannerToTextOverImage(IContent model, string id)
{
var uniqueId = id.Replace("umb://media/", string.Empty);
IMedia content = UmbracoContext.Current.Application.Services.MediaService.GetById(Guid.Parse(uniqueId));
var media = content == null ? null : UmbracoContext.Current.MediaCache.GetById(content.Id);
if (media != null)
{
var bannerJson = @"{
""headline"": """ + model.GetSafeString("headline", model.Name) + @""",
""height"": ""short"",
""link"": {
""id"": 0,
""name"": ""link"",
""target"": ""_self"",
""url"": """"
},
""media"": {
""id"": " + media.Id + @",
""url"": """ + media.Url + @""",
""width"": " + media.GetPropertyValue<int>("umbracoWidth") + @",
""height"": " + media.GetPropertyValue<int>("umbracoHeight") + @",
""altText"": """ + media.GetSafeString("altText") + @"""
},
""subheadline"": ""Sub-Headline"",
""position"": ""mc""
}";
return bannerJson;
}
return string.Empty;
}
To start off with, we replace the headline using model.GetSafeString("headline", model.Name). This is a fancy little extension method I use that checks if the property exists on the model, has a value, and then returns an empty string or the second variable, which is the default text. So in this case, we're replacing the "Headline" field for the Text Over Image Editor with the "headline" property from our textPage DocType, and if that doesn't exist, we plug in the page name instead.
As for the banner image, the Text Over Image Editor actually uses IPublishedContent media and properties that can be called directly in the JSON (this is primarily for the purposes of rendering the image in the back office). So in this case, we want to make sure we don't just plug the banner's ID in, but also the URL, width, height, and if it exists - alt text, a property that the Text Over Image editor will check for automatically on an image (bonus!).
I do want to pay attention and note one particular snippet of the above code:
var uniqueId = id.Replace("umb://media/", string.Empty);
IMedia content = UmbracoContext.Current.Application.Services.MediaService.GetById(Guid.Parse(uniqueId));
var media = content == null ? null : UmbracoContext.Current.MediaCache.GetById(content.Id);
Because of the way the cache pulls from IDs and the media is stored as a UDI, we did have to do a bit of shenanigans (an extra database hit, yuck) to make this work. If you're using older versions (or hopefully newer versions that avoid this!) then you can likely avoid going this route and pushing in the ID. Check which your umbraco.config file is using and see how it goes!
The Grid
Next, we want to convert the Rich Text Editor content into the Grid. There are plenty of ways of going about this and you could do some really cool and complicated landing page generation, but for the purposes of our example, we're going to move the textPage content into the first row of our grid as it's only field, like so:
public static string ConvertRteToGridContent(string rteValue)
{
if (!string.IsNullOrEmpty(rteValue))
{
var text = JsonConvert.SerializeObject(HttpUtility.HtmlDecode(rteValue));
var gridJson = @"{
""name"": ""Content Row"",
""sections"": [
{
""grid"": 12,
""rows"": [
{
""name"": ""Content"",
""areas"": [
{
""grid"": 12,
""allowAll"": true,
""hasConfig"": false,
""controls"": [
{
""value"":" + text + @",
""editor"": {
""alias"": ""rte""
},
""active"": false
}
],
""active"": false
}
],
""label"": ""Content"",
""hasConfig"": false,
""id"": """ + Guid.NewGuid() + @""",
""active"": false
}
]
}
]
}";
return gridJson;
}
return string.Empty;
}
Despite the fact that the Grid's JSON is a bit intimidating, we're actually doing much less here. We take the Rich Text content from our original bodyText property and we put it into the control's value field as a variable. However, we also do one more piece of functionality, which is incredibly important, and that's the "id" field at the bottom of the JSON. You must generate a new Guid for this so that Umbraco can track this Grid property!
Step 2: Setting up the Event
Now that we have our converters written, we need to add a new class for SaveAndPublishEvents. You can put these right into your Startup.cs file, but since you can end up with a lot of these, I like to break them out into their own files. There are a few steps we need to go through to get this working, and I have an important note before we get started:
Because we use the previously published version to target the original copy of the node and compare it to the new one, you have to have a published version of this page for this code to work. A limitation, but one we can deal with! So, in layman's terms, what does it do?
- We check to see if the node has a previously published version
- Then we make sure that version is a textPage and it's changing to a landingPage
- We get our Rich Text property and convert it into a Grid value then assign it to the new landingPage node
- We get our banner property and convert it to a Text Over Image Editor value and assign it to the new landingPage node
And now, in code:
using System.Linq;
using Skrift.Web.Converters;
using Skrift.Web.Extensions;
using Umbraco.Core.Events;
using Umbraco.Core.Models;
using Umbraco.Core.Services;
namespace Skrift.Web.Events
{
public class SaveAndPublishEvents
{
public static void ConvertTextPageToLandingPage(IContentService sender, SaveEventArgs<IContent> args)
{
foreach (var node in args.SavedEntities)
{
if (node.HasPublishedVersion)
{
// This only works if the page has been published before...
var previousPublishedVersion = sender.GetPublishedVersion(node.Id);
// Check if this page was a Text Page and is turning into a Landing Page
if (previousPublishedVersion.ContentType.Alias == "textPage" && node.ContentType.Alias == "landingPage")
{
// Get the main Rich Text Editor content from the previous version
var rteContent = previousPublishedVersion.GetSafeString("bodyText");
// Make sure the new node actually has the Grid content
if (!string.IsNullOrEmpty(rteContent) && node.HasProperty("gridContent"))
{
// Find the specific grid content property
var property = node.Properties.First(x => x.Alias == "gridContent");
// Populate the grid content with the text from the Rich Text Editor
property.Value = DocTypeChangePropertyConverters.ConvertRteToGridContent(rteContent);
}
// Make sure the original version had a banner
if (previousPublishedVersion.WillWork("banner"))
{
// Get the specific banner property
var property = node.Properties.First(x => x.Alias == "banner");
// Convert the picked media into a Text Over Image banner with the image and headline pre-populated
property.Value = DocTypeChangePropertyConverters.ConvertBannerToTextOverImage(previousPublishedVersion, previousPublishedVersion.GetValue<string>("banner"));
}
}
}
}
}
}
}
Step 3: Add the Event Handler
The final step to getting everything to work is actually calling the event in our Startup.cs file (or whatever you name your ApplicationEventHandler). Inside ApplicationStarted, you'll want to call ContentService.Saving as we discussed at the beginning of this article, like so:
protected override void ApplicationStarted(UmbracoApplicationBase umbraco, ApplicationContext context)
{
ContentService.Saving += SaveAndPublishEvents.ConvertTextPageToLandingPage;
}
Save, build, and open your site!
Step 4: Profit!
Now, when you change a DocType, it still won't show that the items map (although it would be neat if there was a flag for that in some kind of DocTypeChanging event!), but it will map them to the new values as desired. I've put together a little sample video to show you the final results in action!
I have a repository with all the code plugged in and the example site to use it on as well. If you want to check it out in full, you can view it here. Hopefully this article helps give your editors more delightful experiences. And if you come up with some new ideas for converting content, I'd love to hear about them!