Thingworx: Adding Dynamic Properties to Widget Extensions

Adding truncation feature to Axis Tick Labels in chart widget extension required the addition of dynamic properties.

Solution Phase One:

Use D3 to set titles on tick labels exceeding a user-configurable length:

if (thisWidget.truncateX !== 0) {
    d3.selectAll(widgetContainerId + '-chart .nv-x .nv-axis .tick text')
        .each(function (i) {
            var tick = d3.select(this);
            var fullText = tick.text();
            var truncText = fullText;
            // standard truncation with ellipses
            if (fullText.length > thisWidget.truncateX) {
                truncText = fullText.substring(0, thisWidget.truncateX) + '...';
            }
            // preceding truncation with ellipses
            if (thisWidget.truncateX < 0){
                truncText = '...' + fullText.substring(Math.abs(thisWidget.truncateX), fullText.length);
            }
            tick.text(truncText);
            tick
                .append("svg:title")
                .text(fullText)
    });
}

I Considered using an algorithm based on available space but this was complicated by text tilt option and the fact that it would have to calculate every label individually. Instead I added a property for X and Y axis truncation to the widget IDE. Note that using a negative number performs truncation at the start of the label string instead of the end. This is handy when labels suffer from an overuse of Hungarian notation.

Negative value leaves n characters at the end of the label and places a preceding ellipses

Standard truncation replaces limits to the first n characters and replaces the rest with an ellipses

I Used the standard Thingworx platform Tooltip library

…with the Widget chart extension to apply a tooltip to truncated text label’s title.

// qtip jquery extension over titles in d3 axis
$('title').each(function () { // Grab all elements with a title element,and set "this"
    $(this).parent().qtip({
        id : thisWidget.jqElementId,
        content: {
            text: $(this).text()
        },
        position: {
            target: 'mouse',
            adjust: {
                x: 10,
                y: 10
            }
        },
        style: {
            classes: 'widget-barChart-tooltipStyle' + customTooltipStyleName,
            widget: false, // Use the jQuery UI widget classes
            def: false // Remove the default styling (usually a good idea, see below)
        }
    });
    // remove title element
    $(this).remove();
});

I Removed the titles when I noticed browser tooltips poking through since titles are divs and not attributes in D3.

-12 truncation result

12 truncation result

 

I Used Qtip class name attribute to style tooltips according to widget property

This was the most confusing aspect of integrating the tooltips. Qtip lets you add a classname to the Qtip element that it puts on the Body DOM so you can create a style and have it apply to it (in style object in snippet above). Sounds simple! But QTip applies its own default class names to each tooltip on its own, and sometimes they superseded my custom classes. In the snippet above, classes is where a classname is defined, and widget and def are false to prevent the other classnames from being added by Qtip. The additional customToooltipStyleName is appended when the widget has a custom style applied to it. In this way, that classname matches the specific widget id, making it apply the custom style to that widget alone.

Default platform tooltip style

custom widget tooltip style

custom widget tooltip result

 

Solution Phase Two:

When multiple services provide data to Chart Widget, which one provides the label text?

I had sidestepped this issue in the original chart widget design by leaving out X-Axis Tick Labels. This was intentional: we released an MVP and wanted feedback from customers about what was important to them to include in a more elaborate solution. I added labels soon after release, but they only worked on single-service bound charts. Adding truncation to labels forced the issue that this effort should not be wasted on a feature that didn’t really work in every case.

Generate a service picker

Adding a property to the widget IDE to list off bound services seemed like it would be trivial, but this was not the case. There is no ready-made property that will display a drop-down list of data sources currently bound to a widget. There is no ready way to persist this information in the widget entity outside of the internal bindings definition, which is used by the Connections tab in the IDE and by Runtime to retrieve the data and update the widget display. This is not directly exposed in the widget API.

So, how to roll our own Bound Service Picker? I looked at how Collections widget used JSON to create and persist bindings and update the IDE properties from a service definition. I took this concept a significant step further by hiding the underlying JSON and using it to create a dropdown picker.

