A deep dive into an award-winning Umbraco editor experience
Jesper Mayntzhusen

Jesper Mayntzhusen @jemayn

About: I enjoy sharing code and knowledge I pick up during my daily work whenever I can!

Joined:
Mar 17, 2021

A deep dive into an award-winning Umbraco editor experience

Publish Date: Jun 24
1 0

This week I attended Codegarden, the annual Umbraco tech conference. My company, Ecreo, had submitted a site that I had taken part in building for the Umbraco Award in the category "Best Editor Experience" - and wednesday evening at the awards ceremony we were announced as winners!

Me receiving the award for best editor experience

I've always found it a bit frustrating seeing all these amazing sites and features being showcased at the Umbraco Awards and then not seeing anything more about the tech behind them - so here is a post explaining one major feature built on the Dansani website to improve editor experience!

NOTE: This site is running Umbraco 13, any examples will be in AngularJS and are not useable on Umbraco 14+.

The problem to solve

When we were building a new site for Dansani they already had a PIM for product data and a DAM for media. In their DAM (Digital Asset Management) they had tagged a lot of images with product SKUs (Stock Keeping Units) from the PIM (Product Information Management) in order to keep track of which products appeared on the images.

Since they already made the connection and we had to do an integration with both the PIM and DAM they were hoping for the tagged SKUs to be displayed as products in the frontend of the site:

Image description

At first we thought we would have to make a custom media picker to pick images from their DAM. However, the DAM - Kontainer - already had a closed-source Umbraco package that the client was used to using. The smart part about this package is that it came with a media picker that sends you to the DAM portal where you can use the full capabilities of the system to manage your media. Once you are done and select an image it has a callback to Umbraco where the image is picked:

Image description

The package would then save a JSON blob to the database that looks something like this:

[
    {
        "url": "https://dansani.kontainer.com/cdn/5FPG1rz/GaqfKzSvrtHW/vcali-10d-shape120-25.webp",
        "urlBaseName": "https://dansani.kontainer.com/cdn/5FPG1rz/GaqfKzSvrtHW/vcali-10d-shape120-25",
        "thumbnailUrl": "https://dansani.kontainer.com/cdn/5FPG1rz/GaqfKzSvrtHW/vcali-10d-shape120-25.webp",
        "type": "image",
        "extension": "webp",
        "description": null,
        "alt": null,
        "fileId": 15245169,
        "fileName": "vcali_10d_shape120_25.tif",
        "folderId": 527324,
        "token": null,
        "external": null,
        "cf": {
            "Article number": "631112403\nC09-1099\nBI1\nBI2\nS939-099\nPO2SK\nC75-0099\n\nBreezy Blue\nNCS S 3010-B10G",
            "Variants": "631112403\nBI1\nBI2\nC09-1099\nC75-0099\nPO2SK\nS939-099",
            "File category": "Images room sets",
            "Series": "Dansani Calidris",
            "Alt tags DK": "Stort og lækkert badeværelse med badeværelsesmøbler i lyse, blå farver og holdt i nordisk stil",
            "Alt tags NO": "Stort og lekkert bad med baderomsmøbler i lyse, blå farger og holdt i nordisk stil",
            "Alt tags SE": "Stort och läckert badrum med badrumsmöbler i ljusa, blåa färger i nordisk stil",
            "Alt tags FI": "Suuri ja herkullinen kylpyhuone, jossa kylpyhuonekalusteet vaaleissa, sinisissä väreissä ja pohjoismaiseen tyyliin",
            "Alt tags UK": "Lovely large bathroom with furniture in light, blue colors oozing with Nordic style",
            "Alt tags DE": "Großes und stilvolles Badezimmer mit Badezimmermöbeln in hellblauen Farben und nordischem Stil",
            "Alt tags NL": "Grote moderne badkamer met badkamermeubel in lichte, blauwe kleuren in een Scandinavische stijl"
        },
        "downloadTemplateId": 7675,
        "originalWidth": 3000,
        "originalHeight": 3900,
        "originalSize": 70227052
    }
]
Enter fullscreen mode Exit fullscreen mode

Which means when we saved an image we actually saved all the custom fields like alt texts in different languages and the tagged product SKUs.

However, the custom ValueConverter (the code that takes the saved value and converts into a typed model for the frontend) didn't use any of the custom fields so they were stripped away by the time the frontend is hit.

Additionally, the client didn't want the product tagging to always occur, and may not want every tagged product to occur even when some should.

Extending the closed-source media picker package

The backoffice in v13 is written in AngularJS which has some very powerful extension capabilities - for example you can create an interceptor that listens to a request for a view file and replaces that view file with your own.

Shout out to Dave Woestenborghs for writing about intercepting views with AngularJS many years ago: https://skrift.io/issues/changing-backoffice-functionality-without-changing-core-code/

We quickly found that the media picker used a view found in the App_Plugins folder on the path: /App_Plugins/Kontainer/editors/mediapicker.html, so we could register our own plugin to intercept that.

/App_Plugins/KontainerExtension/package.manifest:

{
    "javascript": [
        "~/App_Plugins/KontainerExtension/interceptor.js",
    ]
}
Enter fullscreen mode Exit fullscreen mode

/App_Plugins/KontainerExtension/interceptor.js:

angular.module('umbraco.services').config([
    '$httpProvider',
    function ($httpProvider) {
        $httpProvider.interceptors.push(function () {
            return {
                'request': function (request) {
                    // Redirect any requests to the kontainer media picker to our own view
                    if (request.url.indexOf("/App_Plugins/Kontainer/editors/mediapicker.html") === 0) {
                        request.url = '/App_Plugins/KontainerExtension/mediapicker.html';
                    }
                    return request;
                }
            };
        });
    }]);
Enter fullscreen mode Exit fullscreen mode

So basically we say any requests going to the packages mediapicker.html view, send it to our own mediapicker.html view instead - so far pretty easy!

Our own view was a direct copy of the package view, however, they had the little remove and edit buttons when you hover an image. We wanted to add a third button:

Image description

Which you can see I've added here at the top of the list of buttons:

<div class="umb-sortable-thumbnails__actions" data-element="sortable-thumbnail-actions">
    <!-- @@CUSTOM BUTTON EXTENSION-->
    <button type="button" aria-label="Edit products" class="umb-sortable-thumbnails__action btn-reset"
            data-element="action-products" ng-click="vm.products(item, $index)">
        <i class="icon icon-box-alt" aria-hidden="true"></i>
    </button>
    <!-- @@CUSTOM BUTTON EXTENSION-->
    <button type="button" aria-label="Edit media" class="umb-sortable-thumbnails__action btn-reset"
            data-element="action-edit" ng-click="ctrl.edit(item)">
        <i class="icon icon-edit" aria-hidden="true"></i>
    </button>
    <button type="button" aria-label="Remove" class="umb-sortable-thumbnails__action -red btn-reset"
            data-element="action-remove" ng-click="ctrl.remove(item)">
        <i class="icon icon-delete" aria-hidden="true"></i>
    </button>
</div>        
Enter fullscreen mode Exit fullscreen mode

Some of you may have noticed the click event on the button:

ng-click="vm.products(item, $index)"
Enter fullscreen mode Exit fullscreen mode

Which is supposed to then open a separate modal for handling the tagged products. However, to be able to call this vm.products function we had to add to the controller the view is hooked up to - and we didn't want to also entirely replace the package's controller if we didn't have to.

Luckily AngularJS also has the capability of extending controllers (that are explicitly registered).

Shout out to Lennard Fonteijn who wrote about extending AngularJs controllers here: https://umbraco.com/blog/mvp-blog-beyond-umbraco-2-hooking-angularjs-again/

We could see in the view file that the controller was registered as mediapickerController, so we could add our own extending upon that:

/App_Plugins/KontainerExtension/package.manifest:

{
    "javascript": [
        "~/App_Plugins/KontainerExtension/interceptor.js",
        "~/App_Plugins/KontainerExtension/kontainerExtension.controller.js",
        "~/App_Plugins/KontainerExtension/infiniteEditor.controller.js",
    ]
}
Enter fullscreen mode Exit fullscreen mode

/App_Plugins/KontainerExtension/kontainerExtension.controller.js:

(function () {
    "use strict";
    function KontainerController($scope, $controller, angularHelper, editorService) {
        var ctrl = $controller('mediapickerController as ctrl', {$scope: $scope, angularHelper: angularHelper})
        angular.extend(this, ctrl);

        var vm = this;        
        vm.products = products;

        function products(item, $index){
            var options = {
                title: "Select products",
                view: "/App_Plugins/KontainerExtension/infiniteEditor.html",
                size: "medium",
                item: item,
                itemIndex: $index,
                selection: $scope.model.value[$index].selection,
                submit: function(model, index) {                    
                    $scope.model.value[index].selection = model;
                    editorService.close();
                    vm.buttonState = "init";
                },
                close: function() {
                    editorService.close();
                    vm.buttonState = "init";
                }
            };
            editorService.open(options);
        }
    }    

    angular.module('umbraco').controller('KontainerController', [
        '$scope',
        '$controller',
        'angularHelper',
        'editorService',
        KontainerController]);
})();
Enter fullscreen mode Exit fullscreen mode

As you can see the code to extend from an existing controller only takes 2 lines of code:

var ctrl = $controller('mediapickerController as ctrl', {$scope: $scope, angularHelper: angularHelper})
angular.extend(this, ctrl);
Enter fullscreen mode Exit fullscreen mode

And then we can add a method for the products that will open an editor and pass along the selected items, as well as updating the list of selected items when the editor is "submitted".

You may notice that these are saved in $scope.model.value[index].selection which means we can stick with the existing JSON data and just add another object to it called selection.

Here is how the modal then ends up looking:

Image description

And the saved json to the database is the same but with this additional data attached:

"selection": {
    "showProducts": true,
    "productChoices": [
        {
            "name": "631112403",
            "checked": false
        },
        {
            "name": "BI1",
            "checked": true
        },
        {
            "name": "BI2",
            "checked": true
        },
        {
            "name": "C09-1099",
            "checked": true
        },
        {
            "name": "C75-0099",
            "checked": false
        },
        {
            "name": "PO2SK",
            "checked": false
        },
        {
            "name": "S939-099",
            "checked": false
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

Now the modal itself is just a custom modal so I wont go into too much depth with that, but if curious here is the code for the view and the controller:

/App_Plugins/KontainerExtension/infiniteEditor.html
<div ng-controller="KE.InfiniteEditorController as vm">
    <umb-editor-view>
        <umb-editor-header
                name="model.title"
                name-locked="true"
                hide-alias="true"
                hide-icon="true"
                hide-description="true">
        </umb-editor-header>
        <umb-editor-container>
            <umb-box>
                <umb-box-content>
                    <umb-load-indicator ng-if="vm.loading"></umb-load-indicator>
                    <umb-toggle
                            checked="vm.selection.showProducts"
                            on-click="vm.toggle()"
                            show-labels="true"
                            label-on="Showing related products"
                            label-off="Hiding related products"
                            label-position="right">
                    </umb-toggle>
                    <br>
                    <br>                    
                    <div ng-show="vm.selection.showProducts">
                        <p>Select the products that should be shown for the image:</p>
                        <umb-toggle
                                checked="vm.toggleAllChecked"
                                on-click="vm.toggleAll()"
                                show-labels="true"
                                label-on="Toggle all off"
                                label-off="Toggle all on"
                                label-position="right">
                        </umb-toggle>                        
                        <br>
                        <div class="umb-toggle-group">
                            <div class="umb-toggle-group-item" ng-repeat="item in vm.selection.productChoices" ng-if="!item.disabled" ng-class="{'umb-toggle-group-item--disabled': item.disabled}">
                                <umb-toggle class="umb-toggle-group-item__toggle"
                                            checked="item.checked"
                                            disabled="item.disabled"
                                            input-id="{{item.inputId}}"
                                            aria-labelledby="{{item.labelId}}"
                                            on-click="vm.toggleChoice(item)">
                                </umb-toggle>
                                <img src="{{item.image}}" style="max-height:100px; padding-right:20px; max-width:100px;"/>
                                <div class="umb-toggle-group-item__content" ng-click="change(item)">
                                    <div><label id="{{item.labelId}}" for="{{item.inputId}}">{{ item.name }}</label> </div>
                                    <div class="umb-toggle-group-item__description">{{ item.description }}</div>
                                </div>
                            </div>
                        </div>

                        <br><br>
                        <details>
                            <summary>Image skus with no matching products</summary>
                            <div class="umb-toggle-group">
                                <div class="umb-toggle-group-item" ng-repeat="item in vm.selection.productChoices" ng-if="item.disabled" ng-class="{'umb-toggle-group-item--disabled': item.disabled}">
                                    <umb-toggle class="umb-toggle-group-item__toggle"
                                                checked="item.checked"
                                                disabled="item.disabled"
                                                input-id="{{item.inputId}}"
                                                aria-labelledby="{{item.labelId}}"
                                                on-click="vm.toggleChoice(item)">
                                    </umb-toggle>
                                    <div class="umb-toggle-group-item__content" ng-click="change(item)">
                                        <div><label id="{{item.labelId}}" for="{{item.inputId}}">{{ item.name }}</label> </div>
                                        <div class="umb-toggle-group-item__description">{{ item.description }}</div>
                                    </div>
                                </div>
                            </div>
                        </details>                        
                    </div>                    
                </umb-box-content>
            </umb-box>
        </umb-editor-container>
        <umb-editor-footer>
            <umb-editor-footer-content-right>
                <umb-button
                        type="button"
                        button-style="link"
                        label-key="general_close"
                        shortcut="esc"
                        action="vm.close()">
                </umb-button>
                <umb-button
                        type="button"
                        button-style="action"
                        label-key="general_submit"
                        action="vm.submit(model)">
                </umb-button>
            </umb-editor-footer-content-right>
        </umb-editor-footer>
    </umb-editor-view>
</div>
Enter fullscreen mode Exit fullscreen mode

/App_Plugins/KontainerExtension/infiniteEditor.controller.js
(function () {
    "use strict";

    function KeInfiniteEditorController($scope, $routeParams, $http, editorState) {

        var vm = this;

        vm.submit = submit;
        vm.close = close;
        vm.toggle = toggle;
        vm.toggleChoice = toggleChoice;
        vm.toggleAll = toggleAll;

        vm.loading = false;
        vm.item = $scope.model.item;
        vm.articleNumbers = vm.item.cf['Article number'] === undefined ? [] : vm.item.cf['Article number'].split(/\r\n|\r|\n/);
        vm.selection = getSelection($scope.model.selection);
        vm.toggleAllChecked = getToggleState();

        function getToggleState(){
            let obj = vm.selection.productChoices.find(f => f.checked === false);
            if(obj === undefined) return true;
            return false;
        }

        function toggleChoice(item){
            let obj = vm.selection.productChoices.find(f => f.description === item.description);
            obj.checked = !obj.selected;
            obj.selected = !obj.selected;
        }

        function toggleAll(){
            vm.toggleAllChecked = !vm.toggleAllChecked;
            vm.selection.productChoices.forEach(function (item){
                if(!item.disabled){
                    item.checked = vm.toggleAllChecked;
                }
            })
        }

        function getSelection(selection){

            if(selection === undefined){
                var initSelection = {
                    "showProducts": true,
                    "productChoices": []
                }

                var options = [];

                vm.articleNumbers.forEach(function(item){
                    options.push({"name": item.trim(), "checked": false});
                })

                var products = GetProducts(options);
                initSelection.productChoices = products;

                return initSelection;
            }

            var formattedChoices = GetProducts(selection.productChoices);            
            selection.productChoices = formattedChoices;

            return selection;
        }

        function GetProducts(options){
            vm.loading = true;
            var formattedProducts = [];

            if (options === undefined || options.length == 0) {
                vm.loading = false;
                return formattedProducts;
            }

            if(Object.hasOwn(options[0], 'description')){
                var strippedOptions = [];

                options.forEach(function(item){
                    strippedOptions.push({"name": item.description, "checked": item.checked});
                })

                options = strippedOptions;
            }

            $http({
                method: 'POST',
                url: '/umbraco/backoffice/api/KontainerExtension/GetProducts',
                headers: {'Content-Type': 'application/json'},
                data: JSON.stringify({
                    options: options,
                    culture: $routeParams.mculture,
                    nodeId: editorState.current.id
                })
            })
            .then(function (response) {

                response.data.forEach(function(item){
                    formattedProducts.push({"name": item.Name, "checked": item.Checked, "disabled": !item.Exists, "description": item.Sku, "image": item.Image});
                })

                vm.loading = false;
            });

            return formattedProducts;
        }

        function toggle(){
            vm.selection.showProducts = !vm.selection.showProducts
        }

        function submit() {
            if($scope.model.submit) {
                var res = [];
                vm.selection.productChoices.forEach(function (item){
                    res.push({"name": item.description, "checked": item.checked});
                })
                vm.selection.productChoices = res;
                $scope.model.submit(vm.selection, $scope.model.itemIndex);
            }
        }

        function close() {
            if($scope.model.close) {
                $scope.model.close();
            }
        }
    }

    angular.module("umbraco").controller("KE.InfiniteEditorController", KeInfiniteEditorController);
})();
Enter fullscreen mode Exit fullscreen mode

This controller would take the skus and pass along to a secure controller endpoint that would return the product name, image, sku and toggle state to use in the preview.

Finally, we needed to replace the custom property value converter that came with the package. This is quite easy to do, we can just unregister it and register our own:

Composer.cs:

public class Composer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.PropertyValueConverters().Remove<Kontainer.ValueConverters.MediaPickerValueConverter>();
    }
}
Enter fullscreen mode Exit fullscreen mode

KontainerOverwriteValueConverter.cs:

public class KontainerOverwriteValueConverter : IPropertyValueConverter
{
    private readonly IVariationContextAccessor _variationContextAccessor;
    private readonly KontainerMapper _kontainerMapper;

    public KontainerOverwriteValueConverter(IVariationContextAccessor variationContextAccessor, KontainerMapper kontainerMapper)
    {
        _variationContextAccessor = variationContextAccessor;
        _kontainerMapper = kontainerMapper;
    }

    public bool IsConverter(IPublishedPropertyType propertyType)
    {
        return propertyType.EditorAlias.Equals("Kontainer.MediaPicker");
    }

    // Taken directly from Umbraco core - https://github.com/umbraco/Umbraco-CMS/blob/cb544f15eee862361c7830640ac0d0c4252bf488/src/Umbraco.Core/PropertyEditors/PropertyValueConverterBase.cs#L9
    public bool? IsValue(object? value, PropertyValueLevel level)
    {
        switch (level)
        {
            case PropertyValueLevel.Source:
                // the default implementation uses the old magic null & string comparisons,
                // other implementations may be more clever, and/or test the final converted object values
                return value != null && (value is not string stringValue || !string.IsNullOrWhiteSpace(stringValue));
            case PropertyValueLevel.Inter:
                return null;
            case PropertyValueLevel.Object:
                return null;
            default:
                throw new NotSupportedException($"Invalid level: {level}.");
        }
    }

    public Type GetPropertyValueType(IPublishedPropertyType propertyType)
    {
        var isMultiple = IsMultipleDataType(propertyType.DataType);
        return isMultiple
            ? typeof(List<KontainerData>)
            : typeof(KontainerData);
    }

    private bool IsMultipleDataType(PublishedDataType dataType)
    {
        if (dataType.Configuration is not Dictionary<string, object> config)
        {
            return true;
        }

        if(!config.TryGetValue("multiSelect", out var val))
        {
            return true;
        }

        return val.ToString() != "0";
    }

    public PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType)
    {
        return PropertyCacheLevel.Elements;
    }

    public object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source,
        bool preview)
    {
        var culture = string.IsNullOrWhiteSpace(_variationContextAccessor.VariationContext?.Culture) ? "da-DK" : _variationContextAccessor.VariationContext.Culture;
        var isMultiple = IsMultipleDataType(propertyType.DataType);

        var files = new List<KontainerData>();

        if (source == null)
        {
            return isMultiple ? files : null;
        }

        var sourceString = source.ToString();
        if (sourceString is null)
        {
            return isMultiple ? files : null;
        }

        if (!string.IsNullOrWhiteSpace(sourceString))
        {
            var kontainerMedia = JsonSerializer.Deserialize<List<KontainerDataJsonModel>>(sourceString);
            if (kontainerMedia is not null)
            {
                files.AddRange(_kontainerMapper.MapKontainerData(kontainerMedia, culture).GetAwaiter().GetResult());
            }
        }

        return isMultiple ? files : files.FirstOrDefault();
    }

    public object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType,
        PropertyCacheLevel referenceCacheLevel, object? inter, bool preview)
    {
        return inter;
    }

    public object? ConvertIntermediateToXPath(IPublishedElement owner, IPublishedPropertyType propertyType,
        PropertyCacheLevel referenceCacheLevel, object? inter, bool preview)
    {
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Where our KontainerMapper takes the skus and fetches the products from our product cache which then allows us to return our own custom model that contains all the information we want, and we can also access things like the culture of the request so we can build the url for our product nodes, which means when you open the modal you can navigate directly to the product page:

Image description

As for rendering the media, we made a custom taghelper that could differentiate between the different media types and would render dependant on that so in the FE you could always just pass along the model like this:

<ec-kontainer media="@article.Image" container-class="overflow-hidden" img-class="w-full object-cover sm:h-120 xl:h-155 max-sm:w-full"></ec-kontainer>
Enter fullscreen mode Exit fullscreen mode

Additionally, my colleague Søren Kottal created https://marketplace.umbraco.com/package/umbraco.community.imagesharpremoteimages to allow us to continue using imagesharp for cropping the DAM images on our end, caching the crops, etc.

That meant we didn't have to change anything about how we normally work with media, and could do things like a src-set and lazysizes.js for generating multiple media crops to fit multiple browser formats!

I hope you liked this little preview into one of the custom extensions we made for this site!

You can reach me on these socials:

GitHub: https://github.com/jemayn
Mastodon: https://umbracocommunity.social/@Jmayn
LinkedIn: https://www.linkedin.com/in/jemayn

Comments 0 total

    Add comment