Monday, November 27, 2017

An AngularJS Dashboard, Part 8: Mobile Improvements and Role Support

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

This is Part 8 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. An online demo of the latest work is available here.

Previously in Part 7, we added a smart tile fill algorithm, improving how our dashboard renders regardless of the layout's mix of tile sizes or the width of the browser window. Today in Part 8, we're going to focus on two areas: improving the mobile experience abd adding role support. We'll be improving the mobile experience by setting a better initial viewport page width on devices and implementing a friendler way to re-arrange the dashboard. By role support, we mean restricting who can see certain dashboard information and adding the ability to customize dashboards for different roles/departments.

Today we will:
• Add a meta tag for viewport sizing on mobile devices
• Detect a too-small mobile width and adjust viewport size
• Add an alternative to drag-and-drop for reordering tiles on mobile devices
• Relocate some actions in the tile action menu into a second dashboard menu
• Add a new tile action, Copy Tile
• Allow different users to sign in to our demo project
• Track roles for users
• Support dashboards layouts for roles
• Allow tiles to be restricted to a role
• Add personalized tiles through the use of data queries that filter data for the current user

Here's a glimpse of what we'll end up with today:

Today's objective

A Better Mobile Experience

While we haven't exactly ignored mobile devices up till now, we haven't really focused on making mobile a fantastic experience. If you look back at earlier posts in the series, you'll see that the mobile views werewelltoo big. Although the tile layout would re-render for available space, the size of the dashboard layout was simply too large, which meant the tile content and menus were too small to be of practical use to a mobile user.

MOBILE VIEWPORT

By default, mobile device browsers fully expect to be handed pages that are too large for their screen size; as a practical matter, they therefore support zooming and scrolling. Because our markup hasn't addressed this behavior, our dashboard has been rendering too packed on small screens, resulting in text that is too small to read and controls that are too small to interact with. Although the user could certainly scale the page, they shouldn't have to. What we want is a great out-of-the-box display on phones and tables that is easy to interact with.

Fortunately, there is an element we can add to our markup that addresses this: a meta tag that sets viewport size. The typical tag looks like this, which is also what we'll use:

<meta id="viewport" name="viewport" content="width=device-width, initial-scale=1"> 

This immediately makes things better, but doesn't solve all of our problems. The smallest possible rendering of our dashboard is one tile across, but since tiles can be 1- or 2-units wide/tall, we can't really render dashboards well unless we're sure we have enough room for 2 tile units across-or 16 + 200 + 16 + 200 + 16. On something like a portrait iPhone we might be only 375 pixels wide. We're going need to add logic, then, to check whether our screen width is under 500 pixels. If it is, we're going to use jQuery to replace our meta viewport tag with a different one, telling the device to scale to 500 pixels across. This will give us room to render our dashboard.
var scope1, ctrl1;
$(document).ready(function () {
    if (window.innerWidth < 500) {  // If device is so small we can't fit a wide tile, scale meta viewport tag to minimum width of 500px
        var mvp = document.getElementById('viewport');
        mvp.setAttribute('content', 'width=500');
    }
    if (typeof google != 'undefined') {
        google.charts.load("current", { packages: ["corechart", 'table'] });
    }
});

With this in place, we can now try our dashboard out on a variety of mobile devices. When we do, as the results below show, we are seeing markedly improved displays that are large enough to read and use. The desktop, which previously looked fine, remains unchanged.

Dashboard on Android Phone (Portrait)

Dashboard on iPad (Landscape)

Dashboard on Desktop

Tile Menu and Dashboard Menu

Up until now, our tiles have had a tile menu. An ellipsis appears at the top right of a tile, when hovered over or clicked on, revealing a menu of tile and dashboard actions. Some of those actions really affect the entire dashboard, not just the current tile: Add Tile, Reset Dashboard, Make Default Layout. To factor things better in the UI, we now have a separate dashboard menu for dashboard actions, represented by a gear icon at top right.

Dashboard Menu

The tile menu remains, with a smaller number of options. We've also added a new tile action, Copy Tile, which adds a copy of the current tile to the layout.

Tile Menu

Rearranging Tiles

