Friday, November 8, 2013

File uploads with Angular part 2

We left off last time with a file uploader that was functional, but not without its shortcomings. Let's continue with addressing some of the immediate issues that bothered me.

Emptying the file input

The first thing that was bothering me about the file uploader as we left it last was that while the files were uploaded as soon as they were picked in the file input element, they were left as being selected in the element. Unfortunately, the file list is readonly, so I can't just clear that. However I can replace the used file input with a new one to simulate the list being cleared out. To do this I will need to handle the use of the file-uploader attribute a little differently. If Angular attaches events to a file input element, and then I replace it with a new one, all of Angular's bindings disappear. So I'm going to use an outer element instead.
<-- Partial -->
<div file-uploader="onFileSelected($files)"></div>
I no longer have a file input element so I'll have to handle the creation of that in the directive handler:
// Directive
app.directive("fileUploader", ["$parse", function ($parse) {
    var fileInputTemplate = "<input type='file' multiple />"; // jquery template for new file input
    
    return function (scope, $elem, attrs) {
        var fn = $parse(attrs["fileUploader"]);

        var changeFunc = function(e) {
            fn(scope, { $files: e.target.files });

            // Empty the container div and append a new element based
            // on fileInputTemplate and attach the change event handler
            $elem.empty().append( 
                $(fileInputTemplate).on("change", changeFunc)
            );
        };
        
        // Append a new element based on fileInputTemplate and
        //attach the change event handler
        $elem.append(
            $(fileInputTemplate).on("change", changeFunc)
        );
    };
}]);
Since I'm attaching the file-uploader directive on a div instead of an input element, I need to create the input element manually and append it to the div. I'm also declaring a function "changeFunc" which I'm using as the change handler. This is the same as the old event handler, except that in addition to calling fn I'm emptying the container div and appending a new input element with a new change event handler on it.

Adding a file queue

It's rare that someone would want a file to upload immediately upon selection in a file input. It's more likely that a user would want to see the selected files and wait to upload until a form submission. I'm going to modify the controller to populate an array on the scope instead of immediately sending files to be uploaded:
// Controller
app.controller("ImportController", ["$scope", "$http",
    function ($scope, $http) {
        $scope.files = [];
        
        $scope.onFileSelected = function ($files) {
            for (var i = 0; i < $files.length; ++i)
                $scope.files.push($files[i]);
            $scope.$apply();
        };
    }
]);
Instead of submitting the files like before, I'm adding them to a scope variable called files. It's now trivial to add a list of filenames to the partial.
<!-- Partial -->
<div file-uploader="onFileSelected($files)"></div>
<ul>
    <li ng-repeat="file in files">
        {{file.name}}
    </li>
</ul>
<input type="button" ng-click="upload()" value="Upload"/>
I went ahead and added a submit button. Since all of the files are stored on the scope, I can use the submit button to loop through those files and post them to the server:
// Controller
app.controller("ImportController", ["$scope", "$http",
    function ($scope, $http) {
        $scope.files = [];
        
        $scope.onFileSelected = function ($files) {
            for (var i = 0; i < $files.length; ++i)
                $scope.files.push($files[i]);
            $scope.$apply();
        };

        $scope.upload = function() {
            var formData = new FormData();

            for (var i = 0; i < $scope.files.length; ++i) {
                (function () {
                    var $file = $scope.files[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]);
                    $scope.files.length = 0; // clear the array
                }

            });
        };
    }
]);
The new $scope.upload function should look familiar, it's essentially the old $scope.onFileSelected function.

Styling the file input

Right now the file input still looks like a plain old file input. This means, in Chrome, we will perpetually see the label "No file chosen" even if we have chosen files to upload. I'm going to take a page out of the jQuery File Upload book and style my button like theirs. First I'll need to modify the directive to handle a button caption:
// Directive
app.directive("fileUploader", ["$parse", function ($parse) {
    var fileInputTemplate = "<input type='file' multiple />"; // jquery template for new file input

    return function (scope, $elem, attrs) {
        var fn = $parse(attrs["fileUploader"]);

        var changeFunc = function (e) {
            fn(scope, { $files: e.target.files });

            // Empty the container div and append a new element based
            // on fileInputTemplate and attach the change event handler
            $elem.children("input").replaceWith(
                $(fileInputTemplate).on("change", changeFunc)
            );
        };

        $elem.addClass("file-uploader");
        var $button = $(document.createElement("button"));
        var text = $elem.text();
        var $span = $(document.createElement("span")).text(text);

        // Append a new element based on fileInputTemplate and
        //attach the change event handler
        $button.append($span).append(
            $(fileInputTemplate).on("change", changeFunc)
        );
        $elem.empty().append($button);
    };
}]);
There are a few significant differences between this version of the directive and the last. Note line 13. Instead of taking a shotgun approach and emptying the entire container div before appending the new input element, I'm specifically targeting the input element and replacing it. Below that, I am taking the text that was present in the container div and putting it into a span element to target it more easily with CSS. I'm then wrapping the whole thing into a button element that I can also style with CSS.