this.afterAddBindingSource = function (bindingInfo) { ...
this.afterRemoveBindingSource = function (bindingInfo) {...

These functions were the key to managing a list of binding: Every time anything is bound to the widget or unbound, one of these functions is called. The bindingInfo provides the name of the binding target property, and this enables selectively adding and removing dataSource names to a hidden string property that is constructed as a serialized array. The length of the array determines whether the DataSource picker is displayed in the IDE (since it is only required when 2 or more services are bound). The array also updates the selectOptions of the DataSource picker, which are the selections it displays in the dropdown.

Number of bound dataSources is reflected in properties. Each datasource has different labels

Short Labels from DataSource1

Long labels from DataSource2

Generate a Field picker

The same technique was used to populate a Field Picker. The contents of the field picker only needed to be retrieved when the first data source was bound, because every service bound to the Chart Widget has to share a common Data Shape. And the selectOptions of the Field Picker are the Field Definitions from the service Data Shape.

var dataShape = thisWidget.getInfotableMetadataForProperty(property) || {};

This function returns the fieldnames of a service when it is bound if it is called from within afterAddBindingSource() as value names. These are put in an array used to populate a Field Names dropdown property in the Widget IDE and serialized and put in a hidden string property for persistence.

The selected item in the pickers is persisted in the widget entity model and restored when the Mashup containing the widget Extension is reopened in Mashup Builder. If that were not the case, the selections can be monitored and set to hidden string properties as well. I set it up that way and then verified this additional step wasn’t needed.

Every Data Source has the same DataShape which in the example consists of these two field names.

Managing the Data Source and Field Name pickers

    /**
     * Invoked by the platform whenever the user binds a data source to a property on this widget.
     * @param bindingInfo <Object>        An object containing the newly created binding's properties.
     */

    this.afterAddBindingSource = function (bindingInfo) {
        var thisWidget = this;
        var property = bindingInfo.targetProperty;
        var properties = thisWidget.allWidgetProperties().properties;
var seriesNum = thisWidget.getProperty('NumberOfSeries');
        if( seriesNum === dataSources.length && seriesNum < thisWidget.MAX_SERIES){
            thisWidget.setProperty('NumberOfSeries', thisWidget.getProperty('NumberOfSeries') + 1);
        }
        // minimum binding requirement is met
        if (property.indexOf('Data') > -1) {
            this.jqElement.find(".configuration-warning").remove();
        }
        //adding multiple data services to populate chart - only need one dataShape - they all must be same
        if (property.indexOf('DataSource') > -1) {
            thisWidget.setProperty('SingleDataSource', false);
            var dataShape = thisWidget.getInfotableMetadataForProperty(property) || {};

            // build up an array of datashape names and types to allow user to pick dataSource to 
            // provide x-axis label field
            dataSourceList.push({'value': property, 'text' : property});
            // also provide a simple list of data sources to runtime to match them to the dataSource 
            //x-axis label field
            dataSources.push(property);

            // build field names list if it is not already defined
            if(String(thisWidget.getProperty('_dataShape')).length === 0) {
                for (var key in dataShape) {
                    // check also if property is not inherited from prototype
                    if (dataShape.hasOwnProperty(key)) {
                        var value = dataShape[key];
                        multiServiceDataShape.push({'value': value.name, 'text': value.name});
                        dataShapeList.push(value.name);
                    }
                }
            }

            properties['X-AxisLabelField']['selectOptions'] = multiServiceDataShape;
            properties['X-AxisLabelField']['isEditable'] = true;
            properties['X-AxisLabelField']['isVisible'] = true;

            thisWidget.setProperty('_boundDataSources', dataSources.join(','));
            thisWidget.setProperty('_dataShape', dataShapeList.join(','));

            properties['X-AxisLabelDataSource']['selectOptions'] = dataSourceList;
            properties['X-AxisLabelDataSource']['isEditable'] = true;
            properties['X-AxisLabelDataSource']['isVisible'] = true;

            this.setSeriesProperties(this.getProperty('NumberOfSeries'),
            this.getProperty('SingleDataSource'));
            this.updatedProperties();
        }
    };

    /**
     * Invoked by the platform whenever the user unbinds a data source to a property on this widget.
     * @param bindingInfo <Object>        An object containing the newly removed binding's properties.
     */

    this.afterRemoveBindingSource = function (bindingInfo) {
        var thisWidget = this;
        var property = bindingInfo.targetProperty;
        var properties = thisWidget.allWidgetProperties().properties;

        // remove the datasource that was unbound
        for(var i = 0; i < dataSourceList.length; i++){
            if(dataSourceList[i].value === property){
                dataSourceList.splice(i, 1);
            }
        }

        // dataSources is not in same order as dataSourceList so splice separately
        for(var i = 0; i < dataSources.length; i++){
            if(dataSources[i] === property){
                dataSources.splice(i, 1);
            }
        }

        thisWidget.setProperty('_boundDataSources', dataSources.join(','));

        if (property.indexOf('DataSource') > -1) {

            // the X-AxisLabelField doesn't provide any functionality when there is one binding
            if (dataSources.length <= 1){
                properties['X-AxisLabelField']['isVisible'] = false;
                properties['X-AxisLabelDataSource']['isVisible'] = false;
                this.updatedProperties();
            }
        }

        //all data bindings were removed - sound the alarm!
        if (dataSources.length === 0 && property.indexOf('Data') > -1) {
            this.addNoBindingWarning();
        }

        this.updatedProperties();
    };

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *

Captcha loading...

This site uses Akismet to reduce spam. Learn how your comment data is processed.