This article is part of a series of 3 articles
This is the table of contents for this article only, each of the articles in this series has its own table of contents
This is the 3rd part of a proposed 3 part series. Last time we talked about some of the infrastructre peices of the demo app, and we also talked about 2 of the main workflows namely Login/Subscription management. This time we will talk about the final workflows of the demo app:
- Creating sketches workflow
- Viewing all sketches workflow
- Viewing a single sketch workflow
The code for this series is hosted on GitHub and can be found right here:
https://github.com/sachabarber/AngularAzureDemo
You will need a couple of things before you can run the demo application these are as follows:
- Visual Studio 2013
- Azure SDK v2.3
- Azure Emulator v2.3
- A will to learn by yourself for some of it
- Patience
This section outlines how the demo app create
workflow works, and how it looks.
CLICK FOR BIGGER IMAGE
If you have the user that is creating the sketch if you list of subscriptions you should also recieve a popup norification when the create a sketch on their system. You can then click on the notification and that will open their new sketch. This should look something like this (user on left is subscriber to user or right, they are both using different browsers/http sessions)
CLICK FOR BIGGER IMAGE
The login workflow works like this
- We use a html 5 canvas, and allow the user to draw with the mouse (this is done using a custom directive)
- We also allow the user to pick the stroke thickness and color (the color picer is a Angular.js module I grabbed of the internet)
- When the user saves the sketch a couple of things happen
- We grab the canva data as a base64 encoded string, which is then store in Azure blob storage
- We also create a Azure table storage row for the sketch, which simply points to the Azure blob storage url for its url field
- We use SignalR on the server side to broadcast out that a new sketch has been created
- The client side has a special (always visible controller called
RealTimeNotificationsController
which uses a SignalR client side javascript proxy, which will listen for a broadcast message about a new sketch. When it sees a message arrive it examines the message UserId, and sees if it one of the users you have active subscriptions for, if it is a popup notification is shown which you may click on to open the other users sketch
There is not much to say about the MVC controller, it simply serves up the initial view template for the create route, which uses the Angular.js CreateController
. Here is the code for it:
using System;
using System.Collections.Generic;using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace AngularAzureDemo.Controllers
{
public class HomeController : Controller
{
public ActionResult Create()
{
return View();
}
}
}
And here is the template that goes with the Angular.js CreateController
which is just after this
@{
Layout = null;
}
<br />
<div class="well">
<h2>Create Actions</h2>
<div class="row">
<p class="col-xs-12 col-sm-8 col-md-8">
<label class="labeller">Stroke Color:</label>
<input colorpicker type="text" ng-model="strokeColor" />
</p>
<p class="col-xs-12 col-sm-8 col-md-8">
<label class="labeller">Stroke Width:</label>
<input type="range" min="3" max="30" ng-model="strokeWidth">
</p>
<p class="col-xs-12 col-sm-8 col-md-8">
<label class="labeller">Title:</label>
<input type="text" maxlength="30" ng-model="title">
</p>
<p class="col-xs-12 col-sm-8 col-md-8">
<canvas class="drawingSurface"
stroke-color='strokeColor'
stroke-width='strokeWidth'
update-controller="acceptCanvas(canvasArg)"
ng-model='firstCanvas'
draw></canvas>
</p>
</div>
</div>
<div class="row">
<p class="col-xs-12 col-sm-8 col-md-8">
<button ng-click='firstCanvas = ""'
class="btn btn-default">CLEAR</button>
<button type="button" class="btn btn-primary"
data-ng-click="save()">SAVE</button>
</p>
</div>
The view is suprisingly simply. There is a html 5 canvas element which is the real guts of the view, but can you see how the canvas has some extra attributes on it such as:
- stroke-color
- stroke-width
- update-controller
- draw
What are these? These are there thanks to the crazy Angular.js feature called directives, which allow you to create whole new attributes, and even DOM elements. This one in particular is one that does the drawing on the canvas, where the code is as shown below. Angular.js does use some conventions, such as "-" being replaced with "", and the text is converted to camel casing.
The weird "=" / "=?" scope text, are to do with how you want your bindings setup, whether they are one way, two way etc etc
he & binding allows a directive to trigger evaluation of an expression in the context of the original scope, at a specific time
A good resource for this sort of stuff can be found here :
angularAzureDemoDirectives.directive("draw", ['$timeout', function ($timeout) {
return {
scope: {
ngModel: '=',
strokeColor: '=?',
strokeWidth: '=?',
updateController: '&updateController'
},
link: function (scope, element, attrs) {
scope.strokeWidth = scope.strokeWidth || 3;
scope.strokeColor = scope.strokeColor || '#343536';
var canvas = element[0];
var ctx = canvas.getContext('2d');
var drawing = false;
var lastX;
var lastY;
element.bind('mousedown', function (event) {
lastX = event.offsetX;
lastY = event.offsetY;
ctx.beginPath();
drawing = true;
});
element.bind('mouseup', function (event) {
drawing = false;
exportImage();
});
element.bind('mousemove', function (event) {
if (!drawing) {
return;
}
draw(lastX, lastY, event.offsetX, event.offsetY);
lastX = event.offsetX;
lastY = event.offsetY;
});
scope.$watch('ngModel', function (newVal, oldVal) {
if (!newVal && !oldVal) {
return;
}
if (!newVal && oldVal) {
reset();
}
});
function reset() {
element[0].width = element[0].width;
}
function draw(lX, lY, cX, cY) {
ctx.moveTo(lX, lY);
ctx.lineTo(cX, cY);
ctx.lineCap = 'round';
ctx.lineWidth = scope.strokeWidth;
ctx.strokeStyle = scope.strokeColor;
ctx.stroke();
}
function exportImage() {
$timeout(function () {
scope.ngModel = canvas.toDataURL();
scope.updateController({ canvasArg: canvas.toDataURL() });
});
}
}
};
}]);
The bulk of this directive are to do with placing strokes on the html 5 canvas element, which this directive is declared on. However this directive also takes care of grabbing the current base64 encoded string of the canvas data, which it sends back to the original scope (CreateController
which is the overall scope for the current view in this case). It does this using a simple timeout function, which can be seen in the exportImage()
function right at the end. See how we use the canvas.toDataURL()
, that grabs the base 64 encoded string. You should go look at the view again, and see how the directive binding ends up calling the CreateController
AcceptCanvas()
function
Here is the full code for the Angular CreateController
angular.module('main').controller('CreateController',
['$scope', '$log', '$window', '$location', 'loginService', 'imageBlob','dialogService',
function ($scope, $log, $window, $location, loginService, imageBlob, dialogService) {
if (!loginService.isLoggedIn()) {
$location.path("login");
}
$scope.changeLog = function () {
console.log('change');
};
$scope.strokeColor = "#a00000";
$scope.strokeWidth = 5;
$scope.canvasData = null;
$scope.title = 'New Image';
$scope.acceptCanvas = function (newCanvasData) {
$scope.canvasData = newCanvasData;
}
$scope.save = function () {
if (typeof $scope.canvasData !== 'undefined' && $scope.canvasData != null) {
var imageBlobToSave = {
"UserId": loginService.currentlyLoggedInUser().Id,
"UserName": loginService.currentlyLoggedInUser().Name,
"CanvasData": $scope.canvasData,
"Title": $scope.title,
"CreatedOn" : new Date()
};
imageBlob.save(imageBlobToSave, function (result) {
$log.log('save blobs result : ', result);
if (result) {
dialogService.showAlert('Success','Sucessfully saved image data');
} else {
dialogService.showAlert('Error','Unable to save image data');
}
}, function (error) {
dialogService.showAlert('Error',
'Unable to save image data: ' + error.message);
});
}
};
}]);
Where the following are the important bits
- We make use of a common dialog service which we looked at before
- That we make use of a UserService
- That we take a couple of dependencies, such as
$log
: Angular.js logging services (you should use this instead of Console.log)
$window
: Angular.js window abstraction
$location
: Allows controllers to change routes
loginService
: custom service to deal with login
dialogService
: custom service to show dialogs (wait/error etc etc)
imageBlob
: Angular.js $resource for obtaining/saving image blob data
- That the magical (seemlibly unused)
AcceptCanvas()
function, is actually called by the draw directive. Go have a relook at the draw directive, and also the create view to see how this all binds together
The imageBlob
is a custom Angular.js $resource based service which talks to a WebApi controller at the following url /api/ImageBlob
. Here is the imageBlob
factory code:
angularAzureDemoFactories.factory('imageBlob', ['$resource', function ($resource) {
var urlBase = '/api/imageblob/:id';
return $resource(
urlBase,
{ id: "@id" },
{
"save": { method: "POST", isArray: false }
});
}]);
It can be seen this imageBlob
is used to talk to the ImageBlobController
web api controller, which has various methods for saving/retrieving user blob data (via a repository) from Azure blob store. Here is the web api controller
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Mvc;
using AngularAzureDemo.DomainServices;
using AngularAzureDemo.Models;
using AngularAzureDemo.SignalR;
using Microsoft.AspNet.SignalR;
namespace AngularAzureDemo.Controllers
{
public class ImageBlobController : ApiController
{
private readonly IImageBlobRepository imageBlobRepository;
public ImageBlobController(IImageBlobRepository imageBlobRepository)
{
this.imageBlobRepository = imageBlobRepository;
}
[System.Web.Http.HttpPost]
public async Task<bool> Post(ImageBlob imageBlob)
{
if (imageBlob == null || imageBlob.CanvasData == null)
return false;
var storedImageBlob = await imageBlobRepository.AddBlob(imageBlob);
if (storedImageBlob != null)
{
BlobHub.SendFromWebApi(storedImageBlob);
}
return false;
}
}
}
It can be seen above that since this is now server side code, we are free to use async/await (which is fully supported by the web api v2). The only other thing to note in this code is that we also make use of a IImageBlobRepository
which is injected into the web api controller using the IOC code we saw above.
It is the IImageBlobRepository
that talks to Azure table store, it also the that deals with converting the html 5 canvas base64 encoded string into a byte[] that will be stored as a image in Azure blb storage. The full code for that is as follows:
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using System.Web;
using AngularAzureDemo.Azure.TableStorage;
using AngularAzureDemo.Models;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
using Microsoft.WindowsAzure.Storage.Table;
using Microsoft.WindowsAzure.Storage.Table.Queryable;
namespace AngularAzureDemo.DomainServices
{
public interface IImageBlobRepository
{
Task<IEnumerable<ImageBlob>> FetchAllBlobs();
Task<IEnumerable<ImageBlob>> FetchBlobsForUser(int userId);
Task<IEnumerable<ImageBlob>> FetchBlobForBlobId(Guid id);
Task<ImageBlob> AddBlob(ImageBlob imageBlobToStore);
}
public class ImageBlobRepository : IImageBlobRepository
{
private readonly CloudStorageAccount storageAccount;
private readonly Users users = new Users();
private const int LIMIT_OF_ITEMS_TO_TAKE = 1000;
public ImageBlobRepository()
{
string azureStorageConnectionString =
ConfigurationManager.AppSettings["azureStorageConnectionString"];
storageAccount = CloudStorageAccount.Parse(azureStorageConnectionString);
}
public async Task<IEnumerable<ImageBlob>> FetchAllBlobs()
{
var tableModel = await AquireTable();
if (!tableModel.TableExists)
{
return new List<ImageBlob>();
}
List<ImageBlob> imageBlobs = new List<ImageBlob>();
string rowKeyToUse = string.Format("{0:D19}",
DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks);
foreach (var user in users)
{
List<ImageBlobEntity> imageBlobEntities = new List<ImageBlobEntity>();
Expression<Func<ImageBlobEntity, bool>> filter =
(x) => x.PartitionKey == user.Id.ToString() &&
x.RowKey.CompareTo(rowKeyToUse) > 0;
Action<IEnumerable<ImageBlobEntity>> processor = imageBlobEntities.AddRange;
await ObtainImageBlobEntities(tableModel.Table, filter, processor);
var projectedImages = ProjectToImageBlobs(imageBlobEntities);
imageBlobs.AddRange(projectedImages);
}
var finalImageBlobs = imageBlobs.OrderByDescending(x => x.CreatedOn).ToList();
return finalImageBlobs;
}
public async Task<IEnumerable<ImageBlob>> FetchBlobsForUser(int userId)
{
var tableModel = await AquireTable();
if (!tableModel.TableExists)
{
return new List<ImageBlob>();
}
string rowKeyToUse = string.Format("{0:D19}", DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks);
List<ImageBlobEntity> imageBlobEntities = new List<ImageBlobEntity>();
Expression<Func<ImageBlobEntity, bool>> filter =
(x) => x.PartitionKey == userId.ToString() &&
x.RowKey.CompareTo(rowKeyToUse) > 0;
Action<IEnumerable<ImageBlobEntity>> processor = imageBlobEntities.AddRange;
await ObtainImageBlobEntities(tableModel.Table, filter, processor);
var imageBlobs = ProjectToImageBlobs(imageBlobEntities);
return imageBlobs;
}
public async Task<IEnumerable<ImageBlob>> FetchBlobForBlobId(Guid id)
{
var tableModel = await AquireTable();
if (!tableModel.TableExists)
{
return new List<ImageBlob>();
}
List<ImageBlobEntity> imageBlobEntities = new List<ImageBlobEntity>();
Expression<Func<ImageBlobEntity, bool>> filter = (x) => x.Id == id;
Action<IEnumerable<ImageBlobEntity>> processor = imageBlobEntities.AddRange;
await ObtainImageBlobEntities(tableModel.Table, filter, processor);
var imageBlobs = ProjectToImageBlobs(imageBlobEntities);
return imageBlobs;
}
public async Task<ImageBlob> AddBlob(ImageBlob imageBlobToStore)
{
BlobStorageResult blobStorageResult = await StoreImageInBlobStorage(imageBlobToStore);
if (!blobStorageResult.StoredOk)
{
return null;
}
else
{
CloudTableClient tableClient = storageAccount.CreateCloudTableClient();
CloudTable imageBlobsTable = tableClient.GetTableReference("ImageBlobs");
var tableExists = await imageBlobsTable.ExistsAsync();
if (!tableExists)
{
await imageBlobsTable.CreateIfNotExistsAsync();
}
ImageBlobEntity imageBlobEntity = new ImageBlobEntity(
imageBlobToStore.UserId,
imageBlobToStore.UserName,
Guid.NewGuid(),
blobStorageResult.BlobUrl,
imageBlobToStore.Title,
imageBlobToStore.CreatedOn
);
TableOperation insertOperation = TableOperation.Insert(imageBlobEntity);
imageBlobsTable.Execute(insertOperation);
return ProjectToImageBlobs(new List<ImageBlobEntity>() { imageBlobEntity }).First();
}
}
private async Task<ImageBlobCloudModel> AquireTable()
{
CloudTableClient tableClient = storageAccount.CreateCloudTableClient();
CloudTable imageBlobsTable = tableClient.GetTableReference("ImageBlobs");
var tableExists = await imageBlobsTable.ExistsAsync();
return new ImageBlobCloudModel
{
TableExists = tableExists,
Table = imageBlobsTable
};
}
private static List<ImageBlob> ProjectToImageBlobs(List<ImageBlobEntity> imageBlobEntities)
{
var imageBlobs =
imageBlobEntities.Select(
x =>
new ImageBlob()
{
UserId = int.Parse(x.PartitionKey),
UserName = x.UserName,
SavedBlobUrl = x.BlobUrl,
Id = x.Id,
Title = x.Title,
CreatedOn = x.CreatedOn,
CreatedOnPreFormatted = x.CreatedOn.ToShortDateString(),
}).ToList();
return imageBlobs;
}
private async Task<BlobStorageResult> StoreImageInBlobStorage(ImageBlob imageBlobToStore)
{
CloudBlobClient blobClient = storageAccount.CreateCloudBlobClient();
CloudBlobContainer container = blobClient.GetContainerReference("images");
bool created = container.CreateIfNotExists();
container.SetPermissionsAsync(
new BlobContainerPermissions
{
PublicAccess = BlobContainerPublicAccessType.Blob
});
var blockBlob = container.GetBlockBlobReference(string.Format(@"{0}.image/png",
Guid.NewGuid().ToString()));
string marker = "data:image/png;base64,";
string dataWithoutJpegMarker = imageBlobToStore.CanvasData.Replace(marker, String.Empty);
byte[] filebytes = Convert.FromBase64String(dataWithoutJpegMarker);
blockBlob.UploadFromByteArray(filebytes, 0, filebytes.Length);
return new BlobStorageResult(true, blockBlob.Uri.ToString());
}
private async Task<bool> ObtainImageBlobEntities(
CloudTable imageBlobsTable,
Expression<Func<ImageBlobEntity, bool>> filter,
Action<IEnumerable<ImageBlobEntity>> processor)
{
TableQuerySegment<ImageBlobEntity> segment = null;
while (segment == null || segment.ContinuationToken != null)
{
var query = imageBlobsTable
.CreateQuery<ImageBlobEntity>()
.Where(filter)
.Take(LIMIT_OF_ITEMS_TO_TAKE)
.AsTableQuery();
segment = await query.ExecuteSegmentedAsync(
segment == null ? null : segment.ContinuationToken);
processor(segment.Results);
}
return true;
}
}
}
Real time notifications are done by the web socket (there are more transports) enabled library SignalR. What happens is as follows:
- A new blob is stored for the image
- A call to the SignalR hub is done from the web api controller, which broadcasts the event to all connected clients
- The client recieved this and sees if it is from someone in their suscription list, if it is a notification is shown
The web api controller that initates all this is the line
BlobHub.SendFromWebApi(storedImageBlob);
And here is all the SignalR hub code (yep that's it)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Timers;
using System.Web;
using AngularAzureDemo.Models;
using Microsoft.AspNet.SignalR;
namespace AngularAzureDemo.SignalR
{
public class BlobHub : Hub
{
public void Send(ImageBlob latestBlob)
{
Clients.All.latestBlobMessage(latestBlob);
}
public static void SendFromWebApi(ImageBlob imageBlob)
{
var hubContext = GlobalHost.ConnectionManager.GetHubContext<BlobHub>();
hubContext.Clients.All.latestBlobMessage(imageBlob);
}
}
}
And here is the code for the Angular.js RealTimeNotificationsController
that is visible on every page, so it is always active. It can be seen that this controller is where we start / connect to the server side SignalR hub, and deal with any real time notifications that come up, where we show a toast notification should it come from one of the users we have in our subscription list.
I am using John Papa's Toast library to do the notifications.
angular.module('main').controller('RealTimeNotificationsController',
['$rootScope','$scope', '$log', '$window', '$location', '$cookieStore', '_',
function ($rootScope, $scope, $log, $window, $location, $cookieStore, _) {
toastr.options = {
"closeButton": true,
"debug": false,
"positionClass": "toast-top-right",
"onclick": navigate,
"showDuration": "5000000",
"hideDuration": "1000",
"timeOut": "5000000",
"extendedTimeOut": "1000",
"showEasing": "swing",
"hideEasing": "linear",
"showMethod": "fadeIn",
"hideMethod": "fadeOut"
}
$scope.latestBlobId = '';
$(function () {
var blobProxy = $.connection.blobHub;
blobProxy.client.latestBlobMessage = function (latestBlob) {
$scope.allFriendsSubscriptionsCookie = $cookieStore.get('allFriendsSubscriptions');
var userSubscription = _.findWhere($scope.allFriendsSubscriptionsCookie,
{ Id: latestBlob.UserId });
if (userSubscription != undefined) {
$scope.latestBlobId = latestBlob.Id;
$scope.$apply();
var text = latestBlob.UserName + ' has just created a new image called "' +
latestBlob.Title + '", click here to view it';
toastr['info'](text, "New image added");
}
};
startHubConnection();
$.connection.hub.disconnected(function () {
$log.log('*** BlobHub Disconnected');
setTimeout(function () {
startHubConnection();
}, 1000); });
});
function startHubConnection() {
$.connection.hub.start(
{
transport: ['webSockets', 'longPolling'],
waitForPageLoad: false
});
}
function navigate() {
$rootScope.$apply(function() {
$location.path("viewsingleimage/" + $scope.latestBlobId);
$location.replace();
});
}
}]);
The view all workflow is one of the simpler ones, where it really just grabs all the saved image blobs and a count of their comments and shows them in a table, from where the user may click on one of them to see just that image
CLICK FOR BIGGER IMAGE
There is not much to say about the MVC controller, it simply serves up the initial view template for the viewall route, which uses the Angular.js ViewAllController
. Here is the code for it:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace AngularAzureDemo.Controllers
{
public class HomeController : Controller
{
public ActionResult ViewAll()
{
return View();
}
}
}
And here is the template that goes with the Angular.js ViewAllController
which is just after this
@{
Layout = null;
}
<div ng-show="hasItems">
<br />
<div class="well">
<h2>Latest Sketches</h2>
<div class="row">
<div ng-repeat="tableItem in tableItems"
class="col-xs-12 col-sm-3 col-md-2 resultTableItem">
<a ng-href="#/viewsingleimage/{{tableItem.Blob.Id}}">
<div class="resultItem">
<div class="resultItemInner">
<div class="titleInner">
<p><span class="smallText">Title:</span>
<span class="smallText">{{tableItem.Blob.Title}}</span></p>
<p><span class="smallText">Comments:</span>
<span class="smallText">{{tableItem.Comments.length}}</span></p>
</div>
<div class="imgHolder">
<img ng-src="{{tableItem.Blob.SavedBlobUrl}}"
class="img-responsive resultsImage" alt="Responsive image" />
</div>
</div>
</div>
</a>
</div>
</div>
</div>
</div>
It can seen that we simply display a list of items from the controller using Angular.js ng-repeat. The only other bit of interest is that when you click on one of the sketches you are redirected to the single image. This is done by using a big html anchor tag around the whole item template, which when clicked will ask Angular.js to perform a redirect
Here is the full code for the Angular ViewAllController
angular.module('main').controller('ViewAllController',
['$scope', '$log', '$window', '$location', 'loginService', 'imageBlobComment','dialogService',
function ($scope, $log, $window, $location, loginService, imageBlobComment, dialogService) {
if (!loginService.isLoggedIn()) {
$location.path("login");
}
$scope.storedBlobs = [];
$scope.tableItems = [];
$scope.hasItems = false;
dialogService.showPleaseWait();
getAllBlobs();
function getAllBlobs() {
imageBlobComment.get(function (result) {
$scope.storedBlobs = [];
if (result.BlobComments.length == 0) {
dialogService.hidePleaseWait();
dialogService.showAlert('Info',
'There are no items stored right now');
$scope.hasItems = false;
} else {
$scope.hasItems = true;
$scope.tableItems = result.BlobComments;
dialogService.hidePleaseWait();
}
}, function (error) {
$scope.hasItems = false;
dialogService.hidePleaseWait();
dialogService.showAlert('Error',
'Unable to load stored image data: ' + error.message);
});
}
}]);
Where the following are the important bits
- We make use of a common dialog service which we looked at before
- That we make use of a UserService
- That we take a couple of dependencies, such as
$log
: Angular.js logging services (you should use this instead of Console.log)
$window
: Angular.js window abstraction
$location
: Allows controllers to change routes
loginService
: custom service to deal with login
dialogService
: custom service to show dialogs (wait/error etc etc)
imageBlobComment
: Angular.js $resource for obtaining image blob and comment data
- That the controller provides a list of items for the view to bind to
The imageBlobComment
is a custom Angular.js $resource based service which talks to a WebApi controller at the following url /api/ImageBlobComment
. Here is the imageBlobComment
factory code:
angularAzureDemoFactories.factory('imageBlobComment', ['$resource', '$http', function ($resource, $http) {
var urlBase = '/api/imageblobcomment/:id';
var ImageBlobComment = $resource(
urlBase,
{ id: "@id" },
{
"save": { method: "POST", isArray: false }
});
ImageBlobComment.fetchSingle = function(id) {
return $http.get('/api/imageblobcomment/' + id);
}
return ImageBlobComment;
}]);
It can be seen this imageBlobComment
is used to talk to the ImageBlobController
web api controller, which has various methods for retrieving user blob comment data (via a repository) from Azure blob store. Here is the web api controller
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Mvc;
using AngularAzureDemo.DomainServices;
using AngularAzureDemo.Models;
namespace AngularAzureDemo.Controllers
{
public class ImageBlobCommentController : ApiController
{
private readonly IImageBlobRepository imageBlobRepository;
private readonly ImageBlobCommentRepository imageBlobCommentRepository;
public ImageBlobCommentController(IImageBlobRepository imageBlobRepository,
ImageBlobCommentRepository imageBlobCommentRepository)
{
this.imageBlobRepository = imageBlobRepository;
this.imageBlobCommentRepository = imageBlobCommentRepository;
}
[System.Web.Http.HttpGet]
public async Task<FullImageBlobComments> Get()
{
var blobs = await imageBlobRepository.FetchAllBlobs();
var fullImageBlobComments = await FetchBlobComments(blobs);
return fullImageBlobComments;
}
[System.Web.Http.HttpGet]
public async Task<FullImageBlobComment> Get(Guid id)
{
if (Guid.Empty == id)
return new FullImageBlobComment();
var blob = await imageBlobRepository.FetchBlobForBlobId(id);
var fullImageBlobComments = await FetchBlobComments(new List<ImageBlob>() {
blob.First()
});
return fullImageBlobComments.BlobComments.Any() ?
fullImageBlobComments.BlobComments.First() : new FullImageBlobComment();
}
[System.Web.Http.HttpPost]
public async Task<ImageBlobCommentResult> Post(ImageBlobComment imageBlobCommentToSave)
{
if (imageBlobCommentToSave == null)
return new ImageBlobCommentResult() { Comment = null, SuccessfulAdd = false};
if (string.IsNullOrEmpty(imageBlobCommentToSave.Comment))
return new ImageBlobCommentResult() { Comment = null, SuccessfulAdd = false };
var insertedComment = await imageBlobCommentRepository.
AddImageBlobComment(imageBlobCommentToSave);
return new ImageBlobCommentResult()
{
Comment = insertedComment, SuccessfulAdd = true
};
}
private async Task<FullImageBlobComments> FetchBlobComments(IEnumerable<ImageBlob> blobs)
{
FullImageBlobComments fullImageBlobComments = new FullImageBlobComments();
foreach (var blob in blobs)
{
var comments = await imageBlobCommentRepository.FetchAllCommentsForBlob(blob.Id);
fullImageBlobComments.BlobComments.Add(
new FullImageBlobComment()
{
Blob = blob,
Comments = comments.ToList()
});
}
return fullImageBlobComments;
}
}
}
It can be seen above that since this is now server side code, we are free to use async/await (which is fully supported by the web api v2). The only other thing to note in this code is that we also make use of a IImageBlobCommentRepository
which is injected into the web api controller using the IOC code we saw above. It is the IImageBlobCommentRepository
that deals with gathering the data from Azure table store for blobs and comments. Here is the code for the IImageBlobCommentRepository
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using System.Web;
using AngularAzureDemo.Azure.TableStorage;
using AngularAzureDemo.Models;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
using Microsoft.WindowsAzure.Storage.Table;
using Microsoft.WindowsAzure.Storage.Table.Queryable;
namespace AngularAzureDemo.DomainServices
{
public interface IImageBlobCommentRepository
{
Task<IEnumerable<ImageBlobComment>> FetchAllCommentsForBlob(Guid associatedBlobId);
Task<ImageBlobComment> AddImageBlobComment(ImageBlobComment imageBlobCommentToStore);
}
public class ImageBlobCommentRepository : IImageBlobCommentRepository
{
private readonly string azureStorageConnectionString;
private readonly CloudStorageAccount storageAccount;
private Users users = new Users();
private const int LIMIT_OF_ITEMS_TO_TAKE = 1000;
public ImageBlobCommentRepository()
{
azureStorageConnectionString =
ConfigurationManager.AppSettings["azureStorageConnectionString"];
storageAccount = CloudStorageAccount.Parse(azureStorageConnectionString);
}
public async Task<IEnumerable<ImageBlobComment>> FetchAllCommentsForBlob(Guid associatedBlobId)
{
CloudTableClient tableClient = storageAccount.CreateCloudTableClient();
CloudTable imageBlobCommentsTable = tableClient.GetTableReference("ImageBlobComments");
var tableExists = await imageBlobCommentsTable.ExistsAsync();
if (!tableExists)
{
return new List<ImageBlobComment>();
}
string rowKeyToUse = string.Format("{0:D19}",
DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks);
List<ImageBlobCommentEntity> blobCommentEntities = new List<ImageBlobCommentEntity>();
Expression<Func<ImageBlobCommentEntity, bool>> filter =
(x) => x.AssociatedBlobId == associatedBlobId &&
x.RowKey.CompareTo(rowKeyToUse) > 0;
Action<IEnumerable<ImageBlobCommentEntity>> processor = blobCommentEntities.AddRange;
await this.ObtainBlobCommentEntities(imageBlobCommentsTable, filter, processor);
var imageBlobs = ProjectToBlobComments(blobCommentEntities);
return imageBlobs;
}
public async Task<ImageBlobComment> AddImageBlobComment(ImageBlobComment imageBlobCommentToStore)
{
CloudTableClient tableClient = storageAccount.CreateCloudTableClient();
CloudTable imageBlobCommentsTable = tableClient.GetTableReference("ImageBlobComments");
var tableExists = await imageBlobCommentsTable.ExistsAsync();
if (!tableExists)
{
await imageBlobCommentsTable.CreateIfNotExistsAsync();
}
ImageBlobCommentEntity imageBlobCommentEntity = new ImageBlobCommentEntity(
imageBlobCommentToStore.UserId,
imageBlobCommentToStore.UserName,
Guid.NewGuid(),
imageBlobCommentToStore.AssociatedBlobId,
imageBlobCommentToStore.Comment,
imageBlobCommentToStore.CreatedOn.ToShortDateString()
);
TableOperation insertOperation = TableOperation.Insert(imageBlobCommentEntity);
var result = imageBlobCommentsTable.Execute(insertOperation);
return ProjectToBlobComments(new List<ImageBlobCommentEntity>() {imageBlobCommentEntity}).First();
}
private static List<ImageBlobComment> ProjectToBlobComments(
List<ImageBlobCommentEntity> blobCommentEntities)
{
var blobComments =
blobCommentEntities.Select(
x =>
new ImageBlobComment()
{
Comment = x.Comment,
UserName = x.UserName,
CreatedOn = DateTime.Parse(x.CreatedOn),
CreatedOnPreFormatted = DateTime.Parse(x.CreatedOn).ToShortDateString(),
UserId = Int32.Parse(x.PartitionKey),
Id = x.Id,
AssociatedBlobId = x.AssociatedBlobId
}).ToList();
return blobComments;
}
private async Task<bool> ObtainBlobCommentEntities(
CloudTable imageBlobsTable,
Expression<Func<ImageBlobCommentEntity, bool>> filter,
Action<IEnumerable<ImageBlobCommentEntity>> processor)
{
TableQuerySegment<ImageBlobCommentEntity> segment = null;
while (segment == null || segment.ContinuationToken != null)
{
var query = imageBlobsTable
.CreateQuery<ImageBlobCommentEntity>()
.Where(filter)
.Take(LIMIT_OF_ITEMS_TO_TAKE)
.AsTableQuery();
segment = await query.ExecuteSegmentedAsync(segment == null ? null :
segment.ContinuationToken);
processor(segment.Results);
}
return true;
}
}
}
There are a couple of take away points from here:
- Azure SDK supports async / await, so I use it
- Azure table storage supports limited LINQ queries, very limited, but it can be done, so we do that by using a custom
Func<T,TR>
- We don't know how many items are stored, so I opted for doing it in batches, which is done using the
TableQuerySegment
class, which you can see in the code above
- When we delete items from the Azure table store I don't want to do it one by one that would be pretty silly, so we use the
TableBatchOperation
as demonstrated in the code above
- Azure table storage has a upsert like feature (MERGE in standard SQL), so we use that, it is a static method of
TableBatchOperation
called TableBatchOperation.InsertOrReplace(..)
Other than those points, it is all pretty standard stuff
The view single workflow is also fairly straight forward in that it simply shows a single view and allows users to add comments to the sketch. You may only add comments if you are not the owner of the sketch.
CLICK FOR BIGGER IMAGE
There is not much to say about the MVC controller, it simply serves up the initial view template for the viewsingle route, which uses the Angular.js ViewSingleController
.
Here is the code for it:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace AngularAzureDemo.Controllers
{
public class HomeController : Controller
{
public ActionResult ViewSingleImage()
{
return View();
}
}
}
And here is the template that goes with the Angular.js ViewAllController
which is just after this
@{
Layout = null;
}
<div ng-show="hasItem">
<br />
<div class="well">
<h2>{{storedBlob.Blob.Title}}</h2>
<span class="smallText">Created By:</span>
<span class="smallText">{{storedBlob.Blob.UserName}}</span>
<br/>
<span class="smallText">Created On:</span>
<span class="smallText">{{storedBlob.Blob.CreatedOnPreFormatted}}</span>
<div class="row">
<br/>
<div class="col-xs-12 col-sm-12 col-md-12">
<img ng-src="{{storedBlob.Blob.SavedBlobUrl}}"
class="img-responsive"
alt="Responsive image" />
</div>
</div>
<br />
<br />
<div class="row" ng-hide="isTheirOwnImage">
<div id="newCommentActionRow" class="col-xs-12 col-sm-12 col-md-12">
<h4>Add A New Comment</h4>
</div>
<p class="col-xs-12 col-sm-8 col-md-8">
<input type="text" class="form-control" ng-model="commentText">
</p>
<div class="col-xs-12 col-sm-4 col-md-4">
<button type="button" class="btn btn-primary"
data-ng-click="saveComment()"
ng-disabled="!hasComment()">ADD</button>
</div>
<br />
</div>
<div class="row" ng-show="storedBlob.Comments.length > 0">
<br />
<br />
<h4 class="commentHeader">Comments</h4>
<div class="col-xs-12 col-sm-12 col-md-12">
<table class="table table-striped table-condensed">
<tr>
<th>Comment Left By</th>
<th>Comment Date</th>
<th>Comment</th>
</tr>
<tbody ng:repeat="comment in storedBlob.Comments">
<tr>
<td width="25%">{{comment.UserName}}</td>
<td width="25%">{{comment.CreatedOnPreFormatted}}</td>
<td width="50%">{{comment.Comment}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
There is not much to say about this view, its all pretty standard stuff actually. A few bindings nothing more.
Here is the full code for the Angular ViewSingleController
angular.module('main').controller('ViewSingleImageController',
['$scope', '$log', '$window', '$location', '$routeParams',
'loginService', 'imageBlobComment', 'dialogService',
function ($scope, $log, $window, $location, $routeParams,
loginService, imageBlobComment, dialogService) {
$scope.currentUserId = 0;
if (!loginService.isLoggedIn()) {
$location.path("login");
} else {
$scope.currentUserId = loginService.currentlyLoggedInUser().Id;
}
$log.log('single controller $scope.currentUserId', $scope.currentUserId);
$log.log('single controller loginService.currentlyLoggedInUser().Id',
loginService.currentlyLoggedInUser().Id);
$scope.id = $routeParams.id;
$scope.storedBlob = null;
$scope.hasItem = false;
$scope.commentText = '';
$scope.isTheirOwnImage = false;
$log.log('Route params = ', $routeParams);
$log.log('ViewSingleImageController id = ', $scope.id);
dialogService.showPleaseWait();
getBlob($scope.id);
$scope.hasComment = function () {
return typeof $scope.commentText !== 'undefined' &&
$scope.commentText != null
&& $scope.commentText != '';
};
$scope.saveComment = function () {
if ($scope.hasComment()) {
var imageBlobCommentToSave = {
"UserId": loginService.currentlyLoggedInUser().Id,
"UserName": loginService.currentlyLoggedInUser().Name,
"CreatedOn": new Date(),
"AssociatedBlobId": $scope.storedBlob.Blob.Id,
"Comment" : $scope.commentText
};
imageBlobComment.save(imageBlobCommentToSave, function (result) {
$log.log('save imageBlobComments result : ', result);
if (result.SuccessfulAdd) {
$scope.storedBlob.Comments.unshift(result.Comment);
} else {
dialogService.showAlert('Error', 'Unable to save comment');
}
}, function (error) {
$log.log('save imageBlobComments error : ', error);
dialogService.showAlert('Error', 'Unable to save comment: ' + error.message);
});
}
};
function getBlob(id) {
imageBlobComment.fetchSingle(id)
.success(function (result) {
$scope.hasItem = true;
$scope.storedBlob = result;
$log.log("SCOPE BLOB", $scope.storedBlob);
$scope.isTheirOwnImage = $scope.storedBlob.Blob.UserId == $scope.currentUserId;
$log.log('single controller $scope.currentUserId', $scope.currentUserId);
$log.log('single controller loginService.currentlyLoggedInUser().Id',
loginService.currentlyLoggedInUser().Id);
$log.log('single controller $scope.storedBlob.Blob.UserId',
$scope.storedBlob.Blob.UserId);
dialogService.hidePleaseWait();
}).error(function (error) {
$scope.hasItem = false;
dialogService.hidePleaseWait();
dialogService.showAlert('Error',
'Unable to load stored image data: ' + error.message);
});
}
}]);
Where the following are the important bits
- We make use of a common dialog service which we looked at before
- That we make use of a UserService
- That we take a couple of dependencies, such as
$log
: Angular.js logging services (you should use this instead of Console.log)
$window
: Angular.js window abstraction
$location
: Allows controllers to change routes
$routeParams
: We use this service to grab the url parameters from the route that we are now rendering, which allows us to load up the correct entity. The source for the viewsingle route was when the user clicked on single sketch in the viewall view
loginService
: custom service to deal with login
dialogService
: custom service to show dialogs (wait/error etc etc)
imageBlobComment
: Angular.js $resource for obtaining image blob and comment data
The imageBlobComment
is a custom Angular.js $resource based service which talks to a WebApi controller at the following url /api/ImageBlobComment
. We have already seen the code for this above. The web api controller also makes use of the IImageBlobCommentRepository
which we also seen the code for above.
That is all I wanted to say in this series.I hope you have enjoyed the series, and have learned something along the way. I know I always learn quite a bit when I write an article, its my main learning vehicle.
If you like what you have seen, a vote or comment is most welcome.