Our customer editors very often use Compare Versions functionality. The CMS has workflows enabled, so when editor change the content, he need to mark it as Ready to publish. Then one of the supervisor using Tasks list gadget will approve or reject the changes. There are many editors and just few supervisors. They daily have a lot of items to approve. And usually those are really minor changes like single word, comma or typo was corrected.
They are using “All properties compare” view. It’ a very useful EPiServer functionality with several features:
- you can compare any two versions
- the number of changes are displayed on tab
- when two property values are different, then they are mark with different background color
The EPierver 6 solution
Even if supervisor knows which property was update, reading through content and catching those small changes might be very time consuming.
Many of you who worked with EPiServer 6 probably remember that there was a tool that could improve this process. It was a version compare “With Markup”. Using this tool editor was able to show which part of text was added and which was removed. We will aim to get same effect.
Extending built-in functionality
I decided to add markup compare functionality to EPiServer 8. Two new views will be introduced:
- Extended All properties compare
- Compare with markup
In this post I will describe new “All properties compare” and in the next article “Compare with markup”.
ViewConfiguration class
First we need to define new ViewConfiguration class. It’s responsible for registering compare view on the server. Next we wil content types for which view that will be available. In our case it will be all content types, so we set generic type as IContentData. Finally, we set dojo widget class used by view (“alloy/editors/extendedAllPropertiesCompareView”). The widget will be located by the key property value (“extendedAllPropertiesCompare”).
1 2 3 4 5 6 7 8 9 10 11 12 |
[ServiceConfiguration(typeof(EPiServer.Shell.ViewConfiguration))] public class ExtendedAllPropertiesCompare : ViewConfiguration<IContentData> { public ExtendedAllPropertiesCompare() { this.Key = "extendedAllPropertiesCompare"; this.ControllerType = "epi-cms/compare/views/CompareView"; this.ViewType = "alloy/editors/extendedAllPropertiesCompareView"; this.IconClass = "epi-iconCompare"; this.HideFromViewMenu = true; } } |
Registering the editor
Compare views are defined in epi-cms/compare/command/CompareSettingsModel class inside modeOptions array. We were able to dynamically add new compare view on the backend by implementing ViewConfiguration. Unfortunately client side registration is hardcoded. The modeOptions array has two fixed items “allpropertiescompare” and “sidebysidecompare”. The CompareSettingsModel module is used to register compare views by epi-cms/compare/command/CompareCommandProvider. We need to replace this module with our custom implementation. Inside postscript method we will add two new compare views.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
define("epi-cms/compare/command/CompareCommandProvider", [ "dojo/_base/declare", "dojo/when", "epi-cms/component/command/_GlobalToolbarCommandProvider", "epi/dependency", "./CompareSettingsModel", "./CompareViewSelection", "epi/shell/command/ToggleCommand", "epi-cms/command/_NonEditViewCommandMixin", "epi/i18n!epi/cms/nls/episerver.cms.compare.command" ], function ( declare, when, _GlobalToolbarCommandProvider, dependency, CompareSettingsModel, CompareViewSelection, ToggleCommand, _NonEditViewCommandMixin, resources ) { var NonEditViewToggleCommand = declare([ToggleCommand, _NonEditViewCommandMixin]); return declare([_GlobalToolbarCommandProvider], { // summary: // A command provider providing compare commands to the global toolbar constructor: function() { this.profile = this.profile || dependency.resolve("epi.shell.Profile"); }, postscript: function () { this.inherited(arguments); var model = new CompareSettingsModel(); /* ********************************************************************************************************************************** */ /* Code in this section is different than original file */ /* ********************************************************************************************************************************** */ model.modeOptions.push( { label: "Extended All properties Compare", value: "extendedAllPropertiesCompare", iconClass: "epi-iconForms" }); model.modeOptions.push( { label: "Compare with markup", value: "compareWithMarkup", iconClass: "epi-iconForms" }); /* ********************************************************************************************************************************** */ // Need to have a settings object as well, since the global menu builder looks a this for category var settings = { category: "compare" }; this.add("commands", new NonEditViewToggleCommand({ category: "compare", settings: settings, label: resources.togglecompare.label, iconClass: "epi-iconCompare", model: model, property: "enabled" })); this.add("commands", new CompareViewSelection({ category: "compare", settings: settings, model: model, label: resources.compareviewselection.label, optionsLabel: resources.compareviewselection.label, optionsProperty: "modeOptions", property: "mode" })); } }); }); |
Client editor
The client widget extends original All properties compare view – epi-cms/compare/views/AllPropertiesCompareView. We need to override _createForm method and replace AllPropertiesTransformer with new implementation ExtendedAllPropertiesTransformer.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
define([ "dojo/_base/declare", "dojo/_base/lang", "dojo/Deferred", "dojo/when", "epi-cms/compare/views/AllPropertiesCompareView", "epi/shell/widget/FormContainer", "./extendedAllPropertiesTransformer", "./extendedFormField" ], function ( declare, lang, Deferred, when, AllPropertiesCompareView, FormContainer, ExtendedAllPropertiesTransformer, ExtendedFormField ) { return declare("alloy.editors.extendedAllPropertiesCompareView", [AllPropertiesCompareView], { _createForm: function () { // summary: // Setup the edit form. // tags: // private return new FormContainer(lang.mixin({ readOnly: !this.viewModel.canChangeContent(), metadata: this.viewModel.metadata, metadataTransformer: new ExtendedAllPropertiesTransformer({ model: this.model }), baseClass: "epi-cmsEditingForm epi-form-container epi-form-container--compare" }, this.formSettings)); } }); }); |
The extendedAllPropertiesTransformer extends AllPropertiesTransformer and set the _hint and _type values to extendedCompare
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
define([ "dojo/_base/declare", "dojo/_base/lang", "epi-cms/compare/AllPropertiesTransformer" ], function ( declare, lang, AllPropertiesTransformer ) { return declare([AllPropertiesTransformer], { _createPropertyWidget: function() { var editor = this.inherited(arguments); editor[0].settings._hint = "extendedCompare"; editor[1].settings._type = "extendedCompare"; return editor; } }); }); |
Type and hint keys will be used to locate correct FormField. In that case it should be extendedFormField. The extendedFormField will add Compare button. The onClick button event will get old and new property values. For most properties it’s just the value, but for ContentArea we need to take the items.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
define([ // dojo "dojo/_base/declare", "dojo/_base/array", "dojo/_base/lang", "dojo/when", "dojo/dom-style", "dojo/query", "dijit/form/Button", "dijit/Dialog", "epi/dependency", "epi-cms/compare/FormField", "epi/shell/form/formFieldRegistry", "xstyle/css!./compare.css" ], function ( // dojo declare, array, lang, when, domStyle, query, Button, Dialog, dependency, FormField, registry ) { var module = declare([FormField], { buildRendering: function () { this.inherited(arguments); this.own(this._compareButton = new Button({ "class": "epi-form-container__section__row__copy-button epi-chromeless", iconClass: "epi-icon-compare--copy", label: "Compare", title: "Compare two properties" }).placeAt(this.domNode, "last")); domStyle.set(this._compareButton.domNode, { "margin-right": '100px' }); this.own(this.resultDialog = new Dialog({ title: 'Compare properties - ' + this.label, content: '', style: "width: 800px" })); this._compareButton.on("click", lang.hitch(this, function () { var registry = dependency.resolve("epi.storeregistry"); var store = registry.get("compare.htmlDiff"); var left = this.getPropertyValue(this.model.rightMetadata.properties, this.name, '.epi-form-container__section__row__editor--extendedCompare'); var right = this.getPropertyValue(this.model.leftMetadata.properties, this.name, '.epi-form-container__section__row__editor--field'); when(store.add({ left: left, right: right, modelType: this.getModelType() }), lang.hitch(this, function (result) { var html = result.data; this.resultDialog.set('content', html); this.resultDialog.show(); })); })); }, getModelType: function() { var name = this.name.toLowerCase(); var modelType = array.filter(this.model.leftMetadata.properties, function (item) { return item.name.toLowerCase() == name; })[0].modelType; if (modelType == 'EPiServer.Core.ContentArea') { return 2; } return 1; }, getPropertyValue: function (properties, name, widgetSelector) { name = name.toLowerCase(); var property = array.filter(properties, function (item) { return item.name.toLowerCase() == name; })[0]; if (property.modelType == "EPiServer.Core.ContentArea") { var result = []; var caItems = query(widgetSelector + ' div.dijitTreeNode div.epi-tree-mngr--block', this.domNode); for (var i = 0; i < caItems.length; i++) { result.push(caItems[i].outerHTML); } return result; } var value = property.initialValue; // for text area split use br to join lines if (property.modelType == "System.String[]") { value = encodeURIComponent(value.join('<br/>')); } return [value]; } }); function fieldFactory(widget, parent) { var wrapper = new module({ labelTarget: widget.checkbox ? widget.checkbox.id : widget.id, label: widget.label, tooltip: widget.tooltip, name: widget.name, groupName: widget.groupName.toLowerCase(), readonlyIconDisplay: widget.readOnly, hasFullWidthValue: widget.useFullWidth }); wrapper.own(widget.watch("readOnly", function (name, oldValue, newValue) { wrapper.set("readonlyIconDisplay", newValue); })); return wrapper; } registry.add([ { type: registry.type.field, hint: "extendedCompare", factory: fieldFactory }, { type: "extendedCompare", hint: "", factory: registry.get('compare', '') } ]); return module; }); |
The functionality could work for all property types. I prepare it to work with RichTextEditor and ContentArea especially.
Compare service
To compare the data we will use server code. That’s why we need to prepare a service and register it in ModuleInitializer.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
define([ // Dojo "dojo", "dojo/_base/declare", //CMS "epi/_Module", "epi/dependency", "epi/routes" ], function ( // Dojo dojo, declare, //CMS _Module, dependency, routes ) { return declare("compare.ModuleInitializer", [_Module], { // summary: Module initializer for the default module. initialize: function () { this.inherited(arguments); var registry = this.resolveDependency("epi.storeregistry"); //Register the store registry.create("compare.htmlDiff", this._getRestPath("htmlDiffStore")); }, _getRestPath: function (name) { return routes.getRestPath({ moduleArea: "App", storeName: name }); } }); }); |
And the service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
[RestStore("htmlDiffStore")] public class HtmlDiffStore : RestControllerBase { [HttpPost] public ActionResult Post(CompareParameter compareParameter) { string compareResult; compareParameter.Left = ClearText(compareParameter.Left); compareParameter.Right = ClearText(compareParameter.Right); if (compareParameter.ModelType == CompareModelType.ContentArea) { compareResult = new ContentAreaComparer().Compare(compareParameter); } else { compareResult = CompareStrings(compareParameter); } return Rest(new HtmlCompareResult() { Data = compareResult }); } private string CompareStrings(CompareParameter compareParameter) { var htmlDiff = new HtmlDiff(compareParameter.Left.First(), compareParameter.Right.First()); return htmlDiff.Build(); } private IEnumerable<string> ClearText(IEnumerable<string> values) { if (values == null) { return Enumerable.Empty<string>(); } return values.Select(ClearText).ToList(); } private string ClearText(string value) { if (string.IsNullOrEmpty(value)) { return ""; } value = System.Web.HttpUtility.UrlDecode(value); var htmlDocument = new HtmlDocument(); htmlDocument.LoadHtml(value); SortAttributes(htmlDocument.DocumentNode); value = htmlDocument.DocumentNode.OuterHtml; value = value.Replace("dijitTextBoxReadOnly ", ""); value = value.Replace("dijitTextAreaReadOnly ", ""); return value; } private static void SortAttributes(HtmlNode documentNode) { var attributes = documentNode.Attributes.OrderBy(a => a.Name).ToList(); documentNode.Attributes.RemoveAll(); documentNode.Attributes.AddRange(attributes); foreach (var childNode in documentNode.ChildNodes) { if (childNode is HtmlTextNode) { continue; } SortAttributes(childNode); } } } |
The CompareStrings method use HtmlDiff class to compare properties. I got the implementation from EPiServer 6 libraries but you could achieve very similar effect using HtmlDiff nuget library.
Custom WidgetSwitcher
Finally, we need to override epi/shell/widget/WidgetSwitcher class. In the contextChanged method ensure that _loadViewComponentByConfiguration get’s one of our view if necessary. In that case there is a need to copy the file and replace original EPiServer implementation in module.config file.
1 2 |
viewsArr.push("compareWithMarkup"); viewsArr.push("extendedAllPropertiesCompare"); |
Now after click compare button, the editor will see the differences between properties
The full source code is available on Gist