A second mobile concern is the way we've provided users to rearrange tile layout: drag-and-drop. This works great from the desktop, but is problematic on Android and iOS mobile devices. Although we've previously added the Touch-Punch JavaScript library to get touch events working, the experience is still problematic--especially on iOS devices. We also experimented with adding some polyfills for HTML5 drag and drop, but none of this solved the issue. What we've decided to do, then, is replace drag-and-drop on mobile devices with a reordering dialog.

The Rearrange Tiles action now displays the following dialog on devices 1024 wide or smaller:

Mobile Reorder Tiles Dialog

The user can quickly move tiles up or down by touching the arrow keys. As the tile order is changed, the dashboard layout updates live. Clicking Save commits changes, and Cancel undoes them.

On the desktop, Rearrange Tiles continues to provide the same drag-and-drop experience we've had previously. Now every user can customize their dashboard layout easily, regardless of the device they're using.

Role Support

Dashboards are all about providing meaningful information at-a-glance--but that information isn't necesarily meant for all eyes. To make ng-dashboard smarter about what it shows, we need awareness of a user's roles.

User Logins

In order to make it easy to test out how roles are working, our simple test project has been upgraded. The current user is displayed at top right, and by clicking on the name you can select a different login from the drop-down menu. You can choose between John Smith (an Admin), Marcia Brady (a Manager), and Stuart Downey (in Sales). The code will set a cookie and remember who you are; and if you're signing on for the first time, it will log you in as john.smith. There are no passwords to worry about.

Changing login

Representing Roles

Since our dashboard as we release it is just in a simple test project without real security, how do will we handle role management? We're going to assume that whatever authentication/authorization system you have, each user can be said to be in one or more roles/departments--and that those roles can be represented by an array of string names, such as "Employee", "Manager", "Executive", "Sales", "Accounting", etc. In addition, we are attaching special significance to the role "Admin": this role gets access to advanced dashboard actions such as Make Default Layout. This scheme should hopefully be easily adaptible to your authN/authZ system.

In our test project's MVC controller, a few user names are hard-coded (john.smith, an Admin; marcia.brady, a manager; and Stuart Downey, a salesman). This would more properly be driven by our database, but that work is still on the backlog.

Role Defaults for Dashboards 

Up until now, we have had two places to look for the user's dashboard when we load it. First, if the user has saved a custom edition of their dashboard this will be in the DashboardLayout table under the user's username. If that isn't found, the code will look for the default dashboard for all users, stores in the same table under username 'default'. This was done with a query order by Priority that selected the top 1 match. This ensured a customized layout would be selected if one existed for the user, otherwise the default layout would be used.

All of that is working well, but it would be valuable to also store default layouts for various roles.
Imagine for example that you've set up the perfect dashboard for salespeople in your organization--how do you get it to all your salespeople, and to new salespeople who join the company in the future? Using the 'default' layout ins't a good idea, because not everyone in your organization is a salesperson; and a customized layout for one user is only available to that one user. What we need, then, is another level where a dashboard layout can be saved for a role. With today's update, the code now searches for a dashboard to show the user in a 3-level search:

1. User Saved Custom Layout: Use the saved custom dashboard with username=.
2. Default Role Layout: Otherwise, use the saved custom dashboard with username=.
3. Default Layout: Otherwise, use the default dashboard for all users (username='default').

Step 2 needs some explanation, because a user can be in multiple roles; if a user was in both the Manger and Sales roles, and dashboards were defined for each role, which one should be used? The way we handle that is to use the Priority field already built in to the DashboardLayout table. The default layout is priority 1, and saved user custom layouts are priority 10. That leaves priorities 2-8 for role-based layouts. If ng-dashboard found a Sales dashboard with priority 3 and a Manager dashboard with priority 5, it would choose the Sales dashboard. This is the query used to select a matching dashboard based on priority:

SELECT TOP 1 DashboardId FROM DashboardLayout 
WHERE DashboardName='Home' AND
Username=username OR Username IN (role-list) OR Username='default')
ORDER BY [Priority] DESC

Although the addition of role support is important and useful, note that we do not yet have UI support for it--another item for our backlog. That means, right now, you'd need to do some databasee work in order to define a role-based layout in the database in order to use it. Probably the easiest way to do that right now is to first save a custom layout, then update the DashboardLayout record's priority and username (to be a role name).

With all of this in place, we now have a default dashboard layout for everybody, the ability to specify default layouts for different roles, all while preserving a user's right to customize their dashboard layout to their liking.

