AngularJs native tag editor

Introduction

Continuing my series on directives that fill gaps in the angular framework, today I will discuss my implementation of a tag editor. In some ways this is simpler than my previous attempts, no new services needed to be created, however, there are a lot of styling concerns that need to be dealt with. I think I found a reasonable approach that provides a decent default look and feel and provides implementers the flexibility to provide their own styling.

The code is available on github. It is also available on ngmodules.org, please click the ‘I use it button’.

Demo here.

Design Goals

  • Native
  • Control styling
  • Enable tag interactivity
  • Support edit & read-only modes

Supported Browsers

This directive relies 2 events that have been supported by all major browsers for a while:

  • blur
  • keypress

API

The directive works with Tag object which are defined as:

{
Name: "",
Description: "",
ID: ""
}

Events

These are methods that need to be defined on the controller’s scope object, and are called by the directive.

onValidate(tag)
Called before the tag is added to the parent’s Tag array. If this function returns false the tag will not be added. It is up to this function to display any error messages to the user.
onHover(tag, elementPos)
Called when the mouse is over the displayed tag. This event is only called once, when the mouse enters the tag’s bounding box. The second argument details the screen location of the element the mouse is over, the intent being that you can display more information about the tag in a reasonable place on the page
onHoverOut()
Called when the mouse has left the tag’s bounding box

Attributes

These are attributes added to the directive markup in the HTML template. Attributes are angular expressions and optional unless otherwise noted.

useDefaultCss
Do not include the default <style> element in the DOM. You will be responsible for providing your own CSS classes. (default: false)
readOnly
Do not display the tag input box (default: false)
focusInput
Give focus to the tag editor’s input element when the page load. This value is ignored if readOnly === 'true'. If multiple elements claim focus the browser will decide which one wins. (default: false)
caption
If present this will be displayed under the tag editor’s input element
tags
Reference, Required This is the name of an array of tag objects on the directive’s parent.

Tag Editor Directive

The directive is pretty straightforward, the biggest tric

