Link Attributes Extension: Final Touch Ups

Welcome to the third installment (and hopefully final) work on our Link Attributes GUI Extension. In our first post, we discovered how to customize one of the Tridion default popup dialogs (the Link popup for our extension). The post gave birth to the GUI Extension piece of our extension and added special hash parameters to the link’s href attribute to represent the attributes that needed to be created. In our second post, we created a Template Building Block that would be responsible for converting the hash parameters from the GUI Extension into actual attributes on the link.

Our updated Link Attributes dialog!

Our updated Link Attributes dialog!

If you’d rather just skip the reading and download the code, you can find it here.

Today we’ll be adding some finishing touches and enhancements to the work. The biggest piece missing was the ability to add multiple link attributes onto our hyperlink. There was also an issue if the url contained a hash string that wasn’t in a key = value format (ie www.example.com#noKeyValue). For our multiple fields, we want to have a delete button that will either delete the field completely if there are more than one field available, or just clear the field if its the only one remaining. Clicking the add button will insert an additional empty field set.

HashCollection.js

Our first update is to our HashCollection.js file. Our update here is strictly to allow existing hash strings in the url that is not in the key equals value format (ie “#someValue” vs “#someValue=blah”).

Type.registerNamespace("ContentBloom.Extensions");

/**
 * HashCollection is a collection of styles on a link's hash parameters.  This class is responsible
 * for getting, setting, and clearing link attributes.
 *
 * @constructor
 */
ContentBloom.Extensions.HashCollection = function (url) {
    var hashString,
        params,
        param,
        i;

    /**
     * The link attribute prefix.  This prefix is applied to any hash params that are for link attributes only.
     * @type {string}
     * @private
     */
    this._linkAttributePrefix = "linkAttr-";

    /**
     * Whether or not the hash collection contains link attributes. 
     * @type {boolean}
     */
    hasLinkAttributes: false;

    /**
     * The original url passed into the HashCollection instance.
     * @type {string}
     */
    this.url = url;

    /**
     * The params on the url
     * @type {object[]}
     */
    this.params = [];

    // only populate the params if the url contains a hashstring
    if (url.indexOf("#") !== -1) {
        hashString = url.substring(url.indexOf("#") + 1);

        params = hashString.split('&');
        for (i = 0; i < params.length; i++) {
            param = params[i].split('=');
            this.params[this.params.length] = { property: param[0], value: param[1] };
            if (!this.hasLinkAttributes && param[0].indexOf(this._linkAttributePrefix) === 0) {
                this.hasLinkAttributes = true;
            }
        }
    }
};

// StyleCollection prototype members
ContentBloom.Extensions.HashCollection.prototype = {

    /**
     * Clears all params from the collection that are specific to Link Attributes.
     */
    clearLinkAttributes: function () {
        var length = this.params.length,
            param,
            i;

        while (length--) {
            param = this.params[length];
            if (param.property.indexOf(this._linkAttributePrefix) === 0) {
                this.params.splice(length, 1);
            }
        }
        this.hasLinkAttributes = false;
    },

    /**
     * Gets the hash string (starting with '#') only of the wrapped url. Supplies link attribute hash params at the end of the string.
     *
     * @returns {string}
     */
    getHashString: function () {
        var hashString = "#",
            linkAttributes = "",
            length = this.params.length,
            param,
            i;

        if (length === 0) {
            return "";
        }

        for (i = 0; i < length; i++) {
            param = this.params[i];
            if (param.property.indexOf(this._linkAttributePrefix) === 0) {
                if (i > 0) {
                    linkAttributes += "&";
                }
                linkAttributes += "{0}={1}".format(param.property, param.value);
            } else {
                if (i > 0) {
                    hashString += "&";
                }
                if (param.value) {
                    hashString += "{0}={1}".format(param.property, param.value);
                } else {
                    hashString += param.property;
                }
            }
        }


        return hashString + linkAttributes;
    },

    /**
     * If a link attribute property is supplied, strips the prefix and returns just the key. Else just reutrns the key.
     *
     * @returns {string}
     */
    getKey: function (key) {
        if (key.indexOf(this._linkAttributePrefix) === 0) {
            return key.substring(9);
        }
        return key;
    },

    /**
     * Gets a link attribute param by its key.
     *
     * @param {string} key - the key to retrieve (excluding the link attribute prefix)
     * @returns {Object|null} null if no attribute is found with given key.
     */
    getLinkAttribute: function (key) {
        var length = this.params.length,
            param,
            i;

        for (i = 0; i < length; i++) {
            param = this.params[i];
            if (param.property === this._linkAttributePrefix + key) {
                return param;
            }
        }
        return null;
    },

    /**
     * Gets only the parameters that are link attribute parameters.
     */
    getLinkAttributes: function () {
        var linkAttributes = [],
            length = this.params.length,
            param,
            i;

        if (!this.hasLinkAttributes) {
            return linkAttributes;
        }

        for (i = 0; i < length; i++) {
            param = this.params[i];
            if (param.property.indexOf(this._linkAttributePrefix) === 0) {
                linkAttributes[linkAttributes.length] = param;
            }
        }

        return linkAttributes;
    },

    /**
     * Returns a new url including the hash string generated by any parameters.
     *
     * @returns {string}
     */
    getHashedUrl: function () {
        var url;

        if (this.url.indexOf("#") !== -1) {
            url = this.url.substring(0, this.url.indexOf("#"));
        } else {
            url = this.url;
        }

        return url + this.getHashString();
    },

    /**
     * Sets a link attribute param.  If attribute already exists, updates it.
     *
     * @param {string} key - The key of the link attribute (exluding link attribute prefix).
     * @param {string} value - the value of the link attribute.
     */
    setLinkAttribute: function (key, value) {
        var param = this.getLinkAttribute(key);
        if (param !== null) {
            param.value = value;
        } else {
            this.params[this.params.length] = { property: this._linkAttributePrefix + key, value: value };
        }
        this.hasLinkAttributes = true;
    }

}

LinkAttributes.js

Our next update is to the LinkAttributes.js file, where we are adding the ability to add new rows, delete existing rows, and some extra markup for the button controls. We’ve also had to add a way to allow the popup window to dynamically alter its height based off the adding/removing of rows.

Type.registerNamespace("ContentBloom.Extensions");

/**
 * Represents functionality for adding custom attributes onto links from the Link Popup dialog.
 */
ContentBloom.Extensions.LinkAttributes = {

    /**
     * The table row in the popup containing the key value pairs of links.
     * @type {JQuery}
     */
    attributeContainer: null,

    /**
     * The url input field.
     * @type {JQuery}
     */
    urlField: null,

    /**
     * Initializes the LinkAttributes object.
     */
    init: function () {
        var self = this;

        this.customizeLinkObject();
        $jq('#rowTarget').parent().append('<tr id="customAttributes" valign="top"><td>Attributes:</td><td id="attributeContainer" colspan="2"></td><td colspan="2" valign="bottom"><div class="addLinkAttributesRow"></div></td></tr>');

        this.attributeContainer = $jq('#attributeContainer');
        this.urlField = $jq("#FieldUrl");
        
        this.insertKeyValuePair();
        this.initValues();

        // add new rows
        $jq("#customAttributes .addLinkAttributesRow").click(function () {
            self.insertKeyValuePair();
        });

        // delete rows, or clear input fields
        this.attributeContainer.on("click", ".deleteLinkAttributesRow", function () {
            var row =$jq(this).parent();

            if ($jq("div.linkAttributeSet").length > 1) {
                // remove the row if there are more than one fieldset...
                row.remove();
                window.resizeBy(0, -$jq("#attributeContainer div").height());
            } else {
                // clear the fields if there's only one row
                $jq('input.key, input.value', this.attributeContainer).val('');
            }
        });
    },

    /**
     * Initializes input field values on the gui (url, link attributes...).
     */
    initValues: function () {
        var hashParams,
            linkAttributes,
            attribute,
            length,
            i,
            oldLink = window.dialogArguments && window.dialogArguments.link ? window.dialogArguments.link : {};


        if (oldLink.href) {
            hashParams = new ContentBloom.Extensions.HashCollection(oldLink.href);
            linkAttributes = hashParams.getLinkAttributes();
            length = linkAttributes.length;

            if (hashParams.hasLinkAttributes) {
                for (i = 0; i < length; i++) {
                    attribute = linkAttributes[i];
                    if (i === 0) {
                        $jq('.key', this.attributeContainer).val(hashParams.getKey(attribute.property));
                        $jq('.value', this.attributeContainer).val(attribute.value);
                    } else {
                        this.insertKeyValuePair(hashParams.getKey(attribute.property), attribute.value);
                    }
                }
                hashParams.clearLinkAttributes();
                this.urlField.val(hashParams.getHashedUrl());
            }
        }
    },

    /**
     * Gets an array of custom attributes based on text fields in the gui.
     *
     * @returns {object[]}
     */
    getCustomAttributes: function () {
        var attributes = [];

        $jq('div.linkAttributeSet', this.attributeContainer).each(function(index, element) {
            var key = $jq('.key', element).val(),
                value = $jq('.value', element).val();

            if (key) {
                attributes[attributes.length] = { key: key, value: value };
            }
        });

        return attributes;
    },

    /**
     * Inserts a new key value pair of text fields into the attribute container.
     *
     * @param {string=} key - The optional key to set the key input field to.
     * @param {string=} value - The optional value to set the value input field to.
     */
    insertKeyValuePair: function (key, value) {
        if (key == undefined) {
            key = "";
        }
        if (value == undefined) {
            value = "";
        }
        this.attributeContainer
            .append('<div class="linkAttributeSet"><label>Key</label><input class="key" type="text" value="' + key + 
                '" /> <label>Value</label><input class="value" type="text" value="' + value + '" /><div class="deleteLinkAttributesRow"></div></div>');
        window.resizeBy(0, $jq("#attributeContainer div").height());
    },

    /**
     * Modifies the Link._buildNewLinkHtml method to also apply the custom link attributes. Monkey patches, so existing method is still used and not replaced.
     */
    customizeLinkObject: function () {
        var self = this,
            originalFn = Tridion.Cme.Views.Link.prototype._buildNewLinkHtml;
            

        Tridion.Cme.Views.Link.prototype._buildNewLinkHtml = function Link$_buildNewCustomLinkHtml() {
            var link,
                hashParams,
                attributes = self.getCustomAttributes(),
                attribute,
                i;

            originalFn.apply(this);

            hashParams = new ContentBloom.Extensions.HashCollection(this.properties.NewLink.href);
            hashParams.clearLinkAttributes();

            for (i = 0; i < attributes.length; i++) {
                attribute = attributes[i];
                hashParams.setLinkAttribute(attribute.key, attribute.value);
            }

            this.properties.NewLink.href = hashParams.getHashedUrl();
        };
    }
};

// We call init only after document has loaded.
$jq(function () {
    ContentBloom.Extensions.LinkAttributes.init();
});

LinkAttributes.css

And finally, our stylesheet needs to be updated to be updated to support our newly added elements to our customization. We’re reusing some of the icons that are exists in Tridion for our delete and add buttons.

#attributeContainer {
    border: solid 1px #a1a9b2;
    border-bottom-width: 0;
}

    #attributeContainer label {
        font-weight: bold;
        margin-right: 6px;
    }

    #attributeContainer input {
        width: 30%;
    }

.addLinkAttributesRow {
    height: 22px;
    width: 22px;
    background-image: url("/WebUI/Editors/CME/Themes/Carbon/Images/Icons/add.16x16_v6.1.0.55920.93_.png");
    background-position: center;
    background-repeat: no-repeat;
    cursor: pointer;
    position: relative;
    bottom: 8px;
}

.deleteLinkAttributesRow {
    height: 22px;
    width: 22px;
    background-image: url("/WebUI/Editors/CME/Themes/Carbon/Images/Icons/delete.16x16_v6.1.0.55920.93_.png");
    background-position: center;
    background-repeat: no-repeat;
    cursor: pointer;
    display: inline-block;
    position: relative;
    top: 6px;
    left: 5px;
}

And that’s it… our Editor.config file requires no change and our Template Building Block from our previous post and our TBB was already setup to handle the multiple hash parameters.

If you are interested in using this extension and don’t feel like piecing everything together from this and the past two articles, you can download the extension here. A quick installation documentation is provided to help you get it setup.

Enjoy and stay coding my friends!

Customizing An Existing Tridion Popup Dialog

Ever had a need or an itch to completely modify and customize one of those default Tridion popup dialogs? Perhaps you even had a desire to overload an existing JavaScript function from one of those dialogs, but didn’t want to just copy/paste or replace the existing one entirely? Today I’ll be giving you a walk through of adding some custom fields and functionality to the Link popup. Before I begin, I want to give a special thanks to Frank Taylor as we worked on this kind of functionality together not too long ago. And by working together, I mean that I merely made some suggestions and he did all the work so that one day I could steal it as a reference point and write the post you are reading today. So if you find anything on this page useful, feel free to also give Mr. Frank a shout out! And respectively, if you think this article is complete crap and the code here somehow murders your beloved cat, feel free to leave a burning bag of poo on his front door step. Just ping me and I’ll give you his personal home address.

