Friday, November 8, 2013

File uploads with Angular

Uploading a file is a common operation, and in designing an Angular-based site the odds are high that one will eventually need to add support for it. I reached the point in one of my projects where I needed to add support. I've always used the very nice jQuery File Upload plugin, but since I am fairly new to Angular, I want to use this as a chance to explore the behind-the-scenes of creating directives and manually creating HTTP file posts.

Before jumping into any code I'll explain some assumptions. I'll be adding code to three different areas -- the partial, the controller, and the directive. The main javascript file will start off looking something like this:
var app = angular.module("myApp", []); // declare this module, named "myApp". Matches ng-app attribute.
We will assume that my partial file is somehow loaded through some sort of routing.

Now that we have that out of the way, let's jump in.

Creating the directive

First, I'm going to create a directive that I can use for my input tag. According to Angular's documentation, "Directives are markers on a DOM element (such as an attribute, element name, or CSS class) that tell AngularJS's HTML compiler ($compile) to attach a specified behavior to that DOM element or even transform the DOM element and its children." This will allow me to add functionality to an HTML element just by adding an attribute. I'll add a simple attribute to a file input tag like so:
<!-- Partial-->
<input multiple="" file-uploader="" type="file" />
Now that I've added the "file-uploader" attribute, I need to create a directive that's going to pick up on that and add some functionality:
// Directive
app.directive("fileUploader", [function() {
    return function() {
        console.log("It is working.");
    };
}]);
When I load the page I see the console message. Success! Now on to adding more functionality to the directive.

Expanding the directive

// Directive
app.directive("fileUploader", [function() {
    return function (scope, $elem, attrs) {
        $elem.on("change", function(e) {
            console.log("File changed");
        });
    };
}]);
A little more productive, I now have a jquery event handler for when a new file is picked. Say I want the developer to be able to specify his/her own callback function for when a file is picked. Just like any other binding, it will be assigned as a property on the scope:
// Controller
app.controller("ImportController", ["$scope",
    function ($scope) {
        $scope.onFileSelected = function() {
            console.log("onFileSelected called");
        };
    }
]);
<!-- Partial -->
<input multiple="" file-uploader="onFileSelected()" type="file" />
This still won't work, however. Nothing in our application is actually binding this method to the change event of the file input. If you run this as-is, you'll still get the "File changed" log message, but nothing that says "onFileSelected called". So, I need to modify the directive function:
// Directive
app.directive("fileUploader", ["$parse", function ($parse) {
    return function (scope, $elem, attrs) {
        // fn will be the callback function
        // injected into the directive attribute
        var fn = $parse(attrs["fileUploader"]);

        $elem.on("change", function(e) {
            fn(scope);
        });
    };
}]);
I've modified the directive function to take the string passed into the file-uploader directive and parse out the scope variable assigned to it. This should return a function, as it should be the custom callback function assigned to the scope. Once assigned to fn, it can be called, passing in the scope as the first parameter. Now when the a file is selected, the onFileSelected function I've added to the scope is called. Next, I'd like to actually get some file information back to the custom event handler. This is located inside the event object [todo -- on what browsers]. I'll make modifications to pass back this information.
// Directive
app.directive("fileUploader", ["$parse", function ($parse) {
    return function (scope, $elem, attrs) {
        var fn = $parse(attrs["fileUploader"]);
        $elem.on("change", function (e) {
            fn(scope, { $files: e.target.files });
        });
    };
}]);
// Controller
app.controller("ImportController", ["$scope",
    function ($scope) {
        $scope.onFileSelected = function($files) {
            console.log("onFileSelected called");
            console.log($files);
        };
    }
]);
<!-- Partial -->
<input multiple="" file-uploader="onFileSelected($files)" type="file" />
Inside the directive function, I've added a second parameter to the fn() call. The first parameter sends the scope, and the second parameter will extend the scope by adding/replacing properties on the scope. This is useful if you'd like to pass extra data to the callback function, but you don't want to make any lasting changes to the scope that other parts of the application could see. I'm using the property name "$files", with the dollar sign signifying that this is not a normal scope variable. Inside the controller, I've modified the onFileSelected function to take one parameter which will be assigned in the partial. Inside the partial, I've modified the file-uploader attribute to pass $files to the onFileSelected function. This completes the circle, and ensures that the callback has the data it needs.

Sending the data to the server

Now I'm going to loop through all of the files that were passed through the $files parameter and put together an HTTP request:
// Controller
app.controller("ImportController", ["$scope", "$http",
    function ($scope, $http) {
        $scope.onFileSelected = function ($files) {
            console.log("onFileSelected called");
            console.log($files);
            for (var i = 0; i < $files.length; ++i) {
                (function() {
                    var $file = $files.item(i);

                    var formData = new FormData();
                    formData.append("file", $file, $file.name); // test

                    var options = {
                        method: "POST",
                        url: "/api/Import",
                        data: formData,
                        headers: { "Content-Type": undefined },
                        transformRequest: angular.identity
                    };

                    $http(options);
                })();

            }
        };
    }
]);
Here I've added a loop to go through all of the attached files and issue an HTTP post for each one. I'm taking advantage of the FormData function, as it makes sending complex HTTP request incredibly easy. Unfortunately, FormData is not supported in versions of Internet Explorer prior to 10. I will add support for some older versions of IE in a future blog post. After building the FormData object, I'm adding it to the HTTP options. I'm also setting the Content-Type header to undefined and the transformRequest property to angular.identity, a bit of Angular magic to parse our FormData object. The controller as it stands will issue one request for every file, which is a bit overkill. It can be modified to lump all files together:
// Controller
app.controller("ImportController", ["$scope", "$http",
    function ($scope, $http) {
        $scope.onFileSelected = function ($files) {
            var formData = new FormData();
            
            for (var i = 0; i < $files.length; ++i) {
                (function() {
                    var $file = $files.item(i);
                    formData.append("file", $file, $file.name);
                })();
            }

            var options = {
                method: "POST",
                url: "/api/Import",
                data: formData,
                headers: { "Content-Type": undefined },
                transformRequest: angular.identity
            };

            $http(options);
        };
    }
]);
The backend I have this running against will spit out a list of relative paths that the uploaded files can be reached at, so I'm going to add some logging for the response.
// Controller
app.controller("ImportController", ["$scope", "$http",
    function ($scope, $http) {
        $scope.onFileSelected = function ($files) {
            var formData = new FormData();
            
            for (var i = 0; i < $files.length; ++i) {
                (function() {
                    var $file = $files.item(i);
                    formData.append("file", $file, $file.name);
                })();
            }

            var options = {
                method: "POST",
                url: "/api/Import",
                data: formData,
                headers: { "Content-Type": undefined },
                transformRequest: angular.identity
            };

            $http(options).success(function(data, status) {
                if (status != 200)
                    console.log("Error uploading files");
                else {
                    for (var i = 0; i < data.length; ++i)
                        console.log(data[i]);
                }
                    
            });
        };
    }
]);
It's not very pretty, but it functions. In a future post I'll talk about how to add more functionality and dress things up a bit. I eventually want to integrate jquery file uploader, but this is a nice exercise in learning all of the nuts and bolts behind Angular file uploads.

For more information, check out our website.

No comments:

Post a Comment