angular
.module('lvl.directives.tageditor', ['lvl.services'])
.directive('lvlTagEditor', ['$timeout', 'uuid', function ($timeout, uuid) {

    var css = "<style>.tag-list{  " +
"    margin: 0; " +
"    padding: 0; " +
"    list-style: none; " +
"    display: inline-block; " +
"} " +
" " +
".tag-editor { " +
"   display: inline-block; " +
"} " +
" " +
".tag-item " +
"{ " +
"    overflow: hidden; " +
"    height: auto !important; " +
"    height: 15px; " +
"    margin: 3px; " +
"    padding: 1px 3px; " +
"    background-color: #eff2f7; " +
"    color: #000; " +
"    cursor: default; " +
"    border: 1px solid #ccd5e4; " +
"    font-size: 11px; " +
"    border-radius: 5px; " +
"    -moz-border-radius: 5px; " +
"    -webkit-border-radius: 5px; " +
"    float: left; " +
"    white-space: nowrap; " +
"} " +
" " +
" .edit-tag:before { " +
"    content: 'e '; " +
"    font-size: 6pt;" +
" }" +
" " +
" .delete-tag:before { " +
"    content: 'x '; " +
"    font-size: 6pt;" +
" }" +
" " +
".tag-name " +
"{ " +
"    margin: 0; " +
"    display: inline-block; " +
"} " +
" " +
".tag-help { " +
"    color: #595959; " +
"    display: block; " +
"    margin-bottom: 2px; " +
"} " +
" " +
".tag-action { " +
"        color: white; " +
"        border-radius: 4px; " +
"        width: 12px; " +
"        height: 12px; " +
"        display:inline-block; " +
"        text-align: center; " +
"        padding-bottom:8px; " +
"        border: 1px solid gray; " +
"} " +
" " +
".edit-tag { " +
"        background-color: #004181; " +
"        color: #a3a3a3; " +
"} " +
" " +
".delete-tag { " +
"        background-color: #b40000; " +
"        color: #a3a3a3; " +
"} " +
" " +
".edit-tag:hover { " +
"        background-color: gray; " +
"        color: black; " +
"} " +
" " +
".delete-tag:hover { " +
"        background-color: gray; " +
"        color: black; " +
"} " +
" " +
".edit-tag:after { " +
"        word-spacing: 1em; " +
"} " +
"</style>";

    return {
        restrict: 'E',
        template: ' ' +
'<div class="tag-editor"> ' +
'   <div> ' +
'       <input ' +
'           type="text" ' +
'           ng-model="currentTag.Name" ' +
'           style="display: inline-block" /> ' +
'       <small class="tag-caption" ng-show="caption">{{caption}}</small> ' +
'   </div> ' +
'   <div ng-show="tags.length"> ' +
'      <ul class="tag-list"> ' +
'          <li ng-repeat="tag in tags" class="tag-item" ng-mouseover="over($event)" ng-mouseleave="out($event)" data-tag-id={{tag.Id}}> ' +
'              <p class="tag-name"> ' +
'                  {{tag.Name}} ' +
'              </p> ' +
'              <div class="tag-action delete-tag" ng-hide="readOnly" ng-click="removeTag(tag)" title="remove tag"></div> ' +
'              <div class="tag-action edit-tag" ng-hide="readOnly" ng-click="editTag(tag)" title="edit tag"></div> ' +
'          </li> ' +
'      </ul> ' +
'   </div> ' +
'</div>',
        replace: true,
        scope: {
            tags: "=",
            useDefaultCss: "@",
            readOnly: "@",
            focusInput: "@",
            caption: "@",
            onValidate: "&",
            onHover: "&",
            onHoverOut: "&"
        },
        compile: function compile(tElement, tAttrs, transclude) {
            var input = angular.element(angular.element(tElement).children(0).children(0)[0]);
            var inputId = uuid.new();
            input.attr("id", inputId);

            if (tAttrs.readOnly === 'true') {
                input.remove();
            } else {
                input.bind("blur", function(event) {
                    var scope = angular.element(this).scope();
                    scope.$apply(function() {
                        scope.addTag(event);
                    });
                });

                input.bind("keypress", function(event) {
                    var scope = angular.element(this).scope();
                    scope.$apply(function() {
                        if (event.which == 13) {
                            scope.addTag(event);
                            event.preventDefault();
                        }
                    });
                });
            }

            if (tAttrs.useDefaultCss === 'true') {
                angular.element(tElement).prepend(css);
            }

            return function postLink(scope, el, attrs, ctl) {
                var runValidation = attrs.onValidate != undefined;

                if (attrs.focusInput === 'true') {
                    document.getElementById(inputId).focus();
                }

                var hovering = false;
                scope.over = function($event) {
                    if (hovering) return;
                    hovering = true;

                    var domEl = $event.currentTarget;
                    var el = angular.element(domEl);
                    if (!el.attr("data-tag-id")) {
                        el = el.parent();
                    }

                    var id = el.attr("data-tag-id");
                    var tag = scope.tags.getByFunc(function(t) {return t.Id == id;});
                    var pos = domEl.getBoundingClientRect();
                    scope.onHover({tag: tag, elementPos: {
                                                            height: pos.height,
                                                            width: pos.width,
                                                            left: pos.left,
                                                            bottom: pos.bottom,
                                                            right: pos.right,
                                                            top: pos.top
                                                        }});
                };

                scope.out = function($event) {
                    hovering = false;

                    scope.onHoverOut();
                }

                scope.addTag = function ($event) {
                    scope.valErr = false;
                    
                    if (scope.currentTag.Name == "") return;

                    var tagValid = (runValidation && scope.onValidate({ tag: scope.currentTag })) || !runValidation;

                    if (!tagValid) return;

                    if (!scope.tags.tagExists(scope.currentTag)) {
                        scope.tags.push(scope.currentTag);
                    }

                    scope.currentTag = new tag();
                };

                scope.editTag = function (tag) {
                    scope.removeTag(tag);
                    scope.currentTag = tag;
                    document.getElementById(inputId).focus();

                    scope.onHoverOut();
                };

                scope.removeTag = function (tag) {
                    scope.tags.removeByFunc(function (t) {
                        return t.Id == tag.Id;
                    });

                    scope.onHoverOut();
                };

                scope.currentTag = new tag();
                
                function tag() {
                    return {
                        Name: "",
                        Description: "",
                        Id: uuid.new()
                    };
                }
            };
        }
    };
}]);

Array.prototype.tagExists = function(tag) {
    for (var i = 0; i < this.length; i++) {
        if (this[i].Name == tag.Name) {
            return true;
        }
    }

    return false;
};

Array.prototype.removeByFunc = function () {
    var what, a = arguments;
    for (var ax = this.length - 1; ax >= 0; ax--) {
        what = this[ax];
        if (a[0](what)) {
            this.splice(ax, 1);
        }
    }
    return this;
};

Array.prototype.getByFunc = function() {
  var what, a = arguments;
    for (var ax = this.length - 1; ax >= 0; ax--) {
        what = this[ax];
        if (a[0](what)) {
            return what;
        }
    }
    return null;  
}

Lines 5 - 91: The default styles for the directive. The styles are only added to the DOM if the attribute use-default-style = 'true'.

Lines 94 - 126: Setup the directive definition object with the directive UI, and the events and attributes.

Lines 128 - 155: A number of things are happening during the compilation phase.

  1. If the directive is in read only (read-only=='true') mode, remove the text input element
  2. Bind event handlers to handle the blur and keypress which are used to add tags to the array.

    Lines 136 & 143: Since we are not operating in an angular context we retrieve the scope associated with the current element.

    Lines 137 & 144: Again, we are not in an angular context (i.e. not in a digest cycle) so start one before calling into the directive’s scope functions, so two-way binding works as expected.

    Lines 145 - 148: If the user pressed the enter key, add the tag and prevent browser default behavior.
  3. Adds the default styles to the DOM if use-default-css==='true'

Line 158: Determine if tags should be validated.

Line 161: Focus input element if focus-input === 'true'

Lines 164 - 186: Handle the onmouseenter DOM event, and fire the on-hover directive event as required. The variable hovering ensures the on-hover event is only fired once.

Lines 171 - 173: Ensure that the variable el is referencing the li which contain’s the tag’s id as an attribute.

Line 176: Retrieve the tag using a monkey-patch method defined later in the codefile.