If you’re not interested in reading and just want the code that was done in this post, you can find it here.

The Mission

Our mission today is to add the ability for content authors to add extra custom attributes onto their links in rich text fields via the Hyperlink button in the ribbon bar.  For the sake of simplicity, we’ll only add a single set of a key value pair today, and save this enhanced feature for a post for another day.  Also, today’s post will only focus on the GUI Extension piece of this puzzle as the point of today’s blog is more focused on the “how do I customize an existing dialog?”

Analysis

At first thought, we could apply our custom link attribute directly to the link elements from the rich text field.  However, these would just get stripped away in an out of the box Tridion setup due to being invalid attributes.  Although we could configure Tridion to prevent this from happening (for information on how to do this, check Nick’s post here), I wanted to take a more generic approach where we could add attributes regardless of this configuration.  So what we’ll be doing instead is adding these special link attributes as hash parameters to the href attribute.  We’ll give them a special prefix of “linkAttr-” to distinguish our hash parameters from any that may already be a part of the url supplied.  This means that we’ll also need to create a Template Building Block that will search for these specially marked hash params, turn them into attributes on the link element, and remove them from the hash string.  As mentioned earlier, since we’ll only be focusing on the GUI Extension piece today, we’ll leave that for the next blog post.

Assumptions

I’m going to assume that you are already somewhat familiar with developing Tridion GUI Extensions, and will only be going over code and configuration that is specific to this extension. If you are looking for a tutorial that teaches you how to create a Hello World from scratch, there’s already quite a few good ones out there. We’re also going to assume that we’ll be using jQuery and that one doesn’t already exist. We’ll be adding jQuery to a global $jq variable.

The JavaScript

We’re going to start by creating a utility class, “HashCollection”.  This class will be responsible for extracting the hash parameters from a given url, as well as maintaining, adding and clearing of our link attributes.

Type.registerNamespace("ContentBloom.Extensions");

/**
 * HashCollection is a collection of styles on a link's hash parameters.  This class is responsible
 * for getting, setting, and clearing link attributes.
 *
 * @constructor
 */
ContentBloom.Extensions.HashCollection = function (url) {
    var hashString,
        params,
        param,
        i;

    /**
     * The link attribute prefix.  This prefix is applied to any hash params that are for link attributes only.
     * @type {string}
     * @private
     */
    this._linkAttributePrefix = "linkAttr-";

    /**
     * Whether or not the hash collection contains link attributes. 
     * @type {boolean}
     */
    hasLinkAttributes: false;

    /**
     * The original url passed into the HashCollection instance.
     * @type {string}
     */
    this.url = url;

    /**
     * The params on the url
     * @type {object[]}
     */
    this.params = [];

    if (url.indexOf("#") !== -1) {
        hashString = url.substring(url.indexOf("#") + 1);

        params = hashString.split('&');
        for (i = 0; i < params.length; i++) {
            param = params[i].split('=');
            this.params[this.params.length] = { property: param[0].trim(), value: param[1].trim() };
            if (!this.hasLinkAttributes && param[0].trim().indexOf(this._linkAttributePrefix) === 0) {
                this.hasLinkAttributes = true;
            }
        }
    }
};

// StyleCollection prototype members
ContentBloom.Extensions.HashCollection.prototype = {

    /**
     * Clears all params from the collection that are specific to Link Attributes.
     */
    clearLinkAttributes: function () {
        var length = this.params.length,
            param,
            i;

        while (length--) {
            param = this.params[length];
            if (param.property.indexOf(this._linkAttributePrefix) === 0) {
                this.params.splice(length, 1);
            }
        }
        this.hasLinkAttributes = false;
    },

    /**
     * Gets the hash string (starting with '#') only of the wrapped url. Supplies link attribute hash params at the end of the string.
     *
     * @returns {string}
     */
    getHashString: function () {
        var hashString = "#",
            linkAttributes = "",
            length = this.params.length,
            param,
            i;

        if (length === 0) {
            return "";
        }

        for (i = 0; i < length; i++) {
            param = this.params[i];
            if (param.property.indexOf(this._linkAttributePrefix) === 0) {
                if (i > 0) {
                    linkAttributes += "&";
                }
                linkAttributes += "{0}={1}".format(param.property, param.value);
            } else {
                if (i > 0) {
                    hashString += "&";
                }
                hashString += "{0}={1}".format(param.property, param.value);
            }
        }


        return hashString + linkAttributes;
    },

    /**
     * If a link attribute property is supplied, strips the prefix and returns just the key. Else just reutrns the key.
     *
     * @returns {string}
     */
    getKey: function (key) {
        if (key.indexOf(this._linkAttributePrefix) === 0) {
            return key.substring(9);
        }
        return key;
    },

    /**
     * Gets a link attribute param by its key.
     *
     * @param {string} key - the key to retrieve (excluding the link attribute prefix)
     * @returns {Object|null} null if no attribute is found with given key.
     */
    getLinkAttribute: function (key) {
        var length = this.params.length,
            param,
            i;

        for (i = 0; i < length; i++) {
            param = this.params[i];
            if (param.property === this._linkAttributePrefix + key) {
                return param;
            }
        }
        return null;
    },

    /**
     * Gets only the parameters that are link attribute parameters.
     */
    getLinkAttributes: function () {
        var linkAttributes = [],
            length = this.params.length,
            param,
            i;

        if (!this.hasLinkAttributes) {
            return linkAttributes;
        }

        for (i = 0; i < length; i++) {
            param = this.params[i];
            if (param.property.indexOf(this._linkAttributePrefix) === 0) {
                linkAttributes[linkAttributes.length] = param;
            }
        }

        return linkAttributes;
    },

    /**
     * Returns a new url including the hash string generated by any parameters.
     *
     * @returns {string}
     */
    getHashedUrl: function () {
        var url;

        if (this.url.indexOf("#") !== -1) {
            url = this.url.substring(0, this.url.indexOf("#"));
        } else {
            url = this.url;
        }

        return url + this.getHashString();
    },

    /**
     * Sets a link attribute param.  If attribute already exists, updates it.
     *
     * @param {string} key - The key of the link attribute (exluding link attribute prefix).
     * @param {string} value - the value of the link attribute.
     */
    setLinkAttribute: function (key, value) {
        var param = this.getLinkAttribute(key);
        if (param !== null) {
            param.value = value;
        } else {
            this.params[this.params.length] = { property: this._linkAttributePrefix + key, value: value };
        }
        this.hasLinkAttributes = true;
    }

}

Now that we have our utility class, let’s write the code that’s going to be doing the real work.

Type.registerNamespace("ContentBloom.Extensions");

/**
 * Represents functionality for adding custom attributes onto links from the Link Popup dialog.
 */
ContentBloom.Extensions.LinkAttributes = {

    /**
     * The table row in the popup containing the key value pairs of links.
     * @type {JQuery}
     */
    attributeContainer: null,

    /**
     * The url input field.
     * @type {JQuery}
     */
    urlField: null,

    /**
     * Initializes the LinkAttributes object.
     */
    init: function () {
        this.customizeLinkObject();
        $jq('#rowTarget').parent().append('<tr id="customAttributes"><td>Attributes:</td><td id="attributeContainer" colspan="4"></td></tr>');

        this.attributeContainer = $jq('#attributeContainer');
        this.urlField = $jq("#FieldUrl");
        
        this.insertKeyValuePair();
        this.initValues();
    },

    /**
     * Initializes input field values on the gui (url, link attributes...).
     */
    initValues: function () {
        var hashParams,
            linkAttributes,
            attribute,
            length,
            i,
            oldLink = window.dialogArguments && window.dialogArguments.link ? window.dialogArguments.link : {};


        if (oldLink.href) {
            hashParams = new ContentBloom.Extensions.HashCollection(oldLink.href);
            linkAttributes = hashParams.getLinkAttributes();
            length = linkAttributes.length;

            if (hashParams.hasLinkAttributes) {
                this.attributeContainer.html('');
                for (i = 0; i < length; i++) {
                    attribute = linkAttributes[i];
                    this.insertKeyValuePair(hashParams.getKey(attribute.property), attribute.value);
                }
                hashParams.clearLinkAttributes();
                this.urlField.val(hashParams.getHashedUrl();
            }
        }
    },

    /**
     * Gets an array of custom attributes based on text fields in the gui.
     *
     * @returns {object[]}
     */
    getCustomAttributes: function () {
        var attributes = [];

        $jq('div', this.attributeContainer).each(function(index, element) {
            var key = $jq('.key', element).val(),
                value = $jq('.value', element).val();

            if (key) {
                attributes[attributes.length] = { key: key, value: value };
            }
        });

        return attributes;
    },

    /**
     * Inserts a new key value pair of text fields into the attribute container.
     *
     * @param {string=} key - The optional key to set the key input field to.
     * @param {string=} value - The optional value to set the value input field to.
     */
    insertKeyValuePair: function (key, value) {
        if (key == undefined) {
            key = "";
        }
        if (value == undefined) {
            value = "";
        }
        this.attributeContainer
            .append('<div><label>Key</label><input class="key" type="text" value="' + key + 
                '" /> <label>Value</label><input class="value" type="text" value="' + value + '" /></div>');
    },

    /**
     * Modifies the Link._buildNewLinkHtml method to also apply the custom link attributes. Monkey patches, so existing method is still used and not replaced.
     */
    customizeLinkObject: function () {
        var self = this,
            originalFn = Tridion.Cme.Views.Link.prototype._buildNewLinkHtml;
            

        Tridion.Cme.Views.Link.prototype._buildNewLinkHtml = function Link$_buildNewCustomLinkHtml() {
            var link,
                hashParams,
                attributes = self.getCustomAttributes(),
                attribute,
                i;

            originalFn.apply(this);

            hashParams = new ContentBloom.Extensions.HashCollection(this.properties.NewLink.href);
            hashParams.clearLinkAttributes();

            for (i = 0; i < attributes.length; i++) {
                attribute = attributes[i];
                hashParams.setLinkAttribute(attribute.key, attribute.value);
            }

            this.properties.NewLink.href = hashParams.getHashedUrl();
        };
    }
};

// We call init only after document has loaded.
$jq(function () {
    ContentBloom.Extensions.LinkAttributes.init();
});

On Load

It’s important to notice that at the end of this script, we are making a call to our ContentBloom.Extensions.LinkAttributes.init() method only after the document loaded. If we were to call the init method immediately, we’d get some unexpected results.

initValues

You’ll notice here that we check if this is an existing hyperlink that has already been given link attributes. If the hash string contains our specially marked values, we’ll populate or key/value fields appropriately.

Future Proofing

You’ll also notice that although we are only displaying a single set of a key/value pair, our code is already setup to handle multiple sets.

Monkey Patching _buildNewLinkHtml

Here’s another cool trick that we are doing… we are adding functionality to the existing Tridion.Cme.Views.Link.prototype._buildNewLinkHtml method without actually modifying any of the existing code (which would be bad). We’re using a little closure magic here in our customizeLinkObject method to create a copy of the existing function, overwrite the existing function, and ensure that the original function still gets called prior to our custom code (while ensuring that the context of “this” remains for the Link instance).

The Stylesheet

Not too much going on here at this stage of our extension. We’re just making sure out labels stand out… depending on the browser you are on, this may display nicely or get wrapped onto a second line. We’ll come back in another post to pretty-ify it up a lil more though.

/**
 * Yep, not a too lot going on in here just yet...
 */
#LinkPopup #customAttributes label {
    font-weight: bold; // tempted to throw an !important in there just for you Frank...
}

Configuration

Here’s where everything comes into play that allows us to load our newly created JS and CSS files onto the existing Link Popup page. What we want to do here is create an Extension Group. An Extension Group allows us to extend an already existing Group and add additional files to it. In this case, we want to extend the “Tridion.Web.UI.Editors.CME.Views.Popups.Link” group. Since the configuration of a GUI Extension seems to be the biggest gotcha, I’ll go over the process for this extension piece by piece.

...
<?xml version="1.0"?>
<Configuration xmlns="http://www.sdltridion.com/2009/GUI/Configuration/Merge"
               xmlns:cfg="http://www.sdltridion.com/2009/GUI/Configuration"
               xmlns:ext="http://www.sdltridion.com/2009/GUI/extensions"
               xmlns:cmenu="http://www.sdltridion.com/2009/GUI/extensions/ContextMenu">

  <resources cache="true">
    <cfg:filters />
    <cfg:extensiongroups>
      <cfg:extensiongroup name="ContentBloom.ExtensionGroups.Link">
        <cfg:extension target="Tridion.Web.UI.Editors.CME.Views.Popups.Link">
          <cfg:insertafter>ContentBloom.Resources.LinkAttributes</cfg:insertafter>
        </cfg:extension>
      </cfg:extensiongroup>
    </cfg:extensiongroups>
    <cfg:groups>
     <cfg:group name="ContentBloom.Resources.LinkAttributes">
        <cfg:fileset>
          <cfg:file type="script">/js/libs/jquery.js</cfg:file>
          <cfg:file type="script">/js/HashCollection.js</cfg:file>
          <cfg:file type="script">/js/LinkAttributes.js</cfg:file>
          <cfg:file type="style">/css/LinkAttributes.css</cfg:file>
        </cfg:fileset>
      </cfg:group>
    </cfg:groups>
  </resources>
  <definitionfiles />
  <extensions>
      <ext:editorextensions/>
      <ext:dataextenders/>
  </extensions>
  <commands />
  <contextmenus />
  <localization />
  <settings>
    <defaultpage />
    <navigatorurl />
    <editurls/>
    <listdefinitions/>
    <itemicons/>
    <theme>
      <path/>
      <resourcegroup />
    </theme>
    <resourceextensions>
        <resourceextension>ContentBloom.ExtensionGroups.Link</resourceextension>
    </resourceextensions>
    <customconfiguration/>
  </settings>
</Configuration>

Creating the Resource Group

First we create our <cfg:group> element within the <cfg:groups> element. Our resource group is telling Tridion which resource files to include into our little extension… in our case we are loading our JS and CSS files.

Creating the Extension Group

This is where we inject our resources into an already existing resource group. We’ll need to create a <cfg:extensiongroup /> element within the <cfg:extensionsgroups /> element with a unique name attribute.  Within the <cfg:extensiongroup /> element, you’ll want to create a <cfg:extension /> element that has a target attribute that contains a value of the existing resource group that we want to extend (here we are extending “Tridion.Web.UI.Editors.CME.Views.Popups.Link”).  And within that <cfg:extension /> element, you’ll want to create a <cfg:insertafter /> element that contains the name of the resource group that we created in the above step.

Don’t Forget the Resource Extension!

One final piece to this configuration… in the settings element, we want to create a <resourceextensions> element that contains a <resourceextension> element. The value of this element should be that unique name that we gave to our extensiongroup that we created above.

Deploy!

And there you have the starts of the Link Attributes extension! You will have to deploy the extension as normal… a.) virtual directory creation… b.) Add the editor configuration in System.config… c.) Increment the modification attribute. Open up a component with a rich text field, create a Hyperlink using the Hyperlink button, and you should be in business! You may not yet be able to create actual link attributes, but at least you’ll be able to see your customizations as well as modified href attribute on the links!

Our extension of version 1 in action!

Our extension of version 1 in action!

Conclusions

Although our work for this Link Attributes Extension is not yet complete, you should have now be well armed in case you ever have to customize any of the default popup dialogs.

And for those of you who are actually interested in the completion of this extension, stay tuned for future posts where we’ll work on the TBB, as well as add the ability to add multiple link attributes (and fix up some issues that you may have noticed).

You can find the code to what we’ve done today here.

Stay coding my friends!

Updates

8/21/2014 – Follow up post for building out the Template Building Block

Retrieving, Archiving, and Disposing Messages with Anguilla’s MessageCenter

Inspired by yet another question on StackExchange, I’ve decided to do a follow up on a previous post on using MessageCenter. While in the previous post we focused on the creation and registration of new messages and notifications, today we are going to discuss the retrieval, archiving, and disposing (removing) of messages within the message center. Before we continue, you should know that archiving a message in Anguilla means that is shows up as dark grey (ones that you’ve read) in the list of messages, while disposing means that it doesn’t show up in the list at all.

Retrieving

You can get an Array of all the messages using the getMessages() method of MessageCenter.

$messages.getMessages().forEach(function (message) {
    // do cool things with each message
});

The getMessages method does return ALL messages, including those that are inactive (archived) or disposed. If instead you want to get only the active messages, you could use the getActiveMessages() method.

$messages.getActiveMessages().forEach(function (message) {
    // do cool things with your active messages
});

But what if you wanted to get a list of all disposed messages… or a list of all archived messages? Although we’ll talk about how to dispose and archive individual messages later in the article, one thing to understand is that when a message gets archived, the message.properties.inactive property gets set to true, and when a message gets disposed, the message.properties.disposed property gets set to true. Knowing this, we can now filter our results based on those properties:

$messages.getMessages().forEach(function (message) {
    if (!message.properties.disposed) {
        console.log("This message has not been disposed yet!");
    }
    if (!message.properties.inactive) {
        console.log("This message has not been archived yet!");
    }
    if (!message.isActive()) {
        console.log("The isActive method can also be used to check the .properties.inactive property!  There is no isDiposed one though!");
    }
});

You can even filter on other things, like the type of messages or dates!

$messages.getMessages().forEach(function (message) {
    if (!message.properties.disposed && message.isActive()) {
        if (message.getClassName() === "error") {
            console.log("We have an error message that was given on " + message.getDate() + "!");
        }
    }
});

Archiving

When a message is archived, it shows up as gray in the message center. A message gets archived when you acknowledge it (clicking the “close” button), automatically (like with progress messages), or when you programatically archive it. You can do this with a message’s doArchive() method. The following snippet will archive any active messages that are out there:

$messages.getActiveMessages().forEach(function (message) {
    message.doArchive();
});

Disposing

Disposing of a message makes it so that it doesn’t show up in the GUI list at all. Besides marking the message.properties.disposed property as true, it also seems to clean up some of the other properties and data that existed on the message. So this means that even though the message is still in the list of messages after being disposed, there’s really no useful data on it except for that disposed property. You can easily dispose of a message by calling its dispose() method.

$messages.getActiveMessages().forEach(function (message) {
    message.doArchive();
    message.dispose();
});

You’ll notice that in the above example we are calling doArchive() first. This is VERY important to do, because if you dispose of an active method without first archiving it, the message will remain counted as “unread” in the message center’s counter. This will leave our users in a confused state as they are being notified that they have new messages, but there is nothing in this list.

Show/Hide Archived Messages

You’ve probably noticed the checkbox in your Message Center that allows you to “Show Archived Messages”. Unchecking this box will hide any archived messages in the list. The anguilla API also allows you to view and manipulate this setting.