Restricting Tiles to a Role

It would be useful to be able to restrict some dashboard information to certain roles. For example, you might want to limit compensation information to Accounting, or perhaps limit order information to Sales, or restrict employee information to their manager. Although you could take the approach of designing different dashboard for different departments or roles, that doesn't work well in practice; it's more useful to be able to restrict things at the tile level, so that good dashboard layouts can be freely across the organization without fear of someone seeing something they shouldn't.

Our approach will be to two-fold. First , we've added a role column to the database DashboardQuery table. This allows us to indicate that a query requires a role, such as Manager. When the MVC controller loads queries to pass on to the Angular controller, it will not include any queries the user isn't authorized for.

Secondly, we've added a role property to tiles.  Here's the tile configuration dialog with the new Required Role input. If a role is specified, the tile will only be available to users in that role; if a user without the required role accesses the dashboard, the tile will not render.

Required Role in Tile Configuration Dialog

To make this work in Angular, we've made the following changes to our code:

• In the MVC controller, LoadDashboard now includes a list of all system roles in the dashboard object it returns. In our test project, these names are hard-coded; in a real application, they should be supplied by the authorization system. It also returns a filtered list of queries; any queries that require a role the current user doesn't have are not included.
Dashboard dashboard = new Dashboard()
{
    DashboardName = "Home",
    Username = username,
    IsAdmin = CurrentUserIsAdmin(Request),
    Tiles = new List<tile>(),
    Queries = new List<dashboardquery>(),
    Roles = new List<string>(),
    IsDefault = false
};

dashboard.Roles.Add("Accounting");
dashboard.Roles.Add("Admin");
dashboard.Roles.Add("Employee");
dashboard.Roles.Add("Executive");
dashboard.Roles.Add("Manager");
dashboard.Roles.Add("Marketing");
dashboard.Roles.Add("Manufacturing");
dashboard.Roles.Add("Sales");

...

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

    // Load queries.

    DashboardQuery dashboardQuery = null;

    String query = "SELECT * FROM DashboardQuery ORDER BY Name";

    dashboard.Queries.Add(new DashboardQuery()
        {
            QueryName = "inline",
            ValueType = "number",
            Role = ""
        });

    bool addQuery = false;
    String[] roles = CurrentUserRoles(Request);

    using (SqlCommand cmd = new SqlCommand(query, conn))
    {
        using(SqlDataReader reader = cmd.ExecuteReader())
        {
            while(reader.Read())
            {
                dashboardQuery = new DashboardQuery()
                {
                        QueryName = Convert.ToString(reader["Name"]),
                        ValueType = Convert.ToString(reader["ValueType"]),
                        Role = Convert.ToString(reader["Role"])
                };

                addQuery = true;
                if (!String.IsNullOrEmpty(dashboardQuery.Role)) // don't add query if it requires a role the user doesn't have
                {
                    if (roles != null)
                    {
                        addQuery = false;
                        foreach(String role in roles)
                        {
                            if (role==dashboardQuery.Role)
                            {
                                addQuery = true;
                                break;
                            }
                        }
                    }
                }

                if (addQuery)
                {
                    dashboard.Queries.Add(dashboardQuery);
                }
            }
        }
    }

MVC Controller C# LoadDashboard code to return  master role list

• The MVC controller GetUser function returns the user's roles
// /Dashboard/GetUser .... returns username and admin privilege of cucrrent user.

[HttpGet]
public JsonResult GetUser()
{
// If ?user=<username> specified, set username and create cookie
String username = null; // = Request.QueryString["user"];
if (String.IsNullOrEmpty(username)) // if nothing specified in URL, ...
{
    // ...Check for existing username cookie
    if (Request != null && Request.Cookies.AllKeys.Contains("dashboard-username"))
    {
        username = Request.Cookies["dashboard-username"].Value;
    }
    else
    {
        username = "john.smith";    // else default john.smith
        HttpCookie cookie = new HttpCookie("dashboard-username");   // Set cookie
        cookie.Value = username;
        Response.Cookies.Add(cookie);
    }
}
else
{
    HttpCookie cookie = new HttpCookie("dashboard-username");   // Set cookie
    cookie.Value = username;
    Response.Cookies.Add(cookie);
}

User user = new User()
{
    Username = CurrentUsername(Request),
    Roles = CurrentUserRoles(Request),
    IsAdmin = CurrentUserIsAdmin(Request)
};
return Json(user, JsonRequestBehavior.AllowGet);
}

MVC Controller C# GetUser code to return user roles

• The Data Service passes the roles to the controller, which holds the list in a roles variable.
// Load tile definitions and perform remaining initilization.

self.LoadDashboard = function () {
    if (!DataService.requiresPromise) {
        self.tiles = DataService.getTileLayout();
        self.queries = DataService.queries;
        self.roles = DataService.roles;
Angular controller storing queries in LoadDashboard

• The Angular controller's ComputeLayout function now sets a hidden flag on each tile. A tile is marked hidden if it requires a role the user does not have.
self.computeLayout = function () {
    if (self.tiles == null) return;

    var matrix = [];

    for (var r = 0; r < self.tilesdown; r++) {
        matrix.push([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // support up to 20 tile units across 
    }

    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);
    }

    // set tile.hidden for each tile based on role assignments.

    var tile = null;
    for (var t = 0; t < self.tiles.length; t++) {
        tile = self.tiles[t];
        tile.hidden = false;
        if (tile.role) {
            if (!self.userInRole(tile.role)) {
                tile.hidden = true;
            }
        }
    }
Angular ComputeLayout function code to mark unauthorized tiles hidden 

• Lastly, the HTML template markup has an ng-if condition (line 2) which will skip over rendering a tile is tile.hidden is true.
                <!-- Populated tile (data loaded) -->
                <div id="tile-{{tile.id}}" ng-if="tile.haveData && !tile.hidden"
                     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), 'border': ($ctrl.configIndex==$index)?'dotted':'none', 'border-color': ($ctrl.configIndex==$index)?'white':'transparent' }"
                     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%">
                        <!-- tile menu -->
                        <div class="hovermenu">
                            <a href="javascript:void(0)" class="dropdown-toggle" style="text-decoration: none; color: inherit" data-toggle="dropdown">
                                <i class="fa fa-ellipsis-h" aria-hidden="true"></i>
                            </a>
                            <ul class="dropdown-menu" style="margin-top: -10px; margin-left:-144px !important; font-size: 16px !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 id="tile-config-{{tile.id}}" href="#" onclick="copyTile(this.id);"><i class="fa fa-clone" aria-hidden="true"></i>  Copy 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>
                            </ul>
                        </div>
                        <a id="config-{{tile.id}}" ng-href="{{$ctrl.rearranging ? null : tile.link}}" style="color: inherit; text-decoration: inherit;">
                            <div style="overflow: hidden; white-space: nowrap"> {{tile.title}}</div>
                            <div style="position: relative; height: 100%">
                                <!-- COUNTER tile -->
                                <div ng-if="tile.type=='counter' && tile.height==1"
                                     style="text-align: center; position: absolute; left: 0; right: 0; margin: 0 auto; top: 25px">
                                    <div style="font-size: 72px">{{tile.value}}</div>
                                    <div style="font-size: 16px">{{tile.label}}</div>
                                </div>
HTML Markup code to Conditionally Render Tiles

Personalized Tiles

Much of the information you might track in a business dashboard--such as sales, orders, customers, inventory levels, etc.--is more useful to a user if it can be filtered to be personal. For example, imagine a tile that shows the count of orders, or a table tile listing open orders. If you're a salesperson, you would probably be more interested in those tiles if they listed your orders.

To support personalization, we'll make it possible for our data queries (stored in the DashboardQuery database table) to reference the current user's username. The symbol @username in a query will be automatically replaced by the current user name when the MVC controller executes a data query.

For example, we've previously shown an Orders counter tile that uses the Order Count data query. If instead we wanted a My Orders counter tile, we can ceate an Order Count (My Orders) query backed by this query:

SELECT COUNT(ord.OrderId) FROM [Order] ord INNER JOIN Employee emp ON emp.EmployeeId=ord.SalesPersonEmployeeId AND emp.Username=@username

We can then use this personalized query to define a My Orders tile, limited to the Sales role.

Personalized Tile My Orders

The resulting tile will show different results based on the current user:

My Orders tile for stuart.downey (a salesman)


My Orders tile for john.smith (an admin, no order)

(no tile displayed)
My Orders tile for marcia.brady (not in Sales role, tile not visible)

Roles in Action

Now it's time to see this role support in action. Imagine you are HRAdmin John Smith, and you have just created the following default dashboard for employees:
  1. A Customers counter tile, showing the count of customers. Available to all.
  2. An Orders counter tile, showing the count of orders. Available to all.
  3. A My Orders counter tile, showing the current user's order count. Restricted to Sales.
  4. A Customer Satisfaction KPI tile, showing average customer rating. Available to all.
  5. A My Open Orders table tile, listing the current user's open orders. Restricted to Sales.
  6. A Revenue Share by Store pie chart tile, showng revenue by store. Restricted to Sales.
  7. An Orders tile, listing all orders. Available to all.
  8. A Revenue by Store bar chart tile, showing revenue amounts by store. Restricted to Sales.
  9. A My Direct Reports table tile, showing current user's direct reports. Restricted to Manager.
Let's see how this dashboard is shown to different users, starting with our admin, John Smith. Because he is an admin, John gets to see all tiles. Because John is neither a manager nor a salesperson, the personalized tiles My Orders, My Open Orders, and My Direct Reports are empty as there is no data for John.

Default dashboard as viewed by Admin john.smith

Now, instead imagine the dashboard is being viewed by Stuart Downey, a sales executive who has the Sales role. His dashboard is shown below. Note he is seeing different data for his personalized tiles than John: he has direct reports, and he has orders.

Default dashboard as viewed by Sales Executive Stuart Downey

Finally, let's view the dashboard as Office Manager Marcia Brady. Marcia is not in the Sales role, so tiles like My Orders, My Direct Orders, Revenue Share by Store, and Revenue by Store don't appear. But she is a manager, so she does see her direct reports listed in the My Direct Reports tile.

Default dashboard as viewed by Office Manager Marcia Brady

Our role-based features are working well: users see only what they are authorized to see, and much of the information is personalized for their scope of interest.

To see this for yourself, download the sample project and database and follow the intstructions to use the SQL Server Data Service.

Summary

Today in Part 8 we achieved the following:

  • Improved the mobile experience
    • Made use of a meta viewport tag to set ideal page width on mobile devices
    • Changed the Rearrange Tiles implementation for mobile devices to use a reordering dialog
    • Adding a dashboard menu, and relocated some actions from the tile menu
    • Added a Copy Tile action to the tile menu
  • Added role support
    • Added the concept of multiple user logins and roles to the test project
    • Added support for default layouts for roles
    • Added ability to restrict a tile to a role
    • Added personalized tiles through the use of @username in data queries
In this update we've also made some progress on unit testing--but we're going to explore all that in Part 9.

AngularJS: How is it Holding Up?

And how are we feeling about AngularJS at this point? There's definitely good and bad:

  • Angular has some powerful directives (ng-if, ng-style, ng-repeat, etc.) that have been fun to leverage in our markup; however, some of these directives have quirks so occasionally this has been frustrating to get right.
  • Angular more or less forces you to modularize everything you do--you end up with separate controllers, services, templates. That's good. On the other hand, we are loading many  more discrete files now. It remains to be seen if we can take advantage of something like ASP.NET bundles to make loading of dependency JS files more efficient.
  • Angular is designed to facilitate user testing. In theory, our controller and our services, should all be easily testable. In practice, this is one of the things that has taken the longest to get working. There are a lot of caveats to writing Angular tests, which we'll get into next time.
  • There's a huge amount of online inormation and support for Angular. But, you get a lot of conflicting advice. Part of the reason for that is there's so much exposed in Angular in terms of layers, API, and JavaScript objects. There are many ways to do things. Perhaps that's good, in some ways, but when you're trying to get a problem solved it's frustrating to have to sort through dozens of suggestions and perspectives before you land on something that works.
So far, AngularJS has been useful but also frustrating at times.

Download Code
Dashboard_08.zip
https://drive.google.com/open?id=11bej1Wf_YmqqW0Saed-J0SfTy2ERR1Qu

Next: Blogged: An AngularJS Dashboard, Part 9: Unit Tests

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

Next: An AngularJS Dashboard, Part 8: Role Support and Mobile Improvements




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