Lines 177 - 186: Find the position of the element being moused over. One weird thing is that I couldn’t pass the pos variable directly in the event, so I had to redefine the object on Lines 175 - 180.

Lines 188 - 192: Handle the onmouseleave DOM event, and fire the on-hover-out directive event.

Lines 194 - 208: If a validation function has been specified (attribute: on-validate) run it. If the tag passes validation add it the tag to the parent’s tag array (specified in the directives tag attribute).

Lines 210 - 216: When the tag is edited, remove it from the tag array and add it to the input box. Fire the on-hover-out event to ensure any UI the directive’s parent created is cleared.

Lines 218 - 224: Remove the tag from the parent’s tag array using a monkey patch method defined later in the codefile. Fire the on-hover-out event to ensure any UI the directive’s parent created is cleared.

Lines 226 - 234: Define a tag object, and create a new tag that is used as the ng-mode for the input text box.

Lines 240 - 245: Add an array method to determine whether a tag exists in the array. Ensure tag names are unique by comparing names instead of Ids.

Lines 250 - 259: Add an array method to remove tags based on a predicate function. The predicate function is called with a tag object as a parameter and returns true to remove the tag. See line 219 for example usage.

Lines 261 - 270: Add an array method to get a tag based on a predicate function. The predicate function is called with a tag object as a parameter and returns true to return the tag. See line 176 for example usage.

Styling Tags

The following classes are used

tag-editor
Applied to the directive’s root div.
tag-caption
Applied to the small element that contains the text specified by the caption attribute.
tag-list
Applied to the ul element that contains the tags.

tag-item
Applied to the li elements containing the tag mark up.
tag-name
Applied to the p element that contains the tag’s name.
tag-action
Applied to a div elements that are the buttons to control editing and deleting a tag.
delete-tag
Applied with the tag-action class on one of the button divs.
edit-tag
Applied with the tag-action class on one of the button divs.

Using the directive

I’m happy with the way this directive came out. I think I hit the right level of abstraction to make plugging it into a web page easy.

Html

<script src="lib/lvl-uuid.js"></script>
<script src="script/lvl-tag-editor.js"></script>

<!-- editable tags -->
<lvl-tag-editor
  use-default-css='true'
  focus-input='true'
  tags='tags'
  on-hover='hover(tag, elementPos)'
  on-hover-out='hoverOut()'
  on-validate='validate(tag)'
></lvl-tag-editor>

<!-- read only tags -->
<lvl-tag-editor
  use-default-css='false'
  read-only='true'
  tags='tags'
  on-hover='hover(tag, elementPos)'
  on-hover-out='hoverOut()'
  style="display:inline-block"
></lvl-tag-editor>

Script

angular
.module('tagApp', ['lvl.services', 'lvl.directives.tageditor'])
.controller('tagCtl', ['$scope', '$timeout', "uuid", function($scope, $timeout, uuid) {
	$scope.selected = null;
	
	$scope.tags = [
		{Name: "one", Id: uuid.new(), Description: "the one tag"}, 
		{Name: "two", Id: uuid.new(), Description: "the two tag"},
		{Name: "three", Id: uuid.new(), Description: "the three tag"}];

	$scope.hover = function(tag, elementPos) {
		$scope.selected = tag;
		$scope.selectedPos = elementPos;
	};

	$scope.hoverOut = function() {
		$scope.selected = null;
	};

	$scope.validate = function(tag) {
		var isValid = (tag.Name != 'flarn');
		if (!isValid) {
			$scope.error = true;
			$timeout(function() {
				$scope.error = false;
			}, 2500);
		}

		return isValid;
	};

}]);

The End

Thanks for reading. If you find the post and/or directive useful, please click the ‘I use it!’ button on ngmodules.

~~~jason

AngularJs native multi-file upload with progress

Problem

If you are reading this post you probably agree with me that AngularJs is pretty awesome. Because it’s so new, there are a number of gaps in the framework. My last post was inspired by filling a gap, and now again, I will present a directive and service I wrote because it doesn’t exist within the framework.

I am working on an application that requires users to upload files. As it’s 2013 and we are living in an asynchronous world, I wanted an intuitive UI where my users can upload files, along with some data and view upload progress. I’ve found a bunch of stuff that wraps jquery implementations, but nothing native, so, I guess I’ll build one.

The solution consists of a service and directive. The code presented in this post can be found on github

Design Goals

  1. Native – no dependency on external frameworks
  2. Give the parent scope a reasonable API for managing file uploads
  3. Testable

Supported Browsers

This solution relies on the XMLHttpRequest2 object which limits it’s browser support. I don’t know of any polyfills for XmlHttpRequest2 off the top, but if one exists please let me know in comments.

  • IE 10
  • FF 4
  • Chrome 7
  • Safari 5
  • Opera 12

View detailed support information on Can I use

Implementation

As usual, I started “outside-in”, that is, I had an idea of the API I wanted to work with from the parent scope (i.e. the angular module that houses the directive) and then figured out how to build it. The directive is an element that is replaced in the DOM with the appropriate html elements.

Events
These are specified as directive attributes and refer to methods on the parent scope.

onProgress(percentComplete)
Fires as the browser is uploading data
onDone(files, responseData)
Fires when the upload is complete responseData is the data returned by the server
onError(files, type, msg)
Fires when the browser encounters an error
getAdditionalData()
This method is called before the upload, and it should return a json object containing data that is to be posted to the server along with the files
Properties
These are also specifed as directive attributes and can be any valid angular expression. They will be evaluated in the parent’s scope before being used by the directive.

chooseFileButtonText

The text displayed on the button that launches the file picker
uploadUrl
The URL that will process the posted data
maxFiles
The maximum number of files to post. If the user selects more files than indicated an error of type TOO_MANY_FILES is raised
maxFileSizeMb
The maximum size per file allowed to be uploaded. If a file larger than the specified maximum is selected, the entire upload operation is aborted and an error of type MAX_SIZE_EXCEEDED is raised
autoUpload
A boolean value indicating whether the upload should start as soon as the user selects files. If this value is falsey a button is shown, which a user must click in order to upload data
uploadFileButtonText
The text displayed on the button that uploads data (only seen if autoUpload is falsey)

File upload directive

The directive is concerned with manipulating and managing the DOM on behalf of it’s parent. It is not responsible for uploading the actual files. This separation of concerns is important for a couple of reasons. First and foremost it allows us to test the upload functionality, separately from the directive. Also, it would make the directive code file big and unwieldy. Directive definitions are a bit cumbersome – there contain a mix of nested objects, closures, string literals, arrays and functions. Adding more complex functionality directly to the directive definition object would be ugly.

angular
	.module("lvl.directives.fileupload", ['lvl.services'])
	.directive('lvlFileUpload', ['uuid', 'fileUploader', function(uuid, fileUploader) {
		return {
			restrict: 'E',
			replace: true,
			scope: {
				chooseFileButtonText: '@',
				uploadFileButtonText: '@',
				uploadUrl: '@',
				maxFiles: '@',
				maxFileSizeMb: '@',
				autoUpload: '@',
				getAdditionalData: '&',
				onProgress: '&',
				onDone: '&',
				onError: '&'
			},
			template: '<span>' + 
						'<input type="file" style="opacity:0" />' +
						'<label class="lvl-choose-button" ng-click="choose()">{{chooseFileButtonText}}</label>' +
						'<button class="lvl-upload-button" ng-show="showUploadButton" ng-click="upload()">{{uploadFileButtonText}}</button>' +
					  '</span>',
			compile: function compile(tElement, tAttrs, transclude) {
				var fileInput = angular.element(tElement.children()[0]);
				var fileLabel = angular.element(tElement.children()[1]);

				if (!tAttrs.maxFiles) {
					tAttrs.maxFiles = 1;
					fileInput.removeAttr("multiple")
				} else {
					fileInput.attr("multiple", "multiple");
				}

				if (!tAttrs.maxFileSizeMb) {
					tAttrs.maxFileSizeMb = 50;
				}

				var fileId = uuid.new();
				fileInput.attr("id", fileId);
				fileLabel.attr("for", fileId);

				return function postLink(scope, el, attrs, ctl) {
					scope.files = [];
					scope.showUploadButton = false;

					el.bind('change', function(e) {
						if (!e.target.files.length) return;

						scope.files = [];
						var tooBig = [];
						if (e.target.files.length > scope.maxFiles) {
							raiseError(e.target.files, 'TOO_MANY_FILES', "Cannot upload " + e.target.files.length + " files, maxium allowed is " + scope.maxFiles);
							return;
						}

						for (var i = 0; i < scope.maxFiles; i++) {
							if (i >= e.target.files.length) break;

							var file = e.target.files[i];
							scope.files.push(file);

							if (file.size > scope.maxFileSizeMb * 1048576) {
								tooBig.push(file);
							}
						}

						if (tooBig.length > 0) {
							raiseError(tooBig, 'MAX_SIZE_EXCEEDED', "Files are larger than the specified max (" + scope.maxFileSizeMb + "MB)");
							return;
						}

						if (scope.autoUpload && scope.autoUpload.toLowerCase() == 'true') {
							scope.upload();
						} else {
							scope.$apply(function() {
								scope.showUploadButton = true;
							})
						}
					});

					scope.upload = function() {
						var data = null;
						if (scope.getAdditionalData) {
							data = scope.getAdditionalData();
						}

						if (angular.version.major <= 1 && angular.version.minor < 2 ) {
							//older versions of angular's q-service don't have a notify callback
							//pass the onProgress callback into the service
							fileUploader
								.post(scope.files, data, function(complete) { scope.onProgress({percentDone: complete}); })
								.to(scope.uploadUrl)
								.then(function(ret) {
									scope.onDone({files: ret.files, data: ret.data});
								}, function(error) {
									scope.onError({files: scope.files, type: 'UPLOAD_ERROR', msg: error});
								})
						} else {
							fileUploader
								.post(scope.files, data)
								.to(scope.uploadUrl)
								.then(function(ret) {
									scope.onDone({files: ret.files, data: ret.data});
								}, function(error) {
									scope.onError({files: scope.files, type: 'UPLOAD_ERROR', msg: error});
								},  function(progress) {
									scope.onProgress({percentDone: progress});
								});
						}

						resetFileInput();
					};

					function raiseError(files, type, msg) {
						scope.onError({files: files, type: type, msg: msg});
						resetFileInput();
					}

					function resetFileInput() {
						var parent = fileInput.parent();

						fileInput.remove();
						var input = document.createElement("input");
						var attr = document.createAttribute("type");
						attr.nodeValue = "file";
						input.setAttributeNode(attr);

						var inputId = uuid.new();
						attr = document.createAttribute("id");
						attr.nodeValue = inputId;
						input.setAttributeNode(attr);

						attr = document.createAttribute("style");
						attr.nodeValue = "opacity: 0;display:inline;width:0";
						input.setAttributeNode(attr);

						if (scope.maxFiles > 1) {
							attr = document.createAttribute("multiple");
							attr.nodeValue = "multiple";
							input.setAttributeNode(attr);
						}

						fileLabel.after(input);
						fileLabel.attr("for", inputId);

						fileInput = angular.element(input);
					}
				}
			}
		}
	}]);

Line 2: Reference the services used by this directive (included in files lvl-uuid.js and lvl-xhr-post.js).

Line 3: Inject the UUID and XHRPost services.

Line 7 - 18: Setup the directives scope (API).

Line 19 - 23: The HTML template. Note that this directive will replace the mark-up entered into the HTML file.

Line 28 - 33: Determine whether or not this instance of the directive needs to support multiple files. If it does, it adds the multiple attribute to the file input element of the template, otherwise the attribute is removed. If this instance is only meant to support a single file, don’t let the user select more than one (see the pit of success).

Line 35 - 37: Ensure there is a max file size set (default is 50MB).

Line 39 -41: Ensure the label element refers to the file input element. We do this so we can hide the file input element by setting it’s opacity to 0, and when a user clicks the label the file input is opened. This was the only way I could find to hide the stock file chooser button. If there is a way to do it with a button, please let me know in the comments or by submitting a pull request.

Line 43: The link function, where the magic happens!

Line 47: The change DOM event is triggered when files are selected by the end user.

Line 48 - 71: Validate the selected files. No-op if the user cancels, otherwise raise our onError event if any of the validation fails.

Line 73 - 79: If autoUpload is truthy then we upload the file, otherwise show the upload button.

Line 83 - 86: Collect any additional data to post to the server from the parent.

Line 88 - 110: The XHRPost service returns a deferred object from the $q service, which introduced new and relevant functionality in v1.2.0rc1 – specifically the notify method.

notify(value) – provides updates on the status of the promises execution. This may be called multiple times before the promise is either resolved or rejected.

In other words, this is the method used to let the parent know that upload progress has been made. Let’s take a closer look.

Line 91 - 98: Older versions of angular call the XHRPost service by passing in the onProgress event as a method parameter. This compensates for the fact that there is no $q.defer().notify() method.

Line 100 - 109: Newer versions rely on the $q.defer().notify() method.

Line 115 - 118: Raises an onError event and resets the file input.

Line 120 - 148: By default a file can’t be added to a file input element multiple times, and files, once selected can’t be deselected. This is problematic in the case where autoUpload is falsey, and if a validation error occurs. This method gets around this limitation by removing the input file element that has files attached to it, and replacing it with a new one. The code is straight javascript and pretty self explanatory.

Styling the buttons

Styling is easy as there are only 2 elements visible to end-users.

lvl-choose-button
This is actually a label element that triggers the file input element. It acts like a button, so I named it as such
lvl-upload-button
This is a real button element, displayed when autoUpload is falsey.

XHRPost service

As I said earlier, the post service is responsible for sending data to the server and reporting upload events to it’s consumer. I like ‘fluent’ APIs, so I wrote a mini-one for the service. If you are using the directive, you don’t need to concern yourself with this service, but it exists and can be used without the directive if you are so inclined.

post(files, data, progressCb)
  • files the files to be uploaded.
  • data optional, json data to be posted to the server.
  • progressCb optional, if your version of the $q service doesn’t support the notify() method, pass the progress callback in as a method argument.

This method returns an object with a single method that does the actual work.

to(uploadUrl)
this method posts everything to the url specified

You can see the usages above. To review, Line 91 demonstrates how to call the post method with the progressCb parameter, and Line 100 demonstrates how to use the notify method when it’s available. NOTE If you don’t pass in the progressCb parameter AND the notify method is not available you will not receive progress notifications.

var module;

try {
    module = angular.module('lvl.services');  
} catch (e) {
    module  = angular.module('lvl.services', []);
}

module.factory('fileUploader', ['$rootScope', '$q', function($rootScope, $q) {
	var svc = {
		post: function(files, data, progressCb) {

			return {
				to: function(uploadUrl)
				{
					var deferred = $q.defer()
					if (!files || !files.length) {
						deferred.reject("No files to upload");
						return;
					}

					var xhr = new XMLHttpRequest();
					xhr.upload.onprogress = function(e) {
						$rootScope.$apply (function() {
							var percentCompleted;
						    if (e.lengthComputable) {
						        percentCompleted = Math.round(e.loaded / e.total * 100);
						        if (progressCb) {
						        	progressCb(percentCompleted);
						        } else if (deferred.notify) {
							        deferred.notify(percentCompleted);
							    }
						    }
						});
					};

					xhr.onload = function(e) {
						$rootScope.$apply (function() {
							var ret = {
								files: files,
								data: angular.fromJson(xhr.responseText)
							};
							deferred.resolve(ret);
						})
					};

					xhr.upload.onerror = function(e) {
						var msg = xhr.responseText ? xhr.responseText : "An unknown error occurred posting to '" + uploadUrl + "'";
						$rootScope.$apply (function() {
							deferred.reject(msg);
						});
					}

					var formData = new FormData();

					if (data) {
						Object.keys(data).forEach(function(key) {
							formData.append(key, data[key]);
						});
					}

					for (var idx = 0; idx < files.length; idx++) {
						formData.append(files[idx].name, files[idx]);
					}

					xhr.open("POST", uploadUrl);
					xhr.send(formData);

					return deferred.promise;				
				}
			};
		}
	};

	return svc;
}]);

Line 1 - 8: I thought this was a really clever way to keep all of my services in a single namespace. When calling angular.module(namespace) angular tries to find the module in it’s internal list of loaded modules, if it doesn’t exist it throws an error. Calling angular.module(namespace, [dependent on namespaces]) creates the module in the current angular context. So, I was really psyched when I thought of this, but now, I think there is a shortcoming – all of the services/directives in the namespace need to depend on the same namespaces. I’ve not decided if this is a deal breaker or not, so I left it in. If you have ideas about this let me know in the comments.

Line 9 - 10: Setup the factory with the dependencies it needs – specifically rootScope so it can cause a digest cycle when upload events fire, and $q service so we can return promises.

Line 11: This function simply captures the parameters in a closure, and returns an object with the to function.

Line 14 - 75: This function does all the heavy lifting.

Line 16 - 20: Setup the promise object, and ensure there are files to upload. If no files are present, reject the promise and return.

Line 22 - 52: This creates the XMLHttpRequest object resolves/rejects/notifies on the promise object as appropriate. The only trickiness is on Line 28 - 30 where the service determines if it should use the callback or the promise’s notfiy method.

Line 54 - 64: Populate the data to send to the server. Different frameworks will handle the posted data differently. I’ve included a simple NodeJs in the repo to give you a sense, but server implementation details will vary. Feel free to send pull requests with server samples, or leave sample server code as comments.

Line 66 - 67: Post the data!

Line 70: Return a promise to the service’s consumer.

Testing the service

This project was complex enough to get me off my ass and start writing client-side unit tests. So, I am taking baby-steps here, and I started with Jasmine which is, I believe, the angular team’s testing framework of choice (along with the Karma test runner, which I didn’t use).

There were 2 hurdles involved in writing the unit tests

  1. Mocking service dependencies
  2. Mocking the XMLHttpRequest object

Mocking service dependencies

I spent a surprising amount of time on this, and it ended up being quite easy. The trick that I found out way too late is to include the file angular-mock.js after your angular library in the test file. This allows you to create testable modules. Once you know this trick, it’s easy to mock the dependencies with Jasmine.

Mocking the XMLHttpRequest object

Another thing I spent a lot of time on. I investigated sinon.js, which may be a nice library, but proved to be overly complicated for my needs. I ended up using Jasmine.Ajax with some minor modifications.

All of the test code is available in the repository. I don’t want to dilute this post with testing details, but I’m happy to answer questions about the tests in comments.

Using the directive

We’re done — easy peasy lemon squeezy. The repo contains an integration test which posts files to the included NodeJs server, but here’s the high level.

Html

<script src="../script/lvl-uuid.js"></script>
<script src="../script/lvl-xhr-post.js"></script>
<script src="../script/lvl-file-upload.js"></script>
<style>
   .lvl-choose-button {
      //whatever
    }

   .lvl-upload-button {
     //whatever
   }
</style>

<lvl-file-upload
   auto-upload='false'
   choose-file-button-text='Choose files'
   upload-file-button-text='Upload files' 
   upload-url='http://localhost:3000/files' 
   max-files='10'
   max-file-size-mb='5'
   get-additional-data='getData(files)'
   on-done='done(files, data)'
   on-progress='progress(percentDone)' 
   on-error='error(files, type, msg)'/>

Handle events in your controller

Script

angular
  .module('app', ['lvl.directives.fileupload'])
  .controller('ctl', ['$scope', function($scope) {
      $scope.progress = function(percentDone) {
            console.log("progress: " + percentDone + "%");
      };

      $scope.done = function(files, data) {
            console.log("upload complete");
            console.log("data: " + JSON.stringify(data));
            writeFiles(files);
      };

      $scope.getData = function(files) { 
            //this data will be sent to the server with the files
            return {msg: "from the client", date: new Date()};
      };

      $scope.error = function(files, type, msg) {
            console.log("Upload error: " + msg);
            console.log("Error type:" + type);
            writeFiles(files);
      }

      function writeFiles(files) 
      {
            console.log('Files')
            for (var i = 0; i < files.length; i++) {
                  console.log('\t' + files[i].name);
            }
      }
}]);

The end

I hope you found this post useful. As always I welcome questions, comments, observations or suggestions

~~~jason

AngularJS native drag and drop

Problem

I recently needed to add drag & drop functionality to an angularjs web application I’m working on, none of the existing directives did exactly what I needed so I built my own. In building the directive, I needed a service to create UUIDs, so I built one of them too.

The code presented in this post can be found on github.

Design Goals

  1. Provide a mechanism to respond to a user dragging one element onto another
  2. No dependency on external frameworks
  3. No html template
  4. Applied via attribute
  5. Use native HTML5 drag & drop api

Supported Browsers

  • IE 10
  • FF 3.5
  • Chrome 4
  • Safari 3.1
  • Opera 12

View detailed support information on Can I use

Implementation

One trap I wanted to avoid was having the directive do too much. All too often this is where good directives go bad; implementations get overly complicated when concerns aren’t properly separated. To achieve my primary goal to give page authors a hook for dealing with an element being dropped onto another element, I started with the callback signature I wanted

$scope.dropped = function(dragEl, dropEl) {....};

With my goal in mind, I consulted the internet to learn a bit about drag & drop & html5. I found a number of different references, the easiest to follow is the Drag and Drop Tutorial on HTML5 Rocks, and then I consulted MDN for gory details (starting with the draggable attribute).

Moving parts

My implementation relies on 2 directives and a service

lvl-draggable
Used to indicate an element that can be dragged
lvl-drop-target
Used to indicate an element can receive a draggable element and the callback function to fire when that occurs
uuid
A simple service for working with UUIDs

jQuery Compatibility

A reader, Ibes, found the following bug when jQuery was included on the same page as this directive.

Uncaught TypeError: Cannot call method 'setData' of undefined lvl-drag-drop.js:19
Uncaught TypeError: Cannot set property 'dropEffect' of undefined lvl-drag-drop.js:51
Uncaught TypeError: Cannot call method 'getData' of undefined lvl-drag-drop.js:74

I’m happy to say that it’s an issue with jQuery and you can resolve it by adding the following code when the page loads.

jQuery.event.props.push('dataTransfer');

Draggable directive

To make an element draggable we need to do a couple of things. First, the element must be decorated with the attribute draggable='true'. Next the DataTransfer needs to be populated. This object is used to shuttle data between elements during the drag operation. Since my api calls for the dragged element to be returned in the callback I will fill the DataTransfer object with the id of the element being dragged, but how best to ensure an element has an id?

Detour: the UUID service

So, this is a problem that has bugged me for a while, I come from a C# background and I use UUIDs often (GUIDs in the C# world). The .NET framework has a very simple API for generating GUIDs and I finally implemented a service that I can use similarly in the client.

Supported Operations

new()
Quickly generates a new UUID that is RFC4122 compliant
empty()
Returns an empty UUID (00000000-0000-0000-0000-000000000000)

Using stackoverflow I was able to find a suitable implementation in under 2 minutes, and it was just a matter of wrapping it up into an angular factory.

angular
.module('lvl.services',[])
.factory('uuid', function() {
    var svc = {
        new: function() {
            function _p8(s) {
                var p = (Math.random().toString(16)+"000000000").substr(2,8);
                return s ? "-" + p.substr(0,4) + "-" + p.substr(4,4) : p ;
            }
            return _p8() + _p8(true) + _p8(true) + _p8();
        },
        
        empty: function() {
          return '00000000-0000-0000-0000-000000000000';
        }
    };
    
    return svc;
});

The only trickiness here is on Line 7 (the rest of the code is explained in detail on the author’s blog). The line can be decomposed into the following parts

Math.random()
Returns a random # between 0 & 1
.toString(16)
Convert the number into base16
+”000000000″
Add 9 trailing 0s in case the random number generator doesn’t return enough digits
.substr(2, 8)
Grab 8 digits of the random hex number (after the decimal point)

Finishing the draggable directive

Ok, done with the service now we have a way to give unique ids to elements when we need to. So, to review, this directive will add the draggable='true' attribute to an element, ensure the element has an id, and populate the DataTransfer object with the element’s id.

var module = angular.module("lvl.directives.dragdrop", ['lvl.services']);

module.directive('lvlDraggable', ['$rootScope', 'uuid', function($rootScope, uuid) {
	    return {
	        restrict: 'A',
	        link: function(scope, el, attrs, controller) {
	            angular.element(el).attr("draggable", "true");

	            var id = angular.element(el).attr("id");
	            if (!id) {
	                id = uuid.new()
	                angular.element(el).attr("id", id);
	            }
	            
	            el.bind("dragstart", function(e) {
	                e.dataTransfer.setData('text', id);
	                $rootScope.$emit("LVL-DRAG-START");
	            });
	            
	            el.bind("dragend", function(e) {
	                $rootScope.$emit("LVL-DRAG-END");
	            });
	        }
    	}
	}]);

Line 1: Reference the UUID service factory

Line 3 Inject the uuid service

Line 7: Add the draggable attribute to the element

Lines 9 - 13: Ensure the element has an id

Line 15: Bind the element to the dragstart event. The callback function executes when a user first drags the element.

Line 16: Populate the DataTransfer object. One issue here is that both the HTML5 tutorial and MDN set the data by specifying the content-type (text/plain), however this breaks IE. Setting the data as ‘text’ works across all the browsers.

Line 17: Fire a custom event to notify interested parties that the user has begun dragging an element

Line 20: Bind the element to the dragend event. The callback function executes when the user stops dragging an element (regardless of whether the element was dropped or not)

Line 21: Fire a custom event to notify interested parties that the user has completed the drag operation.

So the draggable directive is straight forward, but it doesn’t do too much. If you just add the x-lvl-draggable='true' attribute to an element in your page, you can drag it around, but nothing happens.

The lvl-drop-target directive

The drop target directive is responsible for firing the callback on the parent controller when a draggable object is dropped onto the element the directive is applied to.

module.directive('lvlDropTarget', ['$rootScope', 'uuid', function($rootScope, uuid) {
	    return {
	        restrict: 'A',
	        scope: {
	            onDrop: '&'
	        },
	        link: function(scope, el, attrs, controller) {
	            var id = angular.element(el).attr("id");
	            if (!id) {
	                id = uuid.new()
	                angular.element(el).attr("id", id);
	            }
	                       
	            el.bind("dragover", function(e) {
	                if (e.preventDefault) {
	                  e.preventDefault(); // Necessary. Allows us to drop.
	              }
	              
	              if(e.stopPropagation) { 
	                e.stopPropagation(); 
	              }

	              e.dataTransfer.dropEffect = 'move';
	              return false;
	            });
	            
	            el.bind("dragenter", function(e) {
	              angular.element(e.target).addClass('lvl-over');
	            });
	            
	            el.bind("dragleave", function(e) {
	              angular.element(e.target).removeClass('lvl-over');  // this / e.target is previous target element.
	            });

	            el.bind("drop", function(e) {
	              if (e.preventDefault) {
	                e.preventDefault(); // Necessary. Allows us to drop.
	              }

	              if (e.stopPropogation) {
	                e.stopPropogation(); // Necessary. Allows us to drop.
	              }

	              var data = e.dataTransfer.getData("text");
	              var dest = document.getElementById(id);
	              var src = document.getElementById(data);
	                
	              scope.onDrop({dragEl: src, dropEl: dest});
	            });

	            $rootScope.$on("LVL-DRAG-START", function() {
	              var el = document.getElementById(id);
	              angular.element(el).addClass("lvl-target");
	            });
	            
	            $rootScope.$on("LVL-DRAG-END", function() {
	              var el = document.getElementById(id);
	              angular.element(el).removeClass("lvl-target");
	              angular.element(el).removeClass("lvl-over");
	            });
	        }
    	}
	}]);

Line 1: Inject the uuid service

Line 5: Define the drop function callback. The & operator "provides a way to execute an expression in the context of the parent scope"

Lines 8 - 12: Ensure the element has an id

Lines 14 - 25: Preventing the default browser behavior allows us to drop in FF

Lines 27 - 29: Add the css class lvl-over to the element when a dragged object is hovering over it

Lines 31 - 33: Remove the css class lvl-over

Lines 35 - 49: Fires the callback when the dragged object is dropped onto this element (the drop target). First we prevent the browser from performing default actions so we can complete the drop operation. Next we retrieve the dragged element’s id from the DataTransfer object, then we retrieve the native DOM elements involved in the operation. Finally we call the function on the parent controller passing in the native dragged element along with the drop target.

Lines 51 - 54: Handle the LVL-DRAG-START event by applying the style lvl-target to this element

Lines 56-60: Handle the LVL-DRAG-END event by removing the styles lvl-over and lvl-target from the element

Styling elements

The styling requirements are minimal, and are just necessary to provide visual cues to the user.

[draggable]
This will apply to all elements decorated with the lvl-draggable attribue (or, more precisely the draggable attribute). Setting the cursor property to move is a safe bet.
lvl-target
This will apply to all elements on the page that have been decorated with the lvl-drop-target attribute while a drag operation is in process
lvl-over
This will apply to an element decorated with the lvl-drop-target attribute when a draggable object is hovering over it

Using the directives

Alright, so, it’s all done. Here’s how to use it

Html

  <!-- include the uuid service as well as the directive -->
  <script src="script/lvl-uuid.js"></script>
  <script src="script/lvl-drag-drop.js"></script>
		
  <style>
    .lvl-over {
      border: 2px dashed black !important;
    }

    .lvl-target {
      background-color: #ddd; 
      opacity: .5;
    }

    [draggable] {
      cursor: move;
    }
  </style>

  <!-- make an element draggable -->
  <div x-lvl-draggable='true'>drag me!</div>

  <!-- create a drop target and specify a callback function>
  <div x-lvl-drop-target='true' x-on-drop='dropped(dragEl, dropEl)'>drop zone</div>

perform application logic for dropped elements in your controller.

Script

angular
  .module('myApp', ['lvl.directives.dragdrop'])
  .controller('myCtl', ['$scope', function($scope) {
    $scope.dropped = function(dragEl, dropEl) {
      // this is your application logic, do whatever makes sense
      var drag = angular.element(dragEl);
      var drop = angular.element(dropEl);

      console.log("The element " + drag.attr('id') + " has been dropped on " + drop.attr("id") + "!");
    };
  }]);

The end

I hope you find these directives useful. I welcome any questions, comments, or suggestions.

~~~jason