if ($messages.getIsShowArchivedMessages()) {
   // the checkbox is checked!
   $messages.setIsShowArchivedMessages(false); // but not anymore!
}

And with that I leave you with more power and knowledge to manipulate and bend the Message Center to your will!

Reloading/Refreshing Lists in the Shortcuts Area

A recent question on stack exchange regarding the ability to refresh or reload the favorites section using the Anguilla API has sparked this post, where I thought I would expand a little bit on the answer.  What the asker was doing was modifying the favorites (in this case the names), but needed the favorites section to be reloaded in order to reflect these changes. The shortcuts area is within an iFrame, so my first thought was to use some regular JavaScript to reload the iFrame itself via:

$('#FavoritesTree iframe').contentWindow.location.reload(true);

But as the asker of the question realized, this actually causes the entire Tridion GUI to be loaded into the shortcuts area!  Definitely not what we want to do!

Luckily for us, Anguilla has a way for us to unload the lists which will automatically cause them to reload. If we want to refresh all of the lists inside of the shortcuts area, we can do the folllowing:

$models.getItem("cme:userfavs").unloadLists();

So what’s going on in the above statement? $models.getItem("cme:userfavs") retrieves the shortcuts area which is represented by the type Tridion.Cme.Model.FavoritesFolder. This object contains the method unloadLists() which does the magic for us. But suppose you only want to refresh a specific list in the shortcuts area?

$models.getItem("cme:shortcuts").unloadLists();
$models.getItem("cme:custompgs").unloadLists();
$models.getItem("cme:usrtsks").unloadLists();

Again, with each of these items you can just call the unloadLists() method to refresh that specific list. You can even target the child lists of these lists… for example, $models.getItem("cme:chklst"); which would get the Checked-out Items from the My Tasks. If you’re trying to figure out how you can get the identifier to pass to $models.getItem(), a good place to look is the address bar after clicking on the list you wish to target with your code. For example, after clicking on the “Checked-out Items”, you should see “Dashboard.aspx#locationId=cme:chklst” at the end of the address bar. The value of the locationId is what you want to use.

Happy coding everyone!

Anguilla Framework: Adding Messages With MessageCenter and $messages

The Anguilla Framework comes with a built in message center that allows you to display different types of messages and notifications to the user. You’ve probably seen these messages already – they appear at the top of the window when you do almost any action in Tridion. They can even appear as a modal, display a question that requests a user response, or display a progress message that lets the user know when a certain action is done. When building your custom GUI Extensions, instead of rolling your own status or update notification system, you can easily take advantage of the one that’s already in place (and make your extension feel like its an actual part of Tridion). Messages added with MessageCenter are even archived, so your users can go back and view them if needed (by clicking the flag like symbol in the upper left of the screen). In this post, we’ll cover how to add notifications, warnings, errors, questions, goals and progresses.

Notifications

Notifications get displayed with the “i” icon (as with the image at the top of this post). You can use these for simple updates or alerts. Notifications will disappear from the upper window after around ten seconds or so.

$messages.registerNotification(title, description, local, modal)

title – The title of the notification. This is the message that gets displayed when shown at the top of the screen.
description – (optional) A better description of the notification. Can be seen when user double clicks the message to get further details.
local – (optional) You’ve probably noticed that most of the messages get displayed across all of your open Tridion windows. Setting ‘local’ to true will only display your message at the top of the window you are working with. Note that it still goes to the MessageCenter, so you can still view it from the other windows via the Message Center icon.
modal – (optional) True or false, if true your notification will get displayed as a modal popup.

Goals


Goals are like notifications, except they are displayed with a check mark icon (and a different colored background!).

$messages.registerGoal(title, description, local, modal)

Warnings


Warnings are also similar to notifications as well and are displayed with a warning icon. Unlike Notifications and Goals, the warning message will not disappear on its own (you’ll have to click the little ‘x’).

$messages.registerWarning(title, description, local, modal)

Errors


Errors, like Warnings, do not disappear on their own, and they are displayed with an error icon. They allow for an extra area for specifics of your error.
Error message with Details.

$messages.registerError(title, description, details, local, modal)

details – Details are for displaying the details of your error. For example, this area would be a lovely place to display the stack trace of an error.

Questions

Questions allow you to supply a simple “yes/no” type of question to the user. You can add events to your message to perform an action upon confirming or canceling. Messages are great to use with the modal feature.

Question with modal option turned on

$messages.registerQuestion(title, description, local, modal, buttonLabels)

buttonLabels – (optional) Additional settings to control the labels of the button, which by default is “Yes” and “No”. { cancel: "Cancel Label", confirm: "Confirm Label" }

Question Code Samples:

var question = $messages.registerQuestion("Do you love GUI Extensions?", null, true, true, { cancel: "Hate Them!", confirm: "Love Them!" });
question.addEventListener("cancel", function (event) {
    $messages.registerNotification("Haters want to hate!");
});
question.addEventListener("confirm", function (event) {
    $messages.registerNotification("TridionLove++");
});

Progress


Progress messages let you display the progress of an action, for example, “Saving…”. Like Questions, they are a bit more interactive than just displaying a message to the user. You can set messages that will get displayed upon success of your action using setOnSuccessMessage(title, description). You can even have a cancel button appear and allow the user to cancel your action, and set a message to display upon cancellation using setOnCancelMessage(title, description). Note that the Success and Cancel messages will be displayed as Goals.

$messages.registerProgress(title, description, canCancel, local, modal)

canCancel – (Optional) True or false, when set to true will display a cancel button

Progress Code Samples:

var progress = $messages.registerProgress("Waiting...", null, true);

progress.setOnCancelMessage("You've canceled!");
progress.setOnSuccessMessage("Done waiting!!!");

progress.addEventListener("cancel", function (event) { 
    console.log("Do stuff when canceled..."); 
});

// ... somewhere later in your code, in a callback or some form of dark arts... 
progress.finish({ success: true }); // Will remove progress message and display your onSuccess message.
progress.finish(); // Will remove progress message, but not display your onSuccess message.

// You can even cancel programatically by... clicking the cancel button or calling this method directly will display the cancel message.
progress.cancel();

You can also set it to automatically call the .finish() method upon the event of another item via the .addFinishEvent(itemID, event, isSuccessEvent) method. The follow example will show a loading status, and will automatically fire our success message upon completion of our item being loaded.

var item = $tcm.getItem("tcm:1-234"),
    progress = $messages.registerProgress("Loading our item...");

