(5a) Multiple selection handling

Overview

This chapter is an extension of chapter five and explains the topic multiple selection handling in custom widgets available in MashZone NG 10.2 onwards.

This chapter is recommended for anyone planning to create or enrich a custom widget with a multiple data point selection that can be used to filter other widgets. The basis for this chapter is the single selection custom widget, that resulted from chapter five - Selection handling.

Widget Preview

The multiple selection widget works similar to the single selection widget, but in addition allows multiple selections.
image

Resulting files

The multiple selection widget consists of the following directories and files.
As compared to the structure of “Demo widget 5” the following folders and files were added or changed .

\---customWidgets
  \---demoWidget5a 
          |     demoWidget5aConfig.js 
          |     demoWidget5aModule.js 
          | 
          +---assets 
          |     +---css 
          |     |        widget_demoWidget5a.less  
          |     | 
          |     \---images 
          |              demoWidget5aMenuIcon_32x32.png 
          | 
          +---js 
          |        demoWidget5aAssignDataCtrl.js 
          |        demoWidget5aCtrl.js 
          |        demoWidget5aDataService.js 
          | 
          \---partials 
               |   demoWidget5a.html 
               | 
               \--- assignDataDialog
                     |   advancedProperties.html 
                     |   assignColumns.html 
                     |   assignData.html 
                     |   thresholdProperties.html 
                     | 
                     \---  columnTypeConfigTemplates 
                               date.html 
                               numeric.html 
                               text.html

The created files and source code accompanying this article can be downloaded here.

Creation steps

Step 1: Enable “Multiple selection” support

As a first step multiple selection support for the widget must be enabled in the widget config file. Therefore please edit demoWidget5aConfig.js and add the action “enableMultiSelect” in the action properties as shown in the example below:

demoWidget5aConfig.js

/**
 * Configuration for the custom widget
 * The config contains all configurable properties of the widget
 */
.config(['dashboardProviderProvider','columnTypeConfigUrlServiceProvider',
    function (dashboardProviderProvider, columnTypeConfigUrlServiceProvider) {
        dashboardProviderProvider.widget('demoWidget5a', {
            title: 'Demo Widget 5a',
            category: 'customWidget',
            toolTip : {
                "de":"Demo Widget 5a",
                "en":"Demo Widget 5a"
            },
            actions: ['assignData',
                'editName',
                'hideHeader','hideBorder',
                'autoRefresh',
                'hLine',
                'preselection', 'enableMultiSelect',
                'copy', 'paste', 'cut', 'delete',
                'toTop', 'bringForward', 'sendBackward', 'toBack'],
            viewModeActions: ['deleteSelection'],
...
    }
]);

Explanation : The added action “enable multi select” in line #19 will activate multiple selection support for the custom widget.

Having “multiple selection” activated the widget framework will offer a checkbox in the property panel to switch between single- and multiple selection mode.
image

Step 2: Extend the custom widget view template to handle multiple selections

In order to visualize multiple selections of data points in the custom widget the widget view must be adapted. Therefore please edit the custom widget view template demoWidget5a.html and adapt the html as follows:

demoWidget5a.html


<!--This is the view template for Demo Widget 5a -->
<div class="demoWidget5a">
    <h3>Multiple selection</h3>
    <div ng-show="columns.length == 0">No column was assigned.</div>
    <div ng-show="columns.length > 0">
        <div style="display: flex;">
            <div style="flex-grow: 1;">Row count: {{rowCount}}</div>
            <div style="flex-grow: 1;">Rows selected: {{selectedRows.length}}</div>
        </div>
        <table>
            <tr>
                <th ng-repeat="column in columns">
                    {{column.name}}
                </th>
            </tr>
            <tr ng-repeat="row in rows"
                class="rowEntry"
                ng-click="onRowClick(row)"
                ng-class="{'selected': isRowSelected(row)}"
                ng-style="{'background-color': getRowColor($index, row)}"
                >
                <td ng-repeat="column in columns">
...
                </td>
            </tr>
        </table>
    </div>
</div>

Explanation :

In line #8 a counter for the selected rows was added. This is optional and just for testing purposes.

In line #19 the ng-class directive was changed to add the CSS class “selected” to multiple selected table rows using the function “isRowSelected(row)”. This function is defined in the custom widget controller and takes the current row as a parameter. Inside the function it checks if the passed in row is part of the selected rows, if so, it returns true else it returns false.

Step 3: Extend the custom widget controller to handle multiple selections

In order to handle multiple selections in a custom widget and inform the other components about the selection(s), the custom widget controller needs to be extended as well.

Edit the file demoWidget5aCtrl.js and add the following services and functions.

demoWidget5aCtrl.js


    angular.module('demoWidget5aModule')
 
    /**
     * Controller for the custom widget
     * The controller provides the widget variables and manages the interactions between data-model and view
     */
    .controller('demoWidget5aCtrl',['$scope', '$rootScope', 'formatDateService','formatNumberService','thresholdConstants','thresholdService','filterService',
        function($scope, $rootScope, formatDateService, formatNumberService, thresholdConstants, thresholdService, filterService){
            //Create scope variables to reference the services used for value transformations in the custom widget view
            $scope.formatDateService = formatDateService;
            $scope.formatNumberService = formatNumberService;
 
            //This array holds the current thresholds
            var thresholds = [];
 
            //The column-index-map is used to map the column names onto the original column indices of the feed result
            var columnIndexMap = {};
            //This array holds the selected rows
            $scope.selectedRows = [];
            //This flag determines if mult-selection is enabled
            $scope.multiSelectEnabled = false;
 
            //This watcher observes changes made to the multiSelectEnabled property
            $scope.$watch('config.multiSelectEnabled', function (newValue, oldValue) {
                $scope.multiSelectEnabled = newValue;
                $rootScope.$broadcast("dashboardDefinitionChanged");
            });
 
...
 
            /**
             * This helper function determines the index of a selected row
             * @param row The row data object
             * @return The index value of the row, or -1 if not found
             */
            $scope.findRowIndex = function(row) {
                var rowValuesJoin = row.values.join("");
                return $scope.selectedRows.findIndex(function(r) {
                    return r.values.join("") === rowValuesJoin;
                });
            };
 
            /**
             * This function determines if the passed in row object is in the selectedRows array
             * @param row The row data object
             * @return true if the row is present in the selectedRows array, else false
             */
            $scope.isRowSelected = function(row) {
                return $scope.findRowIndex(row) !== -1;
            };
 
...
 
            /**
             * This function checks if the current feed result contains the selected value
             * If no matching selection value was found the widget selection will be removed
             * @param data The data for the assigned and server-requested data source columns (feed result)
             */
            var checkSelection = function(data){
                if($scope.selectedRows.length > 0) {
                    var _rows = [];
                    for(var i = 0; i < data.rows.length; i++) {
                        var row = data.rows[i];
                        if(!row.values || row.values.length < 1){
                            continue;
                        }
 
                        var rowValuesJoin = row.values.join("");
                        $scope.selectedRows.forEach(function(row) {
                            if(row.values.join("") === rowValuesJoin) {
                                _rows.push(row);
                            }
                        });
                    }
 
                    if(_rows.length > 0) {
                        var selectionObj = fillMultiRowDataArrays(_rows);
                        filterService.onSelectionChange($scope.item.identifier, selectionObj.columns, selectionObj.values, $scope.cardIdentifier);
                        $scope.selectedRows = _rows;
                    }
                    else {
                        filterService.onSelectionChange($scope.item.identifier, [], [], $scope.cardIdentifier);
                        $scope.selectedRows = [];
                    }
                }
            };
 
...
 
            /**
             * This helper function collects column names and column cell values for given rows
             * @param rows The given rows
             * @return an object containing the column names and selected column cell values
             */
            var fillMultiRowDataArrays = function(rows) {
                var resultColumnNames = [];
                var resultColumnValues = [];
 
                if($scope.selectedRows.length === 0) {
                    return {
                        columns: resultColumnNames,
                        values: resultColumnValues
                    };
                }
 
                rows = rows || $scope.selectedRows;
                for(var i=0; i < $scope.columns.length; i++) {
                    var configuredColumnName = $scope.configuredColumns[$scope.columns[i].name].newName;
                    resultColumnNames.push(configuredColumnName);
                    if($scope.multiSelectEnabled) {
                        var columnValues = [];
                        rows.forEach(function(row, index) {
                            columnValues.push(getCellValue(row, configuredColumnName));
                        });
                        resultColumnValues.push(columnValues);
                    }
                    else {
                        resultColumnValues.push(getCellValue(rows[0], configuredColumnName));
                    }
                }
 
                return {
                    columns: resultColumnNames,
                    values: resultColumnValues
                };
            };
 
            /**
             * This helper function informs the framework about selection changes
             * @param row The selected row
             */
            var handleRowSelection = function(row) {
                if(row) {
                    if($scope.multiSelectEnabled) {
                        var rowIndex = $scope.findRowIndex(row);
                        if(rowIndex === -1) {
                            $scope.selectedRows.push(row);
                        }
                        else {
                            $scope.selectedRows.splice(rowIndex, 1);
                        }
                    }
                    else {
                        $scope.selectedRows = [row];
                    }
                }
                var selectionObj = fillMultiRowDataArrays();
                filterService.onSelectionChange($scope.item.identifier, selectionObj.columns, selectionObj.values, $scope.cardIdentifier);
            };
 
            /**
             * This function is called when the user selects a row in the custom widget
             * @param row The selected row
             */
            $scope.onRowClick = function(row) {
                handleRowSelection(row);
            };
 
            /**
             * This event listener is called from the custom widget framework when the user clicks "Clear selection" in the widget menu
             * The event listener function clears the selection in the custom widget
             */
            $scope.$on("onDeleteSelection", function() {
                // make sure we remove all the previously selected rows
                $scope.selectedRows = [];
                handleRowSelection();
            });
 
            /**
             * This function is called from the widget framework to set a selection in the custom widget
             * It searches all custom widget rows to find one matching the given selection parameters
             * @param cols A list of all the columns in selection
             * @param values A list of the values selected corresponding to cols
             */
            $scope.setSelection = function(cols, values) {
                if(!cols || !values || cols.length !== values.length) {
                    return;
                }
 
                //loop through all the rows
                if(cols.length > 0) {
                    // reset the selected rows array
                    $scope.selectedRows = [];
 
                    var rowUnderInspection = null;
                    var rowUnderInspectionValuesJoined = null;
                    var rowValue = "";
                    if($scope.rows) {
                        for (var i = 0; i < $scope.rows.length; i++) {
                            rowUnderInspection = $scope.rows[i];
                            rowUnderInspectionValuesJoined = rowUnderInspection.values.join("");
                            if($scope.multiSelectEnabled) {
                                if(angular.isArray(values[0])) {
                                    for(var j = 0; j < values[0].length; j++) {
                                        rowValue = "";
                                        for(var k = 0; k < cols.length; k++) {
                                            rowValue += values[k][j];
                                        }
                                        if(rowValue === rowUnderInspectionValuesJoined) {
                                            $scope.selectedRows.push(rowUnderInspection);
                                            break;
                                        }
                                    }
                                }
                                else if(angular.isString(values[0])) {
                                    rowValue = values[0];
                                    if(rowUnderInspectionValuesJoined.match(rowValue)) {
                                        $scope.selectedRows.push(rowUnderInspection);
                                       break;
                                   }
                               }
                            }
                           else {
                               if(angular.isArray(values[0])) {
                                    rowValue = values.join("");
                                    if(rowValue === rowUnderInspectionValuesJoined) {
                                        $scope.selectedRows.push(rowUnderInspection);
                                        break;
                                    }
                               }
                               else if(angular.isString(values[0])) {
                                   rowValue = values[0];
                                    if(rowUnderInspectionValuesJoined.match(rowValue)) {
                                        $scope.selectedRows.push(rowUnderInspection);
                                       break;
                                  }
                                }
                           }
                        }
                    }
                }
                handleRowSelection();
            };
 
...
 
            initWidget();
        }
    ]);

Explanation : The following changes and extensions are to be made in the custom widget controller in order to support multiple selections.

In lines #7 and #8 the “$rootScope” dependency gets injected. This provides the $rootScope to be used in the controller.

In line #19 the “selectedRow” variable is changed to an “selectedRows” array to collect the selected rows.

In line #21 the variable “multiSelectEnabled” is to be created to determine if multiple selection is enabled or not.

Starting at line #24 a watch expression is to be added to update the “multiSelectEnabled” flag depending on the value set in the Multiple selection checkbox in the widget property panel.

Starting at line #36 the helper function “findRowIndex(row)” must be added. It determines and returns the index of a passed in row parameter.

Starting at line #48 the helper function “isRowSelected(row)” is to be created. This function takes a row as a parameter and checks if it is present in the selectedRows array or not. If yes, it returns true else false.

Starting at line #59 the function “checkSelection(data)” must be updated as in the source code example above.

Starting at line #95 the function “fillMultiRowDataArrays(rows)” is to be added as a replacement for the function “fillRowDataArrays(row, ioColumnNames, ioColumnValues)”. This helper function takes an array of rows as a parameter and returns an object containing column names and column cell values that describe the row selections.

This is an example for the data collected during a multiple selection, in this case the selection of three table rows containing the countries Germany, Sweden and UK, as shown in the preview above.



Selected rows:;

[ ["184620.22", "Germany"], ["46139.9", "Sweden"], ["113427.11", "UK"] ]

Columns and cell value object:

{

columns: ["Employees", "Country"],
values: [ ["184620.22", "46139.9", "113427.11"], ["Germany", "Sweden", "UK"] ]

}

Starting at line #132 the function “handleRowSelection()” must be adapted to support both single selection and multiple selection.

Starting at line #164 the event listener function for “onDeleteSelection” is to be adapted as in the source code example above.

Starting at line #175 the function setSelection(cols, values) must be adapted to support both single and multiple selection. This function is called by the widget framework when the data gets updated or filtering is applied to the custom widget.

Step 4: Extend the custom widget data service to manage list values

The coordinate list of a widget defines the columns that can be involved in for example filter dependencies with other widgets. In a single selection scenario the depending widgets exchange single coordinate values. After enabling a widget for multiple selections it will deliver multiple coordinate values. This is also reflected in the coordinate list of the widget as shown below:
image
The coordiante list of a single selection widget delivers single coordinate values as compared to a multiple selection widgets delivering list values.

In order to create the right entries in the coordinate list, the data service must be extended to manage list values. Therefore please edit the file demoWidget5aDataService.js and add the property “isList” to each coordinate object.

demoWidget5aDataService.js


angular.module('demoWidget5aModule')
 
    /**
     * Data service for the custom widget
     * The data service defines the data mapping - the structure of the data delivered by the server
     */
    .service('demoWidget5aDataService',['widgetDataService','formatNumberService','typeconstants',
        function(widgetDataService,formatNumberService,typeconstants) {
...
            return {
 
                getDataMapping : function(item) {
...
                },
 
                /**
                 * This function collects all coordinates (columns) provided by this custom widget.
                 * @param config The custom widget configuration
                 * @returns The coordinates provided by the custom widget
                 */
                calculateCoordinateList : function(config) {
                    var list = [];
                    if(config.assignedColumns && config.assignedColumns.numDataColumn){
                        list.push({
                            'name': config.assignedColumns.numDataColumn.newName,
                            'type': config.assignedColumns.numDataColumn.type,
                            'isList' : config.multiSelectEnabled
                        });
                    }
                    if(config.assignedColumns && config.assignedColumns.textDataColumn){
                        list.push({
                            'name': config.assignedColumns.textDataColumn.newName,
                            'type': config.assignedColumns.textDataColumn.type,
                            'isList' : config.multiSelectEnabled
                        });
                    }
                    if(config.assignedColumns && config.assignedColumns.dateDataColumn){
                        list.push({
                            'name': config.assignedColumns.dateDataColumn.newName,
                            'type': config.assignedColumns.dateDataColumn.type,
                            'isList' : config.multiSelectEnabled
                        });
                    }
                    if(config.assignedColumns && config.assignedColumns.furtherColumns){
                        for(var i = 0; i < config.assignedColumns.furtherColumns.length; i++){
                            var column = config.assignedColumns.furtherColumns[i];
                            list.push({
                                'name': config.assignedColumns.furtherColumns[i].newName,
                                'type': config.assignedColumns.furtherColumns[i].type,
                                'isList' : config.multiSelectEnabled
                            });
                        }
                    }
                    return list;
                }
            };
        }
    ]);

Explanation : The function “calculateCoordinateList(config)”, starting in line #21, creates an array containing coordinate objects with column name, column type and a now also a boolean property determining if the coordinate is a list or not. This information is taken from the given “config” parameter.

Summary and sources

As a final result of this chapter, the custom widget supports both multiple and single selection.

The source code for " Demo Widget 5a " can be downloaded here.

Read in this series: