Sunday, November 19, 2017

An AngularJS Dashboard, Part 7: Smart Tile Fill Algorithm

NOTE: for best results, view the http: version of this page (else you won't get syntax highlighting).

This is Part 7 in a series on creating a dashboard in AngularJS. I'm blogging as I progressively create the dashboard, refining my Angular experience along the way.

Previously in Part 6, we added confirmation dialogs and added a Make Default Layout action. Today, we're going to address something that's been bothering me all along (and probably you readers as well): the lack of a smart tile fill algorithm.

Today we will:

• Implement a smart-fill algorithm so that our dashboard is always filled well, no matter what  mix of tile sizes and screen size we have.
• Update the markup, CSS, and JavaScript so that we get good dashboard rendering across all popular browsers, including Chrome, Firefox, Safari, Edge, and IE.

Here's what we'll end up with today. For a limited time, there is an online demo available as well.


Smart Tile Fill

From the very start our TFS-inspired dashboard has supported tiles that are 1- and 2-units wide or tall, for a total of 4 sizes in all. That's useful, but it also creates a problem: your dashboard might be beautiful or it might be full of empty areas. We've admitted all along that depending on how many tile sizes you use and the screen width your dashboard renders on you could easily end up with a bunch of awkward gaps.

Our Past Attempts

Our initial tile rendering in Part 1 simply used a 200px multiplier on tile width and height, plus a right- and bottom-margin of 16px, letting the tiles flow on the page and letting the browser wrap to the next line when there was no more room for tiles on a row. Simple, but hardly elegant. Our dashboard was always geometrically aligned, but rarely filled well unless we restrained ourselves.

Knowing this initial approach wasn't good enough, in Part 2 we switched to using tables for layout (inwardly anticipating hate mail from designers who have long maintained tables shouldn't be used for layout). We also added a function to our controller, computeLayout, that is called after a dashboard is loaded; and, whenever the dashboard is changed. computeLayout's job was to come up a row-column tile layout based on the user's current browser window width, and to decide what would fit on each row of the table. The thinking was this would be an improvement on our initial approach. We leveraged colspan="2" for wide tiles, and we expected to eventually add smarts to also use rowspan="2" for tall tiles. In turn, our HTML template has two nested ng-repeat loops: an outer loop to iterate through the row list that computeLayout produced, and an inner one to iterate through the columns of each row. While all of this did improve tile rendering, it hardly resolved all the issues. We were—um—careful to only show our examples with tile layouts and browser widths that just happened to render well. Again, it was still quite possible have dashboards that had a lot of holes in them.

In Parts 3-6, we concentrate on other important matters--namely, implementing tiles, two chart services, two chart services, and tile actions. Our dashboard really started coming to life--but all along, our not-quite-there-yet tile layout cast a shadow on all our work.

Revisiting Tile Layout

Now that we've reviewed our past attempts, how are we going to do better? Here's the plan:
  1. Abandon the use of a table for controlling layout. Instead, our controller's computeLayout function will compute exact x and y positioning for each tile. The HTML template will position each tile precisely using position: absolute (within a position: relative containing div).
  2. In computeLayout, use a bitmap approach to track the filled / available areas of a matrix.
  3. In compueLayout, for each tile we need to place, find the best open spot in the bitmap for each tile and set its x and y from that.
That's the plan. Now let's get to it.

Using a Bitmap in computeLayout

A bitmap is a simple matrix (table) of true/false flags. In the controller's computeLayout function, we declare an array of arrays named matrix. Although this will hold integers, we're only writing and reading ones and zeroes, so logically we're treating matrix as a bitmap. We're going to set a limit that a dashboard will never have more than 10 rows of 20 tile units across.
self.tilesdown = 10;        // hard-coded limitation for now.

...

// Compute layout based on current screen dimensions (self.tablecolumns()) and generate updated tile layout
// Inputs: self.tablecolumns() ........... number of tiles across.
// Ouptuts: self.tiles ................... sets .x and .y property of each tile to indicate where it should be positioned.

self.computeLayout = function () {
    if (self.tiles == null) return;

    var matrix = [[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0]];

    var numcols = self.tablecolumns();
    if (numcols < 1) numcols = 1;

    // This is used in template to render things like table <col> elements.
    self.tilesacross = numcols;
    self.tileunits = [];
    for (var u = 0; u < numcols; u++) {
        self.tileunits.push(u);
    }

Initializing matrix (bitmap)

Later in the computeLayout function, the code iterates through each tile. There are four versions of similar code, one for each possible tile size. The code loops through the matrix, looking for a "hole" of zeroes in the right shape. Once it finds a hole, it sets the hole to ones in the matrix and sets the tiles's x and y properties--mulitplying by 200 and adding in 16 for spacing between tile units.

var xMargin = 16;
var yMargin = 16;
for (var t = 0; t < self.tiles.length; t++) {
    tile = self.tiles[t];
    if (index + parseInt(tile.width) > numcols) { // no more room in row. commit row and start new one.
        newlayout.push(row);
        row = [];
        index = 0;
    }
    tile.id = tileid.toString();
    tileid = tileid + 1;
    var across = self.tilesacross - 1;
    if (across > 20) across = 20;       // enforce limit of 20 tile units across
    if (across < 1) across = 1;
     switch (tile.height) {
        case '1':
            switch (tile.width) {
                case '1':
                    // find a 1x1 available location
                    loop_1_1:
                    for (var r = 0; r < self.tilesdown; r++) {
                        for (var c = 0; c < self.tilesacross; c++) {
                            if (matrix[r][c] === 0) {
                                tile.x = xMargin + (c * 216);
                                tile.y = yMargin + (r * 216);
                                matrix[r][c] = 1;
                                //console.log('tile ' + tile.id + ' (1x1): r:' + r.toString() + ', c:' + c.toString() + ', x:' + tile.x.toString() + ', y:' + tile.y.toString());
                                break loop_1_1;
                            } // end if
                        } // next c
                    } // next r
                    break;
                case '2':
                    // find a 2x1 available location
                    loop_2_1:

                        for (var r = 0; r < self.tilesdown; r++) {
                        for (var c = 0; c < across; c++) {
                            if (matrix[r][c] === 0 && matrix[r][c+1]===0) {
                                tile.x = xMargin + (c * 216);
                                tile.y = yMargin + (r * 216);
                                matrix[r][c] = 1;
                                matrix[r][c + 1] = 1;
                                //console.log('tile ' + tile.id + ' (2x1): r:' + r.toString() + ', c:' + c.toString() + ', x:' + tile.x.toString() + ', y:' + tile.y.toString());
                                break loop_2_1;
                            } // end if
                        } // next c
                    } // next r
                    break
            } // end switch
            break;
        case '2':
            switch (tile.width) {
                case '1':
                    // find a 1x2 available location
                    loop_1_2:
                        for (var r = 0; r < self.tilesdown - 1; r++) {
                        for (var c = 0; c < self.tilesacross; c++) {
                            if (matrix[r][c]===0 && matrix[r+1][c]===0) {
                                tile.x = xMargin + (c * 216);
                                tile.y = yMargin + (r * 216);
                                matrix[r][c] = 1;
                                matrix[r + 1][c] = 1;
                                //console.log('tile ' + tile.id + ' (1x2): r:' + r.toString() + ', c:' + c.toString() + ', x:' + tile.x.toString() + ', y:' + tile.y.toString());
                                break loop_1_2;
                            } // end if
                        } // next c
                    } // next r
                    break;
                case '2':
                    // find a 2x2 available location
                    loop_2_2:
                        for (var r = 0; r < self.tilesdown - 1; r++) {
                        for (var c = 0; c < across; c++) {
                            if (matrix[r][c] === 0 && matrix[r][c + 1] === 0 &&
                                matrix[r+1][c] === 0 && matrix[r+1][c+1] === 0) {
                                tile.x = xMargin + (c * 216);
                                tile.y = yMargin + (r * 216);
                                matrix[r][c] = 1;
                                matrix[r][c + 1] = 1;
                                matrix[r+1][c] = 1;
                                matrix[r + 1][c + 1] = 1;
                                //console.log('tile ' + tile.id + ' (2x2): r:' + r.toString() + ', c:' + c.toString() + ', x:' + tile.x.toString() + ', y:' + tile.y.toString());
                                break loop_2_2;
                            } // end if
                        } // next c
                    } // next r
                    break
            } // end switch
            break;
    }

Finding a Hole for each Tile Size

Template Rendering

Once the x and y positions have been computed for each tile, the template is rendered. The previous use of a table has been eliminated. We now have a single ng-repeat loop (line 5) that cycles through the controller's tiles array. The outer dashboard div (line 4) is now display: relative, and within that contain tiles are positioned absolutely (line 9) with display: absolute. The tile's x and y properties (set by computeLayout) are used to position the tile in line 10.
<div class="dashboard-controller" window-size>
    <div>
        <div>{{$ctrl.title}} (chart provider: {{$ctrl.chartProvider}} - data provider: {{$ctrl.dataProvider}})</div>
        <div class="dashboard-panel" style="position: relative" ng-style="{'cursor': $ctrl.waiting ? 'wait' : 'default'}">
            <null ng-repeat="tile in $ctrl.tiles track by $index">
                ...
                <!-- Populated tile (data loaded) -->
                <div id="tile-{{tile.id}}" ng-if="tile.haveData"
                        class="tile" ng-class="tile.classes" ng-style="{ 'background-color': $ctrl.tileColor(tile.id), 'color': $ctrl.tileTextColor(tile.id), 'top': $ctrl.tileY(tile.id), 'left': $ctrl.tileX(tile.id) }"
                        style="overflow: hidden; position: absolute; display: inline-block"
                        draggable="true" ondragstart="tile_dragstart(event);"
                        ondrop="tile_drop(event);" ondragover="tile_dragover(event);">
                    <div class="dropdown" style="height: 100%">
                        <div class="hovermenu">
                            <i class="fa fa-ellipsis-h dropdown-toggle" data-toggle="dropdown" aria-hidden="true"></i>
                            <ul class="dropdown-menu" style="margin-top: -30px; margin-left:-150px !important">
                                <li><a id="tile-config-{{tile.id}}" href="#" onclick="configureTile(this.id);"><i class="fa fa-gear" aria-hidden="true"></i>  Configure Tile</a></li>
                                <li><a href="#" onclick="configureTile('0');"><i class="fa fa-plus-square-o" aria-hidden="true"></i>  Add Tile</a></li>
                                <li><a id=tile-remove-{{tile.id}}" href="#" onclick="removeTileConfirm(this.id);"><i class="fa fa-trash-o" aria-hidden="true"></i>  Remove Tile</a></li>
                                <li><a id=tile-reset-{{tile.id}}" href="#" onclick="resetDashboardConfirm();"><i class="fa fa-refresh" aria-hidden="true"></i>  Reset Dashboard</a></li>
                                <li ng-if="$ctrl.user.IsAdmin"><a id=tile-reset-{{tile.id}}" href="#" onclick="saveDefaultDashboardConfirm();"><i class="fa fa-check-square-o" aria-hidden="true"></i>  Make Default Layout</a></li>
                            </ul>
                        </div>
                        ...markup for each kind of tile...
                    </div> <!-- end tile -->
                </null>
        </div>
    </div>
   ...
</div>
And that's pretty much all there is to it.

Dashboard Smart Fill in Action

Now to see how well this all works. To start with, at 1920 x 1080, we're going to set up the following dashboard tiles. Note we're going out of our way to have an interesting mix of tiles sizes so that we can put this code through its paces.
  1. Customers: red 1x1 counter tile
  2. Customer Satisfaction: dark red 2x1 KPI tile
  3. Revenue Share Per Store: greed 2x1 pie chart tile
  4. Orders: gold 2x2 table tile
  5. Revenue by Store: blue 2x2 bar chart tile
  6. Orders: orange 1x1 counter tile
Here's how this dashboard looks at 1920 x 1080.

Mix-tile sizes at 1920 x 1080

We've added some console.log statements in computeLayout so you can see how the tile placement decisions are made. Note that Tile 5 (orange orders 1x1 tile) is not at the end--there was room in the placement for a 1x1 tile on the first row, so it was moved to position row 0 column 5 to avoid a gap.

Here's the same dashboard at about 2/3 the screen width, 1300 pixels across:

Same dashboard, 1300 pixels across


We can see that the rendered layout has shifted. By the time we get to tile 3, we're already out of space on the first row, so now we start filling tiles on row 2. But there's room for tile 5 up at row 0 column 3, so it gets moved. 

Lastly, let's go to an iPhone6-size window. This time, we'll graphically indicate how the bitmap is used as well.


Dashboard rendered on iPhone6 sized window, and bitmap


Once again, things have shifted to leverage the available space without waste. Notice that tile 5 was moved up to row 1, column 2 because there was a gap there of the right size. 

Let's consider another example. Below is a different dashboard at 1920 across. This time, we've altered the titles to include the tile number so it will be easy to see when things get re-arranged.

Dashboard 2, 1920 across

In the above rendering, tiles 1-8 are rendered in their defined sequence, because they fit well. Now, we'll reduce our width to about 700 pixels across. Notice in the rendering below that tile 7 follows tile 5--it's been moved up to avoid the gap that would be there because there's no room or tile 6 on that row.


Dashboard 2, 700 across

Finally, we have the smart tile fill we vaguely envisioned at the start of this series.

Better Browser Support and Visual Improvements

We've done a lot of testing this time around in different browsers and we've fixed a number of things. Although we won't go into the details, the latest code has these improvements:

  • Added a JavaScript Promise polyfill for browsers that don't natively support Promise (such as IE).
  • Resolved an issue with the table tile where vertical and horizontal scroll bars didn't always appear, due to differences in how browsers interepret height: 100%. (affected Edge, IE, FireFox).
In addition to browser-specific issues, we also made some other general visual improvements:
  • Corrected some imperfect JavaScript controller code that wasn't always rendering charts well, depending on tile size and chart library in use.
  • Now re-renders tiles immediately when you change width or height in the configuration dialog.
  • Fixed a problem where the vertical spacing between tiles was different from the horizonal spacing between tiles, creating a noticable asymmetry.
  • The dashboard can now be rendered across a really large display (or across two monitors), up to 20 tile units (about 4320 pixels) across.

Summary

Today in Part 7 we implemented smart tile rendering as well as code updates to work well across all popular browsers--allowing us to now freely use our dashboard's tiles and sizes without concern about how it is going to look, no matter where it is viewed.

Download Code
Dashboard_07.zip
https://drive.google.com/file/d/1NQ9GU2a8bwjKV0lDL4P-km1qxWoCOBmv/view?usp=sharing





Monday, November 13, 2017

An AngularJS Dashboard, Part 6: Admin Tile Actions and Confirmation Dialogs

NOTE: for best results, view the http: version of this page (else you won't get syntax highlighting).

This is Part 6 in a series on creating a dashboard in AngularJS. I'm blogging as I progressively create the dashboard, refining my Angular experience along the way.

Previously in Part 5, we added a user interface for configuring tiles, dashboard layout persistence, and tile menu actions. We also admitted a lot of the code was new and hadn't gone through a deep test and debug cycle.

Today, we're going to:

  • Polish the existing tile actions with confirmation dialogs.
  • Add a new tile action, Make Default Layout, for setting the default dashboard (available only to administrators).
  • Pass user information and privileges from the MVC back end to the Angular code.
  • Improve the appearance of the Table tile.
  • Release updated code with bug fixes.
Here' a view of what we'll be ending up with today:


Confirmation Dialogs

Last time, we added a number of tile menu actions. Some of these, like Remove Tile or Reset Dashboard, delete parts of your saved dashboard layout. We shouldn't be taking potentially destructive actions without being sure it's what the user wants, so we're now going to add confirmation dialogs for these actions.

The approach we'll be taking to confirmation dialogs is to use leverage Bootstrap, which has been part of our soluton all along. We'll be re-using the same confirmation dialog for each confirmation, so let's add that markup to our HTML template. The dialog has id confirm-reset-modal.
<!-- Confirmation dialog -->

<div class="modal fade" tabindex="-1" role="dialog" aria-labelledby="confirm-label" aria-hidden="true" id="confirm-reset-modal">
    <div class="modal-dialog modal-md">
        <div class="modal-content">
            <div class="modal-header">
                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
                <h4 class="modal-title" id="confirm-label">Confirmation Message</h4>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-success" id="modal-btn-yes">Yes</button>
                <button type="button" class="btn btn" id="modal-btn-no">No</button>
            </div>
        </div>
    </div>
</div>
HTML template with confirmation dialog markup

Previously, the tile menu actions simply invoked functons (removeTile, etc.) that called counterpart functions in the controller, taking immediate action. Now, we'll be calling confirmation functions first that will ask the user if they are sure they want to take the selected action. Only in the case of a Yes response will the action take place. Our first step, then, is to update the tile menu markup to call confirmation functions.
<!-- Populated tile (data loaded) -->
<div id="tile-{{tile.id}}" ng-if="tile.haveData"
        class="tile" ng-class="tile.classes" ng-style="{ 'background-color': $ctrl.tileColor(tile.id), 'color': $ctrl.tileTextColor(tile.id) }"
        style="overflow: hidden"
        draggable="true" ondragstart="tile_dragstart(event);"
        ondrop="tile_drop(event);" ondragover="tile_dragover(event);">
    <div class="dropdown" style="height: 100%">
        <div class="hovermenu">
            <i class="fa fa-ellipsis-h dropdown-toggle" data-toggle="dropdown" aria-hidden="true"></i>
            <ul class="dropdown-menu" style="margin-left:-150px !important">
                <li><a id="tile-config-{{tile.id}}" href="#" onclick="configureTile(this.id);"><i class="fa fa-gear" aria-hidden="true"></i>  Configure Tile</a></li>
                <li><a href="#" onclick="configureTile('0');"><i class="fa fa-plus-square-o" aria-hidden="true"></i>  Add Tile</a></li>
                <li><a id=tile-remove-{{tile.id}}" href="#" onclick="removeTileConfirm(this.id);"><i class="fa fa-trash-o" aria-hidden="true"></i>  Remove Tile</a></li>
                <li><a id=tile-reset-{{tile.id}}" href="#" onclick="resetDashboardConfirm();"><i class="fa fa-refresh" aria-hidden="true"></i>  Reset Dashboard</a></li>
                <li ng-if="$ctrl.user.IsAdmin"><a id=tile-reset-{{tile.id}}" href="#" onclick="saveDefaultDashboardConfirm();"><i class="fa fa-check-square-o" aria-hidden="true"></i>  Make Default Layout</a></li>
            </ul>
        </div>
Tile menu updated to call confirmation functions

We've switched to calling confirmation functions for Remove Tile, Reset Default Dashboard, and our new action Make Default Layout.

Remove Tile

The confirmation function for Remove Tile is shown below. Line 4 sets the confirmation message; lines 6-15 set handlers for the Yes and No buttons; and line 17 displays the confirmation dialog. If Yes, is selected, the original removeTile function is executed to remove the tile and the modal dialog is hidden. If No is selected, no action ensues and the modal dialog is hidden.
// removeTile : Remove a tile.

function removeTileConfirm(id) {
    $('#confirm-label').html('Are you sure you want to remove this tile?');

    $("#modal-btn-yes").on("click", function () {
        $("#modal-btn-yes").off();
        $("#modal-btn-no").off();
        removeTile(id);
        $("#confirm-reset-modal").modal('hide');
    });

    $("#modal-btn-no").on("click", function () {
        $("#confirm-reset-modal").modal('hide');
    });

    $("#confirm-reset-modal").modal('show');
}

function removeTile(id) {
    var scope = angular.element('#dashboard').scope();
    var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;

    scope.$evalAsync(function () {
        return ctrl.removeTile(id);
    });
}

Updated JavaScript code for Remove Tile

When the user selectes Remove Tile from the tile menu, they now see this confirmation dialog:

Remove Tile Confirmation Dialog

Clicking Yes proceeds to remove the tile, by calling the original removeTile function.

Reset Dashboard

Following the exact same pattern, here is the code for Reset Dashboard.
// resetDashboard : Restore default dashboard by deleting user's saved custom dashboard.

function resetDashboardConfirm() {
    $('#confirm-label').html('Are you sure you want to reset your dashboard to the default layout?');

    $("#modal-btn-yes").on("click", function () {
        $("#modal-btn-yes").off();
        $("#modal-btn-no").off();
        resetDashboard();
        $("#confirm-reset-modal").modal('hide');
    });

    $("#modal-btn-no").on("click", function () {
        $("#confirm-reset-modal").modal('hide');
    });

    $("#confirm-reset-modal").modal('show');
}

function resetDashboard() {
    var scope = angular.element('#dashboard').scope();
    var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;

    scope.$evalAsync(function () {
        return ctrl.resetDashboard();
    });
}
When the Reset Dashboard tile action is taken, the user sees this confirmation dialog.

Reset Dashboard Confirmation Dialog

Clicking Yes resets the dashboard, by calling the original resetDashboard function.

New Tile Action: Make Default Layout

We're adding a new tile action named Make Default Layout. Up till now, we've seen that we have a default layout defined in the database; and that we can persist a customized layout for a user. What's been lacking is a way to take a customized layout and make it the new default for all users. That's what this new action will do.


// saveDefaultDashboard : Save current layout as the default layout for all users.

function saveDefaultDashboardConfirm() {
    $('#confirm-label').html('Are you sure you want to make this layout the default for all users?');

    $("#modal-btn-yes").on("click", function () {
        $("#modal-btn-yes").off();
        $("#modal-btn-no").off();
        saveDefaultDashboard();
        $("#confirm-reset-modal").modal('hide');
    });

    $("#modal-btn-no").on("click", function () {
        $("#confirm-reset-modal").modal('hide');
    });

    $("#confirm-reset-modal").modal('show');
}

function saveDefaultDashboard() {
    console.log('saveDefaultDashboard');
    var scope = angular.element('#dashboard').scope();
    var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;

    scope.$evalAsync(function () {
        return ctrl.saveDefaultDashboard();
    });
}


Make Default Layout Confirmation Dialog

Below is the new controller function saveDefaultDashboard. This code leverages existing controller code and Data Service code for saving a dashboard, but an isDefault flag has been added to DataService.saveDashboard. When a true is passed to DataService.saveDashboard, that means we are updating the default dashboard rather than the user's customized dashboard.

// saveDefaultDashboard : make user's dashboard the default dashboard for all users

self.saveDefaultDashboard = function () {
    self.wait(true);
    DataService.saveDashboard(self.tiles, true);
    toastr.options = {
        "positionClass": "toast-top-center",
        "timeOut": "1000",
    }
    toastr.info('Default Dashboard Layout Saved');
    self.wait(false);
};
Controller saveDefaultDashboard function

In DataService.saveDashboard (SQL Server version shown below), the only change is the addition of the isDefault flag, which is passed in the structure sent to the SaveDashboard MVC action.

// -------------------- saveDashboard : updates the master layout for tiles (returns tiles array). If isDefault=true, this becomes the new default dashboard layout. ------------------

self.saveDashboard = function (newTiles, isDefault) { 

    var Dashboard = {
        DashboardName: null,
        IsAdmin: false,
        Username: null,
        Tiles: [],
        Queries: null,
        IsDefault: isDefault
    };

    var tile = null;
    var Tile = null;

    // create tile object with properties

    for (var t = 0; t < newTiles.length; t++) {
        tile = newTiles[t];
        Tile = {
            Sequence: t+1,
            Properties: [
                { PropertyName: 'color', PropertyValue: tile.color },
                { PropertyName: 'width', PropertyValue: parseInt(tile.width) },
                { PropertyName: 'height', PropertyValue: parseInt(tile.height) },
                { PropertyName: 'title', PropertyValue: tile.title },
                { PropertyName: 'type', PropertyValue: tile.type },
                { PropertyName: 'dataSource', PropertyValue: tile.dataSource },
                { PropertyName: 'columns', PropertyValue: JSON.stringify(tile.columns) },
                { PropertyName: 'value', PropertyValue: JSON.stringify(tile.value) },
                { PropertyName: 'label', PropertyValue: tile.label },
                { PropertyName: 'link', PropertyValue: tile.link },
                { PropertyName: 'format', PropertyValue: tile.format }
            ]
        };
        Dashboard.Tiles.push(Tile);
    };

    var request = $http({
        method: "POST",
        url: "/Dashboard/SaveDashboard",
        data: JSON.stringify(Dashboard),
        headers : {
            'Content-Type': 'application/json'
        }

    });

    return (request.then(handleSuccess, handleError));
};

DataService.saveDashboard

In the MVC SaveDashboard action below, the Dashboard object now has an IsDefault bool flag. When SaveDashboard is called, it's either for the traditional purpose of saving the user's custom dashboard layout (IsDefault=false), or for saving a new default dashboard that applies to everyone (IsDefault=true). Lines 6-7 customize values used in INSERT database queries depending on what the target dashboard is. In line 222, the same thing happens for DeleteDashboard.
[HttpPost]
public void SaveDashboard(Dashboard dashboard)
{
    try
    {
        int priority = dashboard.IsDefault ? 1 : 2;
        String username = dashboard.IsDefault ? "default" : CurrentUsername();

        DeleteDashboard(dashboard.IsDefault);  // Delete prior saved dashboard (if any) for user.

        // Check whether an existing dashboard is saved for this user. If so, delete it.

        int dashboardId = -1;

        using (SqlConnection conn = new SqlConnection(System.Configuration.ConfigurationManager.AppSettings["Database"]))
        {
            conn.Open();

            // Add dashboard layout root record

            String query = "INSERT INTO DashboardLayout (DashboardName, Username, Priority) VALUES (@DashboardName, @Username, @priority); SELECT SCOPE_IDENTITY();";

            using (SqlCommand cmd = new SqlCommand(query, conn))
            {
                cmd.Parameters.AddWithValue("@DashboardName", "Home");
                cmd.Parameters.AddWithValue("@Username", username);
                cmd.Parameters.AddWithValue("@Priority", priority);
                using (SqlDataReader reader = cmd.ExecuteReader())
                {
                    if (reader.Read())
                    {
                        dashboardId = Convert.ToInt32(reader[0]);
                    }
                }
            }

            if (dashboardId!=-1) // If root record added and we have an id, proceed to add child records
            {
                // Add DashboardLayoutTile records.

                int sequence = 1;
                foreach (Tile tile in dashboard.Tiles)
                {
                    query = "INSERT INTO DashboardLayoutTile (DashboardId, Sequence) VALUES (@DashboardId, @Sequence)";

                    using (SqlCommand cmd = new SqlCommand(query, conn))
                    {
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.ExecuteNonQuery();
                    }
                    sequence++;
                } // next tile

                // Add DashboardLayoutTileProperty records.

                sequence = 1;
                foreach (Tile tile in dashboard.Tiles)
                {
                    query = "INSERT INTO DashboardLayoutTileProperty (DashboardId, Sequence, PropertyName, PropertyValue) VALUES (@DashboardId, @Sequence, @Name, @Value)";

                    using (SqlCommand cmd = new SqlCommand(query, conn))
                    {
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "color");
                        if (tile["color"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["color"]);
                        }
                        cmd.ExecuteNonQuery();

                        cmd.Parameters.Clear();
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "height");
                        if (tile["height"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["height"]);
                        }
                        cmd.ExecuteNonQuery();

                        cmd.Parameters.Clear();
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "width");
                        if (tile["width"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["width"]);
                        }
                        cmd.ExecuteNonQuery();

                        cmd.Parameters.Clear();
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "type");
                        if (tile["type"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["type"]);
                        }
                        cmd.ExecuteNonQuery();

                        cmd.Parameters.Clear();
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "title");
                        if (tile["title"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["title"]);
                        }
                        cmd.ExecuteNonQuery();

                        cmd.Parameters.Clear();
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "dataSource");
                        if (tile["dataSource"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["dataSource"]);
                        }
                        cmd.ExecuteNonQuery();

                        cmd.Parameters.Clear();
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "label");
                        if (tile["label"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["label"]);
                        }
                        cmd.ExecuteNonQuery();

                        cmd.Parameters.Clear();
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "columns");
                        if (tile["columns"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["columns"]);
                        }
                        cmd.ExecuteNonQuery();

                        cmd.Parameters.Clear();
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "value");
                        if (tile["value"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["value"]);
                        }
                        cmd.ExecuteNonQuery();

                        cmd.Parameters.Clear();
                        cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                        cmd.Parameters.AddWithValue("@Sequence", sequence);
                        cmd.Parameters.AddWithValue("@Name", "link");
                        if (tile["link"] == null)
                        {
                            cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                        }
                        else
                        {
                            cmd.Parameters.AddWithValue("@Value", tile["link"]);
                        }
                        cmd.ExecuteNonQuery();
                    }
                    sequence++;
                } // next tile
            }

            conn.Close();
        } // end SqlConnection
    }
    catch(Exception ex)
    {
        Console.WriteLine("EXCEPTION: " + ex.Message);
    }
}

// DeleteDashboard : Delete user's saved custom dashboard. If isDefault is true, deletes the default dashboard.

private void DeleteDashboard(bool isDefault)
{
    try
    {
        String username = isDefault ? "default" : CurrentUsername();

        // Check whether an existing dashboard is saved for this user. If so, delete it.

        int dashboardId = -1;

        using (SqlConnection conn = new SqlConnection(System.Configuration.ConfigurationManager.AppSettings["Database"]))
        {
            conn.Open();

            // Load the dashboard.
            // If the user has a saved dashboard, load that. Otherwise laod the default dashboard.

            String query = "SELECT TOP 1 DashboardId FROM DashboardLayout WHERE DashboardName='Home' AND Username=@Username";

            using (SqlCommand cmd = new SqlCommand(query, conn))
            {
                cmd.CommandType = System.Data.CommandType.Text;
                cmd.Parameters.AddWithValue("@Username", username);
                using (SqlDataReader reader = cmd.ExecuteReader())
                {
                    if (reader.Read())
                    {
                        dashboardId = Convert.ToInt32(reader["DashboardId"]);
                    }
                }
            }

            if (dashboardId != -1) // If found a dashboard...
            {
                // Delete dashboard layout tile property records

                query = "DELETE DashboardLayoutTileProperty WHERE DashboardId=@DashboardId";

                using (SqlCommand cmd = new SqlCommand(query, conn))
                {
                    cmd.CommandType = System.Data.CommandType.Text;
                    cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                    cmd.ExecuteNonQuery();
                }

                // Delete dashboard layout tile records

                query = "DELETE DashboardLayoutTile WHERE DashboardId=@DashboardId";

                using (SqlCommand cmd = new SqlCommand(query, conn))
                {
                    cmd.CommandType = System.Data.CommandType.Text;
                    cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                    cmd.ExecuteNonQuery();
                }

                // Delete dashboard layout record

                query = "DELETE DashboardLayout WHERE DashboardId=@DashboardId";

                using (SqlCommand cmd = new SqlCommand(query, conn))
                {
                    cmd.CommandType = System.Data.CommandType.Text;
                    cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                    cmd.ExecuteNonQuery();
                }
            }
            conn.Close();
        } // end SqlConnection
    }
    catch (Exception ex)
    {
        Console.WriteLine("EXCEPTION: " + ex.Message);
    }
}

MVC DashboardController SaveDashboard Action

This takes care of the functionality for Make Default Layout, except for one other matter: we don't want just any user to be able to set the default layout--that should be reserved for administrators.

Reserving Make Default Layout for Administrators

In order to enforce Make Default Layout only being avaiable for administrators, we need to know whether our user is an administrator. In the MVC DashboardController, our pre-existing CurrentUsername function is now accompanied with a CurrentUserIsAdmin function. In the demo project, the return values are hard-coded; in your real application, your authentication/authorization mechanism would be used to determine identity and privileges.

#region Authentication and Authorization

// Use these methods to simulate different users, by changing the username or admin privilege. In a real app, your authN/authZ system handles this.

// Return current username. Since this is just a demo project that lacks authentication, a hard-coded username is returned.

private String CurrentUsername()
{
    //return "Mike.Jones";
    //return "Karen.Carpenter";
    return "John.Smith";
}

// Return true if current user is an administrator. Since this is just a demo project that lacks authentication, a hard-coded role assignment is made.

private bool CurrentUserIsAdmin()
{
    switch (this.CurrentUsername())
    {
        case "John.Smith":
            return true;
        default:
            return false;
    }
}

#endregion
MVC Dashboard Methods for Username and Administrator Status

We need to pass the user information on to the Angular side of things. This is done with a new MVC action named GetUser. 
// /Dashboard/GetUser .... returns username and admin privilege of cucrrent user.

[HttpGet]
public JsonResult GetUser()
{
    User user = new User()
    {
        Username = CurrentUsername(),
        IsAdmin = CurrentUserIsAdmin()
    };
    return Json(user, JsonRequestBehavior.AllowGet);
}
MVC Dashboard GetUser Action

There is a matching getUser function in the DataService that the controller uses to get this information.
// -------------------- getUser : return user information.

self.getUser = function () {

    var url = '/Dashboard/GetUser';

    var request = $http({
        method: "GET",
        url: url,
    });

    return (request.then(getUser_handleSuccess, handleError));
};
DataService.getUser function

The HTML template uses ng-if to conditionally show the Make Default Layout menu option: it only appears if the current user is an administrator.
<div class="hovermenu">
    <i class="fa fa-ellipsis-h dropdown-toggle" data-toggle="dropdown" aria-hidden="true"></i>
    <ul class="dropdown-menu" style="margin-left:-150px !important">
        <li><a id="tile-config-{{tile.id}}" href="#" onclick="configureTile(this.id);"><i class="fa fa-gear" aria-hidden="true"></i>  Configure Tile</a></li>
        <li><a href="#" onclick="configureTile('0');"><i class="fa fa-plus-square-o" aria-hidden="true"></i>  Add Tile</a></li>
        <li><a id=tile-remove-{{tile.id}}" href="#" onclick="removeTileConfirm(this.id);"><i class="fa fa-trash-o" aria-hidden="true"></i>  Remove Tile</a></li>
        <li><a id=tile-reset-{{tile.id}}" href="#" onclick="resetDashboardConfirm();"><i class="fa fa-refresh" aria-hidden="true"></i>  Reset Dashboard</a></li>
        <li ng-if="$ctrl.user.IsAdmin"><a id=tile-reset-{{tile.id}}" href="#" onclick="saveDefaultDashboardConfirm();"><i class="fa fa-check-square-o" aria-hidden="true"></i>  Make Default Layout</a></li>
    </ul>
</div>
Use of ng-if to show menu option for administrator users


Improving the Table Tile

Our Table tile works well enough, but it's kind of crammed. It could do with an expanded layout, with more space padding in the cells. But doing that will make it even harder for the table content to fit in the tile space at times--it really needs a horizontal scroll bar.

In our dashboard.less file, we've updated the styles for table tbody elements to scroll horizontally when necessary; and added greated padding for table cells.
.tile tbody {
    color: black;
    background-color: white;
    height: 100%;
    overflow-y: auto;    /* vertical scroll when needed */
    overflow-x: auto;    /* horizontal scroll when needed */
    font-size: 12px;
}

.tile td, .tile th {
    padding: 8px;
}
Updated table styles

Our improved tile markup is shown below. 
<!-- TABLE tile -->
<div ng-if="tile.type=='table'"
        style="text-align: left !important; padding: 16px; height: 100%">
    <div style="height: 100%; text-align: left !important">
        <table style="padding-bottom: 28px;">
            <tbody style="max-width: {{$ctrl.tileTableWidth(tile.id); }}">
                <tr>
                    <th ng-repeat="col in tile.columns">{{col[0]}}</th>
                </tr>
                <tr ng-repeat="row in tile.value">
                    <td ng-repeat="cell in row track by $index">
                        <div ng-if="tile.columns[$index][1]=='number'" class="td-right">{{cell}}</div>
                        <div ng-if="tile.columns[$index][1]!='number'">{{cell}}</div>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</div>
Updated tile markup

With the above changes, our table tile is much improved: it's more readable, and the content is scrollable.

Improved Table Tile

Summary


Today we did the following to polish and refine our earlier efforts:

  • Added confirmation dialogs for several tile actions.
  • Added a new tile action, Make Default Layout, for administrators.
  • Passed user information from the back end to the front end, including admin role.
  • Improved the Table tile's appearance and made it scrollable.
  • Released updated code with bug fixes.
Download Source
Download Zip
https://drive.google.com/open?id=1913tZaEmxSFj9StyMlKCynePpLw43T0O

Saturday, November 4, 2017

An AngularJS Dashboard, Part 5: Tile Configuration UI and Dashboard Persistence

NOTE: for best results, view the http: version of this page (else you won't get syntax highlighting).

This is Part 5 in a series on creating a dashboard in AngularJS. I'm blogging as I progressively create the dashboard, refining my Angular experience along the way.

Previously in Part 4, we added new chart tiles and completed enough of the back end that dashboard layout and data are queried from a database. However, we haven't yet done any work to allow the user to customize their dashboard and persist those changes for the future.

Today, we're going to:

  • Add a user interface for configuring tiles.
  • Add persistence of dashboard changes for the user.
  • Add tile actions to edit/add/remove tiles, and to reset the dashboard.

Here's what we'll be ending up with:

Dashboard with Configuration Dialog Open

Tile Configuration UI

For adding new tiles or configuring existing ones, the dialog shown below is used. It appears docked at right when the Configure Tile or Add New Tile actions are selected from a tile's menu.

Tile Configuration UI

When I first started implementing the UI, I approached it jQuery-style, where I used explicit code to set the input control values based on properties of the selected tile. Likewise, when the UI was saved similar code would copy input control values back into tile properties. Although this worked, I was dissatisfied with it. For one thing, changes to tiles were only visible when you saved your changes, not as you interacted with the form.

I realized I wasn't doing this the Angular way. I re-implemented using Angular directives, and I now have a form that is automatically linked to the tile being configured. As changes are made in the dialog, they immediately show visually which is much nicer for the user. I also eliminated a lot of code.

Just how is this linkage accomplished?

  • The visibility of the configuration dialog is controlled by the ng-style directive on line 4: when the controller's configuring variable is true, the dialog is visible; and when false, the dialog is hidden. 
  • The ng-model directive is used on many of the input controls (such as lines 7, 11, and 15). It sets a 2-way binding between a tile property and an input control: when one changes, the other is updated. 
  • There are a number of labels and input controls that only apply to certain tile types; ng-if is used to control their visibility (lines 48, 51, 52).
<!-- Configure Tile Panel -->

<div id="configTilePanel" style="position: fixed; width: 400px; right: 0; top: 0; background-color: #FFFFFF; border: 1px solid black"
        ng-style="{'visibility': $ctrl.configuring?'visible':'hidden'}">
    <div style="font-size: 18px !important; margin-bottom: 8px; background-color: navy; color: white"> {{$ctrl.configureTileTitle}}<div style="float: right;" onclick="cancelConfigureTile();">X </div></div>
    <table style="width: 100%">
        <tr><td>Title:  </td><td><input id="configTileTitle" style="width: 100%" ng-model="$ctrl.tiles[$ctrl.configIndex].title"></td></tr>
        <tr>
            <td>Type:  </td>
            <td>
                <select id="configTileType" style="width: 100%" ng-model="$ctrl.tiles[$ctrl.configIndex].type" ng-change="$ctrl.TileTypeChanged($ctrl.configIndex);">
                    <option value="counter">counter</option>
                    <option value="bar">bar chart</option>
                    <option value="column">column chart</option>
                    <option value="donut">donut chart</option>
                    <option value="kpi">kpi</option>
                    <option value="pie">pie chart</option>
                    <option value="table">table</option>
                </select>
            </td>
        </tr>
        <tr>
            <td>Color:  </td>
            <td>
                <input id="configTileColor" type="color" class="configTileColor" style="width: 100%" ng-model="$ctrl.tiles[$ctrl.configIndex].color" />
            </td>
        </tr>
        <tr>
            <td>Width:  </td>
            <td>
                <select id="configTileWidth" ng-model="$ctrl.tiles[$ctrl.configIndex].width">
                    <option value="1">1</option>
                    <option value="2">2</option>
                </select>
            </td>
        </tr>
        <tr>
            <td>Height:  </td>
            <td>
                <select id="configTileHeight" ng-model="$ctrl.tiles[$ctrl.configIndex].height">
                    <option value="1">1</option>
                    <option value="2">2</option>
                </select>
            </td>
        </tr>
        <tr><td>Data Source:  </td><td>
            <select id="configTileDataSource" ng-model="$ctrl.tiles[$ctrl.configIndex].dataSource" ng-change="$ctrl.UpdateTileData($ctrl.configIndex);">
                <option ng-repeat="query in $ctrl.queries" value="{{query.QueryName}}" ng-if="query.ValueType==$ctrl.dataSourceType()">{{query.QueryName}}</option>
            </select>
        </td></tr>
        <tr ng-if="$ctrl.tiles[$ctrl.configIndex].type=='counter' || $ctrl.tiles[$ctrl.configIndex].type=='kpi'"><td>Label:  </td><td><input id="configTileLabel" style="width: 100%" ng-model="$ctrl.tiles[$ctrl.configIndex].label" /></td></tr>
        <tr ng-if="$ctrl.tiles[$ctrl.configIndex].type=='counter' && $ctrl.tiles[$ctrl.configIndex].dataSource=='inline'"><td>Value:  </td><td><input id="configTileValue" style="width: 100%" /></td></tr>
        <tr><td>Link:  </td><td><input id="configTileLink" style="width: 100%" ng-model="$ctrl.tiles[$ctrl.configIndex].link" /></td></tr>
        <tr><td colspan="2" style="text-align: right">
            <button id="cancelButton" onclick="cancelConfigureTile();" class="btn" style="background-color: black; color: white">Cancel</button>  
            <button id="saveButton" onclick="saveConfigureTile(true);" class="btn" style="background-color: green; color: white">Save</button>
        </td></tr>
    </table>
</div>
Markup for Tile Configuration Dialog

Color Selection

For color selection, the dialog makes use of the Spectrum Color Picker, which does a nice job of letting users select from the palette of colors we've chosen to make available. Speaking of which, we've expanded our available tile colors.

Color Picker with an Expanded Palette

Many of the colors in the tile palette are the same as offered by the TFS dashboard, but I've also made a few changes. Here's a sampling of how some of these colors look on tiles.

Dashboard with some new Tile Colors

One other thing to note about tile colors. Previously, when we had a smaller color palette, we used classes to set tile colors--which allowed us to also set the foreground color to white or black, whichever gave the best contrast for the tile color. We've now made that automatic, with the addition of  a new controller function, tileTextColor(id), which uses an algorithm found on the Internet for determining the best contrast color for a background color.
// Return the best text color for a tile. Pass id ('1', '2', ...).

self.tileTextColor = function (id) {
    return self.textColor(self.tiles[parseInt(id) - 1].color);
}

// textColor('#rrggbb') : Given a background color (rrggbb or #rrggbb). Return '#000000' or '#FFFFFF'.
// Courtesty of https://24ways.org/2010/calculating-color-contrast/

self.textColor = function(hexcolor) {
    var color = hexcolor;
    if (color.length === 7) color = color.substr(1);
    var r = parseInt(color.substr(0, 2), 16);
    var g = parseInt(color.substr(2, 2), 16);
    var b = parseInt(color.substr(4, 2), 16);
    var yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
    return (yiq >= 128) ? '#000000' : '#FFFFFF';
}
textColor function in controller
With this controller action, we can now set the tile text color in the HTML template with ng-style, setting the tile's CSS color attribute to $ctrl.tileTextColor(tile.id) on line 3. Once again now that I am starting to think "the Angular way", I am eliminating a lot of discrete code.
<!-- Populated tile (data loaded) -->
<div id="tile-{{tile.id}}" ng-if="tile.haveData"
        class="tile" ng-class="tile.classes" ng-style="{ 'background-color': $ctrl.tileColor(tile.id), 'color': $ctrl.tileTextColor(tile.id) }"
        style="overflow: hidden"
        draggable="true" ondragstart="tile_dragstart(event);"
        ondrop="tile_drop(event);" ondragover="tile_dragover(event);">
    <div class="dropdown" style="height: 100%">
Setting tile background color and text color in HTML template

Tile Actions

We've had an ellipsis (...) menu at the top right of dashboard tiles all along, visible on hover. Now we'll add actions underneath that menu. Here's the markup for the tile action menu:
<div class="dropdown" style="height: 100%">
    <div class="hovermenu">
        <i class="fa fa-ellipsis-h dropdown-toggle" data-toggle="dropdown" aria-hidden="true"></i>
        <ul class="dropdown-menu" style="margin-left:-130px !important">
            <li><a id="tile-config-{{tile.id}}" href="#" onclick="configureTile(this.id);"><i class="fa fa-gear" aria-hidden="true"></i>  Configure Tile</a></li>
            <li><a href="#" onclick="configureTile('0');"><i class="fa fa-plus-square-o" aria-hidden="true"></i>  Add Tile</a></li>
            <li><a id=tile-remove-{{tile.id}}" href="#" onclick="removeTile(this.id);"><i class="fa fa-remove" aria-hidden="true"></i>  Remove Tile</a></li>
            <li><a id=tile-reset-{{tile.id}}" href="#" onclick="resetDashboard();"><i class="fa fa-refresh" aria-hidden="true"></i>  Reset Dashboard</a></li>
        </ul>
    </div>
Tile Action Menu Markup in HTML Template

This gives us the menu you see below when we click on the ellipsis.

Menu Actions

Now let's look at how each action is implemented.

Configure Tile

The Configure Tile action allows the tile's properties to be edited. When this action is selected, the confguration dialog appears, populated with the tile's properties. As you edit the dialog, the tile changes visually in real-time. Clicking Save commits the changes, saving the updated dialog. Clicking Cancel undoes any changes.

The event handler for Configure Tile is shown below, but all of the work is done in the controller's configureTile function. The page code invokes ctrl.configureTile(id) within a scope.$evalSync(...) call: without $evalSync, scope changes made in the controller's configureTile function would not be detected and the page wouldn't automatically update.
// configureTile : Configure (edit) a tile. Displays configure tile dialog. Value is tile index ('1', '2', ..) or '0' (new tile).

function configureTile(id) {
    var scope = angular.element('#dashboard').scope();
    var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;
    scope.$evalAsync(function () {
        return ctrl.configureTile(id);
    });
}
Event hanlder for Configure Tile

Over in the controller, the configureTile function is expecting to be passed a tile Id (1, 2, etc.). If it is passed a zero, that indicates a new tile is being added. In the former case (configuring an existing tile), the 0-based index to the tile is saved in self.configIndex. The self.configuring flag is set to true, indicating tile configuration is in progress--and causing the dialog to be visible.
    // configureTile : Configure (edit) a tile. Displays configure tile dialog. Value is tile index ('1', '2', ..) or '0' (new tile).

    self.configureTile = function (id) {
        if (!id) // if no id specified, cancel configuration tile action
        {
            self.cancelConfigureTile();
        }
        else {
            self.clonedTiles = angular.copy(self.tiles);    // Make a copy of original tile configuration
            self.applied = true;
            if (id == '0') {    // add new tile
                self.configureTileTitle = 'Add New Tile';
                tileIndex = -1;
                var color = '#888888'; 
                $('#configTileColor').val(color);
                $("#configTileColor").spectrum("set", color);
                $('#configTileLink').val('');
                self.configuring = true;
                self.configIndex = self.tiles.length;
                self.tiles.push({
                    id: (self.configIndex+1).toString(),
                    title: 'title',
                    type: 'counter',
                    color: color,
                    dataSource: 'inline',
                    height: '1',
                    width: '1',
                    link: null,
                    classes: 'tile_1_1',
                    columns: null,
                    label: 'label',
                    value: 1,
                    haveData: true
                });
                self.computeLayout();
            }
            else {  // edit existing tile
                var index = parseInt(id.substring(12)) - 1;      // tile-config-1 => 0, tile-config-2 => 1, etc.
                self.configureTileTitle = 'Configure Tile ' + (index+1).toString();
                tileIndex = index;
                self.configIndex = index;
                self.configTile = self.tiles[index];
                if (self.configTile) {
                    self.configuring = true;
                    self.configTileSave = angular.copy(self.configTile);
                    var color = self.configTile.color; 
                    $('#configTileColor').val(color);
                    $("#configTileColor").spectrum("set", color);
                    $('#configTileLink').val(self.configTile.link);
                    var value = '';
                    if (self.configTile.type === 'counter' && self.configTile.dataSource === 'inline') {
                        value = self.configTile.value;
                        $('#configTileValue').val(value);
                    }
                }
            }
        }
        return false;
    };

configureTile function in Controller

In the above, there's some code used to set a color picker control but otherwise you don't see type, title, width, height, etc. being set into the configuration UI. As explained earlier, the reason for this is that the tile properties are bound to the UI automatically by angular directives in the HTML template.

Cancel

If the user clicks Cancel, we need to abandon changes--but because of the 2-way binding, any changes made on the dialog have already been applied to the tile--so how do we handle a Cancel? If you look back above at the conrtroller's configureTile function, the answer is on line 9. When we begin the configuration activity, we make a copy of the entire tile array named clonedTile. This is done using angular.copy, which makes a deep (without references) copy of the tiles array. Because this has been done, the code to cancel (shown below) merely has to copy back the saved copy of the tiles array. It also calls self.computeLayout (to calculate the arrangement of tiles to fit the current window width) and calls deferCreateCharts to have the chart library render any chart tiles.
    // cancelConfigTile : cancel a configure tile action (dismiss tile, clear configuring flags).

    self.cancelConfigureTile = function () {
        if (self.configuring) {
            self.configuring = false;
            if (self.applied) {  // roll back changes from Apply button
                self.tiles = angular.copy(self.clonedTiles);
                self.computeLayout();
                deferCreateCharts();
                self.clonedTiles = null;
                self.applied = false;
            }
        }
    };
cancelConfigureTile function in controller

Save

When Save is clicked, our tiles array is already up to date thanks to the 2-way binding that has been operative all during the editing process. We do, however, need to save the updated layout. That is done with a new call added to the DataService named saveDashboard (line 14).
// saveConfigureTile : Save tile configuration. An Apply can be reverted with a cancel.
// if tileIndex is -1, a new tile is being added.

self.saveConfigureTile = function () {
    var index = tileIndex;

    if (tileIndex == -1) { // new tile
        tileIndex = self.configIndex;
        index = tileIndex;
    }

    self.configTile = self.tiles[index];

    DataService.saveDashboard(self.tiles);

    self.applied = false;
    tileIndex = -1;

    self.updateTileProperties();
    self.computeLayout();
    deferCreateCharts();
    toastr.options = {
        "positionClass": "toast-top-center",
        "timeOut": "1000",
    }
    toastr.info('Dashboard Changes Saved')

    self.configuring = false;
};
saveConfigureTile function in controller

In the SQL Server implementation of the Data Service, saveDashboard makes an Ajax call (line 38) to a SaveDashboard action in the MVC controller. The MVC controller action writes out the dashboard layout and tile properties to the database for the current user. The demo edition of the Data Service also has a saveDashboard function, but it doesn't do anything.
// -------------------- saveDashboard : updates the master layout for tiles (returns tiles array) ------------------

this.saveDashboard = function (newTiles) { 

    var Dashboard = {
        DashboardName: null,
        Username: null,
        Tiles: [],
        Queries: null
    };

    var tile = null;
    var Tile = null;

    // create tile object with properties

    for (var t = 0; t < newTiles.length; t++) {
        tile = newTiles[t];
        Tile = {
            Sequence: t+1,
            Properties: [
                { PropertyName: 'color', PropertyValue: tile.color },
                { PropertyName: 'width', PropertyValue: parseInt(tile.width) },
                { PropertyName: 'height', PropertyValue: parseInt(tile.height) },
                { PropertyName: 'title', PropertyValue: tile.title },
                { PropertyName: 'type', PropertyValue: tile.type },
                { PropertyName: 'dataSource', PropertyValue: tile.dataSource },
                { PropertyName: 'columns', PropertyValue: JSON.stringify(tile.columns) },
                { PropertyName: 'value', PropertyValue: JSON.stringify(tile.value) },
                { PropertyName: 'label', PropertyValue: tile.label },
                { PropertyName: 'link', PropertyValue: tile.link },
                { PropertyName: 'format', PropertyValue: tile.format }
            ]
        };
        Dashboard.Tiles.push(Tile);
    };

    var request = $http({
        method: "POST",
        url: "/Dashboard/SaveDashboard",
        data: JSON.stringify(Dashboard),
        headers : {
            'Content-Type': 'application/json'
        }

    });

    return (request.then(handleSuccess, handleError));
};
saveDashboard function in SQL Server Data Service

Add New Tile

The Add New Tile action adds a new tile to the layout and brings up the configuration dialog. Clicking Save commits the new tile to the layout and saves it. Clicking Cancel abandons the new tile.

Add New Tile uses the same code describes above under Configure Tile. By passing an id of zero, the code in the controller configureTile function knows to add a new tile to the layout.

By the way, Add New Tile and Configure Tile have to deal with the fact that as the user is defining / modifying a tile, some of the tile properties may be incomplete / invalid. In particular, consider what happens when a tile type is changed in the configuration dialog: more likely than not, a valid data source is no longer set that makes sense for that tile type. To combat this, the controller code that responds to changes in type and data source calls a function named tileHasValidTypeAndDataSource(index) before trying to re-fetch data and render the tile. I'm quite sure more needs to be done along these lines--expect an update after more testing and debugging. There, you've been warned!

Remove Tile

The Remove Tile action removes the current tile and saves the updated layout. Your dashboard must have at least one tile, so this action won't do anything if there's only one tile left. 


In the controller, the removeTile function removes the tile from the tiles array using JavaScript's array.splice function. DataService.saveDashboard is called to save the updated dashboard layout. The usual call to computeLayout is then made to recompute the dashboard layout.
// removeTile : Remove a tile.

self.removeTile = function(id) {
    if (!id) return;

    if (self.tiles.length < 2) return; // can't delete all tiles, because we lose the tile menu and have no way to add new tiles

    var index = parseInt(id.substring(12)) - 1;      // tile-remove-1 => 0, tile-remove-2 => 1, etc.

    self.tiles.splice(index, 1);
    self.computeLayout();

    DataService.saveDashboard(self.tiles);

    toastr.options = {
        "positionClass": "toast-top-center",
        "timeOut": "1000",
    }

    toastr.info('Dashboard Tile Deleted');
    return false;
}
removeTile function in controller

There really should be a confirmation dialog for destructive actions such as Remove Tile. One more item to add to our To Do list.

Reset Dashboard

One last action we'll implement is the ability to reset the dashboard. This will remove the user's custom dashboard and return them back to the default layout.

Recall that our database scheme is to have a default dashboard in the database, but also the ability for saving a custom dashboard for a user: all of the cases above where we've added / configured / removed a tile causes a custom dashboard layout to be saved for the user.

When our code loads a dashboard for a user, it tries to find a custom one for the current user's username. If it can't find that, it uses the default dashboard. All it takes for a dashboard reset, then, is to delete the user's custom saved dashboard. In the Angular controller, the resetDashboard function invokes the Data Service resetDashboard function; and then re-loads the user's dashboard.
// resetDashboard : reset user's dashboard

self.resetDashboard = function () {
    Promise.resolve(DataService.resetDashboard()).then(
        function (response) {
            self.LoadDashboard();
        });
}
resetDashboard function in controller

The Data Service's resetDashboard function in turn calls an MVC action that removes the custom dashboard from the database.
// resetDashboard : reset user's dashboard

this.resetDashboard = function () {
    var request = $http({
        method: "GET",
        url: "/Dashboard/DashboardReset/",
    });
}
resetDashboard function in SQL DataService

Drag and Drop Persistence

In addition to the tile actions we've covered that persist a user's custom dashboard layout to the database, there's one other way we have to modify the layout: through drag-and-drop. Several posts back we developed the capability to re-arrange tiles through dragging. We've updated the controller's dragTile method to persist the dashboard changes after a tile has been moved. This is done by calling DataService.saveDashboard, just as with the other tile actions described earlier.

New MVC Controller Actions

We've mentioned a few new MVC actions but haven't shared the back-end C# code yet, so let's review it now.

SaveDashboard

SaveDashboard saves a dashboard layout for the current user in the SQL Server database. It first deletes any existing dashboard layout set of records for the username, then adds a new set.
        // /Dashboard/SaveDashboard (POST) tiles .... save updated dashboard for user.

        [HttpPost]
        public void SaveDashboard(Dashboard dashboard)
        {
            try
            {
                String username = CurrentUsername();

                DeleteDashboard();  // Delete prior saved dashboard (if any) for user.

                // Check whether an existing dashboard is saved for this user. If so, delete it.

                int dashboardId = -1;

                using (SqlConnection conn = new SqlConnection(System.Configuration.ConfigurationManager.AppSettings["Database"]))
                {
                    conn.Open();

                    // Add dashboard layout root record

                    String query = "INSERT INTO DashboardLayout (DashboardName, Username, Priority) VALUES (@DashboardName, @Username, 2); SELECT SCOPE_IDENTITY();";

                    using (SqlCommand cmd = new SqlCommand(query, conn))
                    {
                        cmd.Parameters.AddWithValue("@DashboardName", "Home");
                        cmd.Parameters.AddWithValue("@Username", username);
                        using (SqlDataReader reader = cmd.ExecuteReader())
                        {
                            if (reader.Read())
                            {
                                dashboardId = Convert.ToInt32(reader[0]);
                            }
                        }
                    }

                    if (dashboardId!=-1) // If root record added and we have an id, proceed to add child records
                    {
                        // Add DashboardLayoutTile records.

                        int sequence = 1;
                        foreach (Tile tile in dashboard.Tiles)
                        {
                            query = "INSERT INTO DashboardLayoutTile (DashboardId, Sequence) VALUES (@DashboardId, @Sequence)";

                            using (SqlCommand cmd = new SqlCommand(query, conn))
                            {
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.ExecuteNonQuery();
                            }
                            sequence++;
                        } // next tile

                        // Add DashboardLayoutTileProperty records.

                        sequence = 1;
                        foreach (Tile tile in dashboard.Tiles)
                        {
                            query = "INSERT INTO DashboardLayoutTileProperty (DashboardId, Sequence, PropertyName, PropertyValue) VALUES (@DashboardId, @Sequence, @Name, @Value)";

                            using (SqlCommand cmd = new SqlCommand(query, conn))
                            {
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "color");
                                if (tile["color"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["color"]);
                                }
                                cmd.ExecuteNonQuery();

                                cmd.Parameters.Clear();
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "height");
                                if (tile["height"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["height"]);
                                }
                                cmd.ExecuteNonQuery();

                                cmd.Parameters.Clear();
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "width");
                                if (tile["width"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["width"]);
                                }
                                cmd.ExecuteNonQuery();

                                cmd.Parameters.Clear();
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "type");
                                if (tile["type"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["type"]);
                                }
                                cmd.ExecuteNonQuery();

                                cmd.Parameters.Clear();
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "title");
                                if (tile["title"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["title"]);
                                }
                                cmd.ExecuteNonQuery();

                                cmd.Parameters.Clear();
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "dataSource");
                                if (tile["dataSource"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["dataSource"]);
                                }
                                cmd.ExecuteNonQuery();

                                cmd.Parameters.Clear();
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "label");
                                if (tile["label"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["label"]);
                                }
                                cmd.ExecuteNonQuery();

                                cmd.Parameters.Clear();
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "columns");
                                if (tile["columns"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["columns"]);
                                }
                                cmd.ExecuteNonQuery();

                                cmd.Parameters.Clear();
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "value");
                                if (tile["value"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["value"]);
                                }
                                cmd.ExecuteNonQuery();

                                cmd.Parameters.Clear();
                                cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                                cmd.Parameters.AddWithValue("@Sequence", sequence);
                                cmd.Parameters.AddWithValue("@Name", "link");
                                if (tile["link"] == null)
                                {
                                    cmd.Parameters.AddWithValue("@Value", DBNull.Value);
                                }
                                else
                                {
                                    cmd.Parameters.AddWithValue("@Value", tile["link"]);
                                }
                                cmd.ExecuteNonQuery();
                            }
                            sequence++;
                        } // next tile
                    }

                    conn.Close();
                } // end SqlConnection
            }
            catch(Exception ex)
            {
                Console.WriteLine("EXCEPTION: " + ex.Message);
            }
        }

        // DeleteDashboard : Delete user's saved custom dashboard.

        private void DeleteDashboard()
        {
            try
            {
                String username = CurrentUsername();

                // Check whether an existing dashboard is saved for this user. If so, delete it.

                int dashboardId = -1;

                using (SqlConnection conn = new SqlConnection(System.Configuration.ConfigurationManager.AppSettings["Database"]))
                {
                    conn.Open();

                    // Load the dashboard.
                    // If the user has a saved dashboard, load that. Otherwise laod the default dashboard.

                    String query = "SELECT TOP 1 DashboardId FROM DashboardLayout WHERE DashboardName='Home' AND Username=@Username";

                    using (SqlCommand cmd = new SqlCommand(query, conn))
                    {
                        cmd.CommandType = System.Data.CommandType.Text;
                        cmd.Parameters.AddWithValue("@Username", CurrentUsername());
                        using (SqlDataReader reader = cmd.ExecuteReader())
                        {
                            if (reader.Read())
                            {
                                dashboardId = Convert.ToInt32(reader["DashboardId"]);
                            }
                        }
                    }

                    if (dashboardId != -1) // If found a dashboard...
                    {
                        // Delete dashboard kayout tile property records

                        query = "DELETE DashboardLayoutTileProperty WHERE DashboardId=@DashboardId";

                        using (SqlCommand cmd = new SqlCommand(query, conn))
                        {
                            cmd.CommandType = System.Data.CommandType.Text;
                            cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                            cmd.ExecuteNonQuery();
                        }

                        // Delete dashboard layout tile records

                        query = "DELETE DashboardLayoutTileProperty WHERE DashboardId=@DashboardId";

                        using (SqlCommand cmd = new SqlCommand(query, conn))
                        {
                            cmd.CommandType = System.Data.CommandType.Text;
                            cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                            cmd.ExecuteNonQuery();
                        }

                        // Delete dashboard layout record

                        query = "DELETE DashboardLayout WHERE DashboardId=@DashboardId";

                        using (SqlCommand cmd = new SqlCommand(query, conn))
                        {
                            cmd.CommandType = System.Data.CommandType.Text;
                            cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                            cmd.ExecuteNonQuery();
                        }
                    }
                    conn.Close();
                } // end SqlConnection
            }
            catch (Exception ex)
            {
                Console.WriteLine("EXCEPTION: " + ex.Message);
            }
        }

SaveDashboard Action in MVC Controller

DashboardReset

The DashboardReset action action deletes the user's saved custom dashboard (if any).

        // DashboardReset ... reset user to default dashboard (by deleting their saved custom dashboard).

        [HttpGet]
        public JsonResult DashboardReset()
        {
            try
            {
                DeleteDashboard();
                return Json(true, JsonRequestBehavior.AllowGet);
            }
            catch (Exception ex)
            {
                Console.WriteLine("EXCEPTION: " + ex.Message);
                return Json(false, JsonRequestBehavior.AllowGet);
            }
        }
DashboardReset Action in MVC Controller

Summary

Today we achieved the following:

  • Added a tile configuration UI
  • Expanded the tile color palette
  • Used Angular directives for tile editing with real-time visual changes
  • Added dashboard layout persistence with new Data Service functions and MVC Controller actionss
  • Added tile menu actions to Configure Tile, Add New Tile, Remove Tile, and Reset Dashboard

After 5 posts, we now have a dashboard that works! However, we can't quite claim completion yet. For one thing, all of the above is brand new and it needs testing and debugging. After some of that, we'll look to polish what we have and perhaps add some more functionality.

Download Source 
Download Zip
https://drive.google.com/file/d/0B4734rvcufGGRC1ZenItNUNwNTA/view?usp=sharing

Next: Part 6 : Admin Tile Actions and Confirmation Dialog