progress.setOnSuccessMessage("Item Finished Loading!");
progress.addFinishEvent("tcm:1-234", "load", true);

item.load();

Playing around in a console…

If you want a quick way to experiment with MessageCenter, feel free to try the $messages api inside of the console window!

Update 08/01/2014

I’ve posted a follow up on this article on retrieving, archiving, and disposing of messages. You can find it here!

Anguilla Framework: Getting Items From Tridion With $tcm.getItem() And Load Events

For anyone wondering if you can get items from Tridion using the Anguilla Framework in your GUI Extensions, the answer is yes, yes you can.

var yourPage = $tcm.getItem("tcm:10-1234-64");

Simple, no? Unfortunately your work is not done here. The getItem() method loads data from a cached state. That means, if the properties that you are looking for hasn’t been loaded already, they will return undefined.

var templateID = yourPage.getPageTemplateId();
// templateID is "undefined".

To load the data, you can call the load() method. You’ll also want to create an event handler for when the event has been triggered.

var yourPage = $tcm.getItem("tcm:10-1234-64");
$evt.addEventHandler(yourPage, "load", function (event) {
    doSomethingWithYourPage(yourPage); // event.source also contains the page instance.
});
yourPage.load();

Remember to take advantage of how Tridion handles the caching. The above script is inefficient, because you’ll always load the item regardless of whether or not it is in the cache. To check if the items has already been loaded, you could do the following:

var yourPage = $tcm.getItem("tcm:10-1234-64");
if (yourPage.isLoaded()) {
    doSomethingWithYourPage(yourPage);
} else {
    $evt.addEventHandler(yourPage, "load", function (event) {
        doSomethingWithYourPage(yourPage); // event.source also contains the page instance.
    });
    yourPage.load();
}

Another possible gotcha is that .load() doesn’t load everything. For example, the WebDav URL. After the .load() call, the following will still not return anything.

var webDavUrl = yourPage.getWebDavUrl();

To get the data you need in the above example, you would have to do something along the lines of:

var yourPage = $tcm.getItem("tcm:10-1234-64"),
    webDavUrl = yourPage.getWebDavUrl();

if (!webDavUrl) {
    $evt.addEventHandler(yourPage, "loadwebdavurl", function (event) {
        webDavUrl = yourPage.getWebDavUrl(); // also could do event.source.getWebDavUrl()
    });
    yourPage.loadWebDavUrl();
}

There are other load functions that load different types of data, each with their own events. Here is a list of them with their associated load and fail event names.

Function Name Load Event Load Fail Event
load() load loadfailed
loadBlueprintInfo() loadblueprintinfo loadblueprintinfofailed
loadItemCompareInfo() compareinfoload compareinfoloadfailed
loadRollBackComment() rollbackcommentload rollbackcommentloadfailed
loadWebDavUrl() loadwebdavurl loadwebdavurlfailed
staticLoad() staticload staticloadfailed

GUI Extensions: Experimenting with Extended Areas

A little back I posted Event System code on Inheriting the Page Process setting on Structure Groups. I mentioned the missing piece: an “Inherit Page Process” like check box to have full control over the inheriting process. I decided to investigate completing this little extension and started looking at Site Edit 2012′s code where they added check boxes for “Enable Inline Editing”. This brought me to experiment with the <ext:extendedareas /> section of the GUI Extension configuration, and I thought I’d share what I learned in case anyone was wondering what these areas were for or how to use them.  NOTE that this is just my experimentation, and if you see anything stated incorrectly (or done in an inefficient way) please do comment!

The Good News

Extended Areas allows you to add your custom controls and code to existing areas in Views. Since I was looking for a way to add a “Inherit Page Process” check box right below the “Associated Page Process” dropdown in the Workflow Tab of the StructureGroup view, I thought that this would be the perfect way of adding my little extension.

How It Works

The extendedarea section contains another section where you get to target the view and the ExtendableArea control. The following comes from the configuration in the SiteEdit 2012 GUI Extension.

<ext:extension assignid="EnableSEForPublicationTarget" name="{Resources: Tridion.Web.UI.Editors.SiteEdit.Strings, SE}">
    <ext:control>~/Extensions/PublicationTarget/EnableSiteEdit.ascx</ext:control>
    <ext:pagetype></ext:pagetype>
    <ext:renderinblock>false</ext:renderinblock>
    <ext:apply>
        <ext:view name="PublicationTargetView">
            <ext:control id="PublicationTargetTab_ExtendableArea" />
        </ext:view>
    </ext:apply>
</ext:extension>

The above example looks for the Control <c:ExtendableArea id=”PublicationTargetTab_ExtendableArea” runat=”server” RenderInBlock=”false” /> on the PublicationTarget View (PublicationTarget.aspx). It will inject a custom control you define in the <ext:control>~/YourControl.ascx</ext:control> into the ExtendableArea control. Sounds good, right?

The Bad News

Unfortunately, not every view and area contains an ExtendableArea control (hopefully this changes in the future!). And unfortunately for me, the WorkflowTab on StructureGroups is one of those unlucky areas that does NOT contain an ExtendableArea control. :(

With the real extension I plan on posting once its finished up, I’ll probably be doing a lil JavaScript injection to get my customization showing up. But for the sake of science and still wanting to complete my experiment using extended areas, I committed an abomination and a no no for this little experiment…

Adding the ExtendedableArea Control Manually

To see my check box get added using an extended area, I added my own ExtendableArea Control to Tridion’s WorkflowStructureGroup.ascx (\WebUI\Editors\CME\Tabs\Workflow\).

<%@ Import Namespace="Tridion.Web.UI"%>
<%@ Control Language="C#" AutoEventWireup="true" Inherits="Tridion.Web.UI.Editors.CME.Tabs.WorkflowStructureGroup" ClassName="WorkflowStructureGroup" %>

<div id="ItemWorkflow">
    <div id="Workflow_StructureGroup">
        <fieldset>
            <div class="field">
                <label for="PageProcess">
                    <asp:Literal runat="server" Text="<%$ Resources: Tridion.Web.UI.Strings, TextPageProcess %>" />
                </label>
                <c:Dropdown id="PageProcess" runat="server" NullText="<%$ Resources: Tridion.Web.UI.Strings, None %>" disabled="true"/>
            </div>
        </fieldset>
        <c:ExtendableArea id="WorkflowStructureGroup_ExtendableArea" runat="server" RenderInBlock="false" />
    </div>
</div>

InheritFromParent.ascx

The following is just the markup for my custom checkbox.

<%@ Control Language="C#" AutoEventWireup="true" %>
<div class="form stack-elem fieldgroup">
    <div class="field">
        <label for="InheritPageProcess">Inherit Page Process</label>
        <div>
            <input type="checkbox" id="InheritPageProcess" />
        </div>
    </div>
</div>
<div class="stack-elem hr"></div>

InheritFromParent.ascx.js

Again, much thanks to the Tridion UI 2012 code for the PublicationTarget extension for me to look at as a refererence. The following JavaScript was used just for some quick testing.

Type.registerNamespace("Tahzoo.Extensions.StructureGroup");

Tahzoo.Extensions.StructureGroup.InheritFromParent = function Tahzoo$Extensions$InheritFromParent() {
    Tridion.OO.enableInterface(this, "Tahzoo.Extensions.StructureGroup.InheritFromParent");
    this.addInterface("Tridion.DisposableObject");

    this.properties.controls = {};
};

Tahzoo.Extensions.StructureGroup.InheritFromParent.prototype.initialize = function Tahzoo$Extensions$InheritFromParent$initialize() {
    var c = this.properties.controls;
    console.log($display.getItem().getTitle() + " Initialized!");
    c.inheritPageProcess = $("#InheritPageProcess");
    c.inheritPageProcess.disabled = true;

    $evt.addEventHandler(c.inheritPageProcess, "click", this.getDelegate(this._onInheritPageProcessClicked));

    var item = $display.getItem();
    if (item) {
        $evt.addEventHandler(item, "load", this.getDelegate(this._onItemLoaded));
        $evt.addEventHandler(item, "change", this.getDelegate(this._onItemChanged));
        $evt.addEventHandler(item, "undocheckoutfailed", this.getDelegate(this._onItemChanged));
        $evt.addEventHandler(item, "checkinfailed", this.getDelegate(this._onItemChanged));

        if (item.isLoaded()) {
            this._onItemLoaded();
        }
    }

};

Tahzoo.Extensions.StructureGroup.InheritFromParent.prototype.disposeInterface = Tridion.OO.nonInheritable(function Tahzoo$Extensions$InheritFromParent$disposeInterface() {
    var item = $display.getItem();
    if (item) {
        $evt.removeEventHandler(item, "load", this.getDelegate(this._onItemLoaded));
        $evt.removeEventHandler(item, "change", this.getDelegate(this._onItemChanged));
        $evt.removeEventHandler(item, "undocheckoutfailed", this.getDelegate(this._onItemChanged));
        $evt.removeEventHandler(item, "checkinfailed", this.getDelegate(this._onItemChanged));
    }
});

Tahzoo.Extensions.StructureGroup.InheritFromParent.prototype._onItemLoaded = function Tahzoo$Extensions$InheritFromParent$_onItemLoaded() {
    console.log("Item Loaded");
    var item = $display.getItem();

    this._updateCheckbox(item);
};

Tahzoo.Extensions.StructureGroup.InheritFromParent.prototype._onItemChanged = function Tahzoo$Extensions$InheritFromParent$_onItemChanged(event) {
    console.log("Item Changed");
    this._updateCheckbox($display.getItem());
};

Tahzoo.Extensions.StructureGroup.InheritFromParent.prototype._onInheritPageProcessClicked = function Tahzoo$Extensions$InheritFromParent$_onInheritPageProcessClicked(event) {
    var c = this.properties.controls;
    console.log("Clicked To: " + c.inheritPageProcess.checked.toString());
};

Tahzoo.Extensions.StructureGroup.InheritFromParent.prototype._updateCheckbox = function Tahzoo$Extensions$InheritFromParent$_updateCheckbox(item) {
    this.properties.controls.inheritPageProcess.disabled = item.isReadOnly() || item.isLoading();
};

Tridion.Controls.Deck.registerInitializeExtender("WorkflowTab", Tahzoo.Extensions.StructureGroup.InheritFromParent);

The Extension Configuration (InheritedWFSG.config)

And finally we have our extension’s actual configuration file which pieces everything together.

<?xml version="1.0"?>
<Configuration xmlns="http://www.sdltridion.com/2009/GUI/Configuration/Merge" xmlns:cfg="http://www.sdltridion.com/2009/GUI/Configuration" xmlns:ext="http://www.sdltridion.com/2009/GUI/extensions" xmlns:cmenu="http://www.sdltridion.com/2009/GUI/extensions/ContextMenu">
    <resources cache="true">
        <cfg:filters/>
        <cfg:groups>
            <cfg:group name="Tridion.Extensions.GUI.InheritedWFSG.StructureGroup">
                <cfg:fileset>
                    <cfg:file type="script">/InheritFromParent.ascx.js</cfg:file>
                </cfg:fileset>
            </cfg:group>
        </cfg:groups>
    </resources>
    <definitionfiles/>
    <extensions>
        <ext:editorextensions>
        <ext:editorextension target="CME">
        <ext:editurls />
        <ext:listdefinitions />
        <ext:itemicons />
        <ext:taskbars />
        <ext:commands />
        <ext:commandextensions />
        <ext:contextmenus />
        <ext:lists />
        <ext:tabpages />
        <ext:toolbars />
        <ext:ribbontoolbars />
        <ext:extendedareas>
            <ext:add>
                <ext:extension assignid="InheritPageProcessFromParent" name="InheritPageProcessFromParent">
                    <ext:control>~/InheritFromParent.ascx</ext:control>
                    <ext:pagetype></ext:pagetype>
                    <ext:renderinblock>false</ext:renderinblock>
                    <ext:dependencies>
                        <cfg:dependency>Tridion.Extensions.GUI.InheritedWFSG.StructureGroup</cfg:dependency>
                    </ext:dependencies>
                    <ext:apply>
                        <ext:view name="StructureGroupView">
                            <ext:control id="WorkflowStructureGroup_ExtendableArea" />
                        </ext:view>
                    </ext:apply>

                </ext:extension>
            </ext:add>
        </ext:extendedareas>
        </ext:editorextension>
        </ext:editorextensions>
        <ext:dataextenders />
    </extensions>
    <commands/>
    <contextmenus/>
    <localization/>
    <settings>
        <defaultpage />
        <navigatorurl />
        <editurls/>
        <listdefinitions/>
        <itemicons/>
        <theme>
            <path/>
        </theme>
        <customconfiguration/>
    </settings>
</Configuration>

And there you have it! If you need help understanding how to deploy the above, check out this tutorial here.