Views: 876
Number of votes: 12
Average rating:

A property viewer plugin for multilingual sites

A challenge I have found when working on large multilingual Episerver builds is that there are often times where you want to review the value of a property across all languages - for example when debugging an issue on the site or comparing how a togglable feature is configured for different languages.

Doing this using the Epi CMS UI can be a slightly laborious task – load the page in the CMS, select a language, navigate to correct tab, find the property, rinse and repeat for each language. This approach works fine when reviewing a few languages but becomes unworkable when managing as many as 35 languages, as we do for some clients at Zone.

To provide a speedier approach, I created a custom admin plugin that allows you to compare the values of a property across on languages on a single screen.

It works by navigating to a page using the content tree, and then selecting the property you want to view:

Configuration

The plugin is built using the Episerver GuiPlugin attribute with an Authorize attribute to ensure that it is only accessible to admins

[GuiPlugIn(
    DisplayName = "Property Viewer",
    Area = PlugInArea.AdminMenu,
    Url = "~/plugins/propertyviewer")]
[Authorize(Roles = "Administrators,WebAdmins")]
public class PropertyViewerController : Controller

and a custom route to allow MVC actions to be called in the standard way.

[InitializableModule]
public class CustomRouteInitialization : IInitializableModule
{
    public void Initialize(InitializationEngine context)
    {
        RouteTable.Routes.MapRoute(
            null,
            "plugins/propertyviewer/{action}",
            new { controller = "PropertyViewer", action = "Index" });
    }

    public void Uninitialize(InitializationEngine context)
    {
    }
}

Displaying a content tree

Although Episerver has a Web Forms component available (PageTree) to render the site content tree, there is no out-of-the-box solution for displaying the tree in an MVC plugin. I used jsTree, a third-party jQuery plugin, to render the content which was quick to setup and easy to configure.

To display the tree, I provide a url for an AJAX endpoint of the plugin which will return the first two levels of content, and is called again when subsequent children in the tree are expanded.

<script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-3.3.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jstree/3.2.1/jstree.min.js"></script>
…
<div id="jstree" class="epi-paddingVertical-small episerver-pagetree-selfcontained"></div>
<script>
    $(function() {
        $("#jstree").jstree({
            "core": {
                "data": {
                    'url': "/plugins/propertyviewer/getcontenttree/",
                    "dataType": "json",
                    "data": function(node) {
                        // jstree requests # for root of tree
                        return { "id": node.id === "#" ? "1" : node.id };
                    }
                }
            },
            "root": "1"
        });
    });
</script>

The GetContentTree endpoint in the plugin takes a page id as a parameter and returns details about the page and its children, using IContentLoader to fetch content from Episerver. jsTree expects a JSON object with a node’s id, name and an array of children with the same information.

Setting the children property to true indicates that the node has children and means that it will be closed initially. When it is expanded, another request is sent to the endpoint to fetch the next levels of the tree.

public JsonResult GetContentTree(int id = 1)
{
    var page = _contentLoader.Get<PageData>(new ContentReference(id));

    return Json(new
    {
        id,
        text = page.Name,
        children = _contentLoader.GetChildren<PageData>(page.ContentLink)?.Select(s => new
        {
            text = s.Name,
            id = s.ContentLink.ToReferenceWithoutVersion().ID,
            children = _contentLoader.GetChildren<PageData>(s.ContentLink)?.Any()
        })
    }, JsonRequestBehavior.AllowGet);
}

Displaying page properties

To display a dropdown list of page properties, I bind to the select_node event (triggered when a node in the tree is selected) and call the GetProperties endpoint. The selected node id is passed as a parameter, and the returned html for the dropdown list is rendered below the content tree. The selected node id is also stored in a hidden input for use with other js events.

$("#jstree").on("select_node.jstree",
    function (e, data) {
        $("#PageId").val(data.node.id);
        $.ajax("/plugins/propertyviewer/getproperties/?pageId=" + data.node.id).done(
            function (data) {
                $("#blockPropertyList").html("");
                $("#results").html("");
                $("#propertyList").html(data);
        });
});

The GetProperties action of the controller fetches the list of standard page properties for the specified ID, and returns a partial view containing the dropdown list of properties.

public PartialViewResult GetProperties(int pageId)
{
    var page = _contentLoader.Get<PageData>(new ContentReference(pageId));
    var model = new PropertyListModel
    {
        PageProperties = page.Property
                            .Where(x => x.IsPropertyData)
                            .Select(x => x.Name)
                            .OrderBy(x => x)
    };

    return PartialView(PropertyListView, model);
}

Displaying property values

Once a page has been selected and a property chosen, we need to display a table containing the values of the property for each language.

The plugin can handle properties in two ways:

  • in the case of most properties, it simply displays the string representation for the property
  • for local blocks (i.e. a fixed block added as a property), it provides an additional dropdown to choose the block property to view

When a property is selected from the dropdown, we call the GetPropertyValues endpoint with the page id and property name, and either display the values for the property, or display the block property list.

$("#propertyList").on("change", "select", function () {
    $.ajax("/plugins/propertyviewer/getpropertyvalues/?pageId="
        + $("#PageId").val() + "&propertyname=" + this.value).done(
        function (data) {
            if (data.indexOf("BlockPropertyName") > -1) {
                $("#blockPropertyList").html(data);
                $("#results").html("");
            } else {
                $("#blockPropertyList").html("");
                $("#results").html(data);
            }
    });
});

The GetPropertyValues action checks whether the specified property is a block type using propertyData.Type and returns the list of block properties if it is block. For other property types, the property values are returned.

public PartialViewResult GetPropertyValues(PropertyReference reference)
{
    if (IsBlock(reference))
    {
        var blockModel = BuildBlockPropertyListModel(reference);
        return PartialView(BlockPropertyListView, blockModel);
    }

    var model = BuildPropertyValuesModel(reference);

    return PartialView(PropertyValuesView, model);
}

To fetch the values of a property for each language, we need to use IContentRepository.GetLanguageBranches to get the language-specific instances of the page, and then fetch the value of the property for each instance.

private PropertyValuesModel BuildPropertyValuesModel(PropertyReference reference)
{
    var languageVersions = _contentRepository.GetLanguageBranches<PageData>(new ContentReference(reference.PageId));
    var propertyValues = languageVersions.Select(x => new PropertyValueModel
    {
        Language = x.Language.Name,
        Value = x.GetPropertyValue(reference.PropertyName)
    });

    return new PropertyValuesModel
    {
        PropertyValues = propertyValues
    };
}

In the situation where the property is a local block, we return a partial view containing a dropdown of the block properties, constructed in a similar way to the page property dropdown.

private BlockPropertyListModel BuildBlockPropertyListModel(PropertyReference reference)
{
    var page = _contentLoader.Get<PageData>(new ContentReference(reference.PageId));
    var property = page.Property.Get(reference.PropertyName);

    return new BlockPropertyListModel
    {
        BlockProperties = ((BlockData) property.Value).Property
            .Where(x => x.IsPropertyData)
            .Select(x => x.Name)
            .OrderBy(x => x)
    };
}

Finally, we bind to the change event of the block property dropdown and call a GetBlockPropertyValues endpoint that builds a table of the block property values in the same way as the page property values, but fetching the value from the property on the block.

var propertyValues = languageVersions.Select(x => new PropertyValueModel
{
    Language = x.Language.Name,
    Value = x.Property
            .GetPropertyValue<BlockData>(propertyName)
            .GetPropertyValue(blockPropertyName)
});

The results are rendered in a simple HTML table using the default Epi table styling with a special case to display a tick for true Boolean properties.

@model AlloyTemplates.Plugins.PropertyViewer.Models.PropertyValuesModel

<table class="epi-default">
    <colgroup>
        <col style="width:10%">
        <col style="width:90%">
    </colgroup>
    <thead>
        <tr>
            <th>Language</th>
            <th>Value</th>
        </tr>
    </thead>
    @foreach (var item in Model.PropertyValues)
    {
        <tr>
            <td>
                @item.Language
            </td>
            <td>
                @RenderValue(item.Value)
            </td>
        </tr>
    }
</table>

@helper RenderValue(string propertyValue)
{
    if (propertyValue == "True")
    {
        <img src="/App_Themes/Default/Images/Tools/png/Check.png" alt="True" align="center" />
    }
    else
    {
        @propertyValue
    }
}

Improvements

This version of the plugin works well for simple properties – text, numbers, Booleans and any property that provides a comprehensive ToString method. It is also useful for Content References where it will show the content ID, and values stored as JSON where it will display the JSON object. It doesn’t currently work for complex property types or Content Areas since it uses the string representation of the property.

Future improvements I would like to make are:

  • display a thumbnail for images
  • allow the user to select a block in a content area and a property from that block
  • handle blocks nested to more than 1 level
  • create as an add-on so it can be easily shared and added to projects

However, in its current form it is still an extremely useful tool to have available as a first port of call for quickly auditing a property on a large multilingual site.

Apr 18, 2019

KennyG
(By KennyG, 4/18/2019 5:37:36 PM)

This is super cool! And there's a lot that can be learned from your breakdown. Thanks!

valdis
(By valdis, 4/21/2019 7:24:00 PM)

nice! can't see from video (I'm old) - but would be cool if you can add link to actual content as well. so that editors / admins could jump straight to the content and make necessary edits if needed.

Per Nergård
(By Per Nergård, 4/22/2019 4:12:45 PM)

Haven't tried it yet but this looks very nice!

I love tools that make life easier!

Jake Jones
(By Jake Jones, 4/22/2019 9:42:34 PM)

Really nice!

Henrik Fransas
(By Henrik Fransas, 4/23/2019 9:38:43 AM)

Very nice!
Like it a lot!

Ollie Philpott
(By Ollie Philpott, 4/23/2019 12:53:15 PM)

@valdis iljuconoks Thanks for the feedback, that's a great idea - I'll add it to the list of enhancements I want to implement.

GOSSO
(By GOSSO, 4/23/2019 1:14:18 PM)

Nice!! Seems like you could use it to find properties that is not used too. Just by choosing startpage and then a property, is that so? or maybe it is not recursive...

Have you opensourced it, can i contribute? 

regards

Ollie Philpott
(By Ollie Philpott, 4/24/2019 11:43:12 AM)

@GOSSO Thanks! Do you mean properties that have no values set for any language? You could manually search through page properties for those ones using the plugin if so.

I'm currently setting it up as an add-on module, and will share the code with you once I have finished that.

Please login to comment.