Speaking of CSS, here are the style rules I'm going to use:
// CSS
.file-uploader button {
    position: relative;
    overflow: hidden;
}

.file-uploader input {
    position: absolute;
    top: 0;
    right: 0;
    margin: 0;
    opacity: 0;
    -ms-filter: 'alpha(opacity=0)';
    font-size: 200px;
    direction: ltr;
    cursor: pointer;
}
This is a stripped down form of how jQuery File Upload styles their input button. It very cleverly mixes the opacity property of the input element (to hide it) with the overflow property of the button to mask the input element from the cursor.

Additional attributes

I'd like to add a couple more attributes for functionality. I'll start with an attribute to specify the maximum number of files and an attribute to signify whether the user would like to append to the current list of files or replace the current list of files every time they click browse.
<!-- Partial -->
<div file-uploader="files" 
     file-uploader-max-files="5" 
     file-uploader-browse-action="replace">Select files</div>
<ul>
    <li ng-repeat="file in files">
        {{file.name}}
    </li>
</ul>
<input type="button" ng-click="upload()" value="Upload"/>
You'll notice that I changed the value of "file-uploader" in the partial to bind directly to the "files" scope property instead of the "onFileSelected" scope property. In order to check for these attributes inside my directive, I'm going to need to refactor my code a bit. Currently the directive serves as nothing more than a traffic cop which redirects the "change" event of the file input to a function that is defined on the controller. I'm going to modify this pattern to bind the attribute directly to the files property on the scope and handle the population directly in the directive (no pun intended).
// Directive
app.directive("fileUploader", ["$parse", function ($parse) {
    var fileInputTemplate = "<input type='file' multiple />"; // jquery template for new file input

    return function (scope, $elem, attrs) {
        var scopePropGetter = $parse(attrs["fileUploader"]);

        var maxFiles = $parse(attrs["fileUploaderMaxFiles"])() || attrs["fileUploaderMaxFiles"];
        maxFiles = Math.max(parseInt(maxFiles || 0), 0);
        if (maxFiles == 1)
            fileInputTemplate = "<input type='file' />"; // remove the multiple attribute
        
        var browseAction = $parse(attrs["fileUploaderBrowseAction"])() || attrs["fileUploaderBrowseAction"];
        browseAction = browseAction || "replace";
        if (browseAction != "append")
            browseAction = "replace";

        var changeFunc = function (e) {
            var files = scopePropGetter(scope);
            if (browseAction == "replace")
                files.length = 0;

            var numFilesToAdd = e.target.files.length;
            if (maxFiles > 0)
                numFilesToAdd = Math.min(maxFiles - files.length, e.target.files.length);
            
            for (var i = 0; i < numFilesToAdd; ++i)
                files.push(e.target.files[i]);
            scope.$apply();

            // Empty the container div and append a new element based
            // on fileInputTemplate and attach the change event handler
            $elem.children("input").replaceWith(
                $(fileInputTemplate).on("change", changeFunc)
            );
        };

        $elem.addClass("file-uploader");
        var $button = $(document.createElement("button"));
        var text = $elem.text();
        var $span = $(document.createElement("span")).text(text);

        // Append a new element based on fileInputTemplate and
        //attach the change event handler
        $button.append($span).append(
            $(fileInputTemplate).on("change", changeFunc)
        );
        $elem.empty().append($button);
    };
}]);
// Controller
app.controller("ImportController", ["$scope", "$http",
    function ($scope, $http) {
        $scope.files = [];

        $scope.upload = function() {
            var formData = new FormData();

            for (var i = 0; i < $scope.files.length; ++i) {
                (function () {
                    var $file = $scope.files[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]);
                    $scope.files.length = 0; // clear the array
                }

            });
        };
    }
]);
Let's start with the controller. The only thing we changed is we removed the "onFileSelected" function from the scope. As far the directive, there were quite a few changes made. Lines 8-16 deal with reading the values out of the new attributes and making sure they are valid. Lines 19-29 contain the new code that was taken from the old "onFileSelected" function. It handles the population of the whatever scope variable is bound to the directive. Lines 19-25 decide how many files to add based on the maximum file limit as well as clearing out the existing files if "replace" is selected. Lines 27-29 handle appending the new files onto the scope property.

If you've followed along, you should now have a functioning file uploader. It isn't quite feature complete, but I will continue adding to it in future posts.

No comments:

Post a Comment