From 97cb4ee4f7416288859e3449df5fb768184aec22 Mon Sep 17 00:00:00 2001 From: Aidas Klimas Date: Sun, 19 Jan 2014 16:58:42 +0200 Subject: [PATCH 1/6] initial commit --- .coveralls.yml | 2 + .gitignore | 13 ++++ .travis.yml | 12 ++++ CHANGELOG.md | 37 ++++++++++ Gruntfile.js | 94 +++++++++++++++++++++++++ LICENSE | 20 ++++++ README.md | 60 ++++++++++++++++ bower.json | 13 ++++ karma.conf.js | 117 +++++++++++++++++++++++++++++++ package.json | 44 ++++++++++++ src/api.js | 4 ++ src/events.js | 53 ++++++++++++++ src/export.js | 21 ++++++ src/flow.js | 2 + src/flow.prefix | 1 + src/flow.suffix | 1 + src/helpers.js | 59 ++++++++++++++++ test/FakeXMLHttpRequestUpload.js | 95 +++++++++++++++++++++++++ test/eventsSpec.js | 70 ++++++++++++++++++ 19 files changed, 718 insertions(+) create mode 100644 .coveralls.yml create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 Gruntfile.js create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bower.json create mode 100644 karma.conf.js create mode 100644 package.json create mode 100644 src/api.js create mode 100644 src/events.js create mode 100644 src/export.js create mode 100644 src/flow.js create mode 100644 src/flow.prefix create mode 100644 src/flow.suffix create mode 100644 src/helpers.js create mode 100644 test/FakeXMLHttpRequestUpload.js create mode 100644 test/eventsSpec.js diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 00000000..6b1cfdde --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1,2 @@ +service_name: travis-pro +repo_token: W4HtBtmljYK3MDFAMo2QGMNbohQtFqgP9 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7efdbfd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*~ + +# Node +/build +/node_modules +/bower_components + +# Editors +.idea + +# Tests +sauce_connect.log +/coverage \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..d210d69e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: node_js +node_js: + - 0.1 +env: + global: + - SAUCE_USERNAME=aidaskk + - SAUCE_ACCESS_KEY=6e96b47e-6665-4f69-beaa-085e5d5b6b9b +before_script: + - sh -e /etc/init.d/xvfb start + - npm install --quiet -g grunt-cli karma + - npm install +script: grunt travis diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..60bc6629 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# 2.0.0 + +## Features + + - All code follows Google javascript style guide + - Target url can be provided with query string + - Events **fileAdded** and **filesAdded** can prevent file from being added to $.files list by + returning false. Custom validators can be ran here. + - **ResumableFile.getType()** and **ResumableFile.getExtension()** helper methods added. Can be + used for custom validation. + - **fileProgress** and **progress** events are always asynchronous. + - **ResumableFile.pause()** and **ResumableFile.resume()** methods for single file pausing and + resuming. + - **filesSubmitted** event added. Can be used to start file upload. Event is thrown then files are + added to queue. + - **progressCallbacksInterval** parameter added. Minimum interval between callbacks execution in + milliseconds. + - **averageSpeed** and **currentSpeed** parameters added for `ResumableFile`. These params + accuracy can be adjusted with `speedSmoothingFactor` and `progressCallbacksInterval` parameters. + - **timeRemaining** method added for `ResumableFile`. Returns remaining time to upload in seconds. Accuracy is based on average speed. + - **sizeUploaded** method added for `ResumableFile`. Returns size uploaded in bytes. + - **singleFile** parameter added. Then enabled, uploaded file will replace current one. + +## Breaking Changes + - **Resumable** was renamed to **Flow** + - **ResumableFile.fileName** parameter renamed to **ResumableFile.name** + - **Resumable.getOpt** method dropped, use Resumable.opts parameter instead if needed. + - **Resumable.maxFiles**, **Resumable.minFileSize**, **Resumable.maxFileSize**, + **Resumable.fileType** validators dropped. Use **fileAdded** and **filesAdded** events for + custom validation. + - **fileProgress** and **progress** events are not thrown on ResumableFile.abort() and ResumableFile.cancel() methods execution. + - **cancel** event was removed. Event was always called after **Resumable.cancel()** function. + - **fileAdded**, **filesAdded** events are thrown before file is added to upload queue. This means + that calling **Resumable.upload()** method in these events will not start uploading current + files. To start upload use **filesSubmitted** event instead. + - **throttleProgressCallbacks** parameter was replaced with **progressCallbacksInterval** and it + is now measured in milliseconds. \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 00000000..e7dd36e0 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,94 @@ +module.exports = function(grunt) { + var browsers = grunt.option('browsers') && grunt.option('browsers').split(','); + // Project configuration. + grunt.initConfig({ + pkg: grunt.file.readJSON('package.json'), + uglify: { + options: { + banner: '/*! <%= pkg.name %> <%= pkg.version %> */\n' + }, + build: { + src: 'build/flow.js', + dest: 'build/flow.min.js' + } + }, + concat: { + build: { + files: { + 'build/flow.js': [ + 'src/flow.prefix', + 'src/flow.js', + 'src/events.js', + 'src/helpers.js', + 'src/api.js', + 'src/export.js', + 'src/flow.suffix' + ] + } + } + }, + coveralls: { + options: { + coverage_dir: 'coverage/' + } + }, + karma: { + options: { + configFile: 'karma.conf.js', + browsers: browsers || ['Chrome'] + }, + watch: { + autoWatch: true, + background: false + }, + singleRun: { + singleRun: true + }, + coverage: { + singleRun: true, + reporters: ['progress', 'coverage'], + preprocessors: { + 'src/*.js': 'coverage' + }, + coverageReporter: { + type: "lcov", + dir: "coverage/" + } + }, + travis: { + singleRun: true, + reporters: ['progress', 'coverage'], + preprocessors: { + 'src/*.js': 'coverage' + }, + coverageReporter: { + type: "lcov", + dir: "coverage/" + }, + // Buggiest browser + browsers: browsers || ['sl_chorme'], + // global config for SauceLabs + sauceLabs: { + username: grunt.option('sauce-username') || process.env.SAUCE_USERNAME, + accessKey: grunt.option('sauce-access-key') || process.env.SAUCE_ACCESS_KEY, + startConnect: grunt.option('sauce-local') ? false : true , + testName: 'flow.js' + } + } + } + }); + + // Loading dependencies + for (var key in grunt.file.readJSON("package.json").devDependencies) { + if (key !== "grunt" && key.indexOf("grunt") === 0) grunt.loadNpmTasks(key); + } + + grunt.registerTask('default', ['test']); + // Testing + grunt.registerTask('test', ['karma:continuous']); + grunt.registerTask('watch', ['karma:watch']); + // Release + grunt.registerTask('build', ['concat', 'uglify']); + // Development + grunt.registerTask('travis', ["karma:travis", "coveralls"]); +}; \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..fb166662 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014, Aidas Klimas + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..1d794086 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +## Flow.js + +Flow.js is a JavaScript library providing multiple simultaneous, stable and resumable uploads via the HTML5 File API. + +Flow.js does not have any external dependencies other than the `HTML5 File API`. Currently, this means that support is limited to Firefox 4+, Chrome 11+, Safari 6+ and Internet Explorer 10+. + +Library follows simple file upload protocol, which can be easily implemented in any language. One of this protocol design goals is to make it simple for mobile and browser clients to use it. Server side just exposes common api, which can be easily adopted and used as public api. + +The library is designed to introduce fault-tolerance into the upload of large files through HTTP. This is done by splitting each file into small chunks. Then, whenever the upload of a chunk fails, uploading is retried until the procedure completes. This allows uploads to automatically resume uploading after a network connection is lost either locally or to the server. Additionally, it allows for users to pause, resume and even recover uploads without losing state because only the currently uploading chunks will be aborted, not the entire upload. + + +Examples are available in the `examples/` folder. Please push your own as Markdown to help document the project. + +## Contribution + +To ensure consistency throughout the source code, keep these rules in mind as you are working: + +* All features or bug fixes must be tested by one or more specs. + +* We love functions and closures and, whenever possible, prefer them over objects. + +* We follow the rules contained in [Google's JavaScript Style Guide](http://google-styleguide.googlecode.com/svn/trunk/javascriptguide.xml) with an exception we wrap all code at 100 characters. + + +## Installing development dependencies +1. To clone your Github repository, run: + + git clone git@github.com:/flow.js.git + +2. To go to the Flow.js directory, run: + + cd flow.js + +3. To add node.js dependencies + + npm install + +## Testing + +Our unit and integration tests are written with Jasmine and executed with Karma. To run all of the +tests on Chrome run: + + grunt karma:watch + +Or choose other browser + + grunt karma:watch --browsers=Firefox,Chrome + +Browsers should be comma separated and case sensitive. + +To re-run tests just change any source or test file. + +Automated tests is running after every commit at travis-ci. + +### Running test on sauceLabs + +1. Connect to sauce labs https://saucelabs.com/docs/connect +2. `grunt test --sauce-local=true --sauce-username=**** --sauce-access-key=***` + +other browsers can be used with `--browsers` flag, available browsers: sl_opera,sl_iphone,sl_safari,sl_ie10,sl_chorme,sl_firefox diff --git a/bower.json b/bower.json new file mode 100644 index 00000000..ee6a19e6 --- /dev/null +++ b/bower.json @@ -0,0 +1,13 @@ +{ + "name": "flow.js", + "version": "3.0.0-snapshot", + "main": "src/flow.js", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests", + "samples" + ] +} diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 00000000..544dc917 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,117 @@ +module.exports = function(config) { + config.set({ + + // base path, that will be used to resolve files and exclude + basePath: '', + + + // frameworks to use + frameworks: ['jasmine'], + + + // list of files / patterns to load in the browser + files: [ + 'node_modules/sinon/pkg/sinon-1.7.3.js', + 'test/FakeXMLHttpRequestUpload.js', + 'src/flow.js', + 'src/events.js', + 'src/helpers.js', + 'src/api.js', + 'test/*Spec.js' + ], + + + // list of files to exclude + exclude: [ + + ], + + + // test results reporter to use + // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' + reporters: ['progress'], + + + // web server port + port: 9876, + + + // enable / disable colors in the output (reporters and logs) + colors: true, + + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: ['Chrome'], + + + // If browser does not capture in given timeout [ms], kill it + captureTimeout: 60000, + + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false, + + + // define SL browsers + customLaunchers: { + sl_opera: { + base: 'SauceLabs', + browserName: "opera", + platform: 'Windows 7', + version: "12" + }, + sl_iphone: { + base: 'SauceLabs', + browserName: 'iphone', + platform: 'OS X 10.8', + version: '6' + }, + sl_safari: { + base: 'SauceLabs', + browserName: 'safari', + platform: 'OS X 10.8', + version: '6' + }, + sl_ie10: { + base: 'SauceLabs', + browserName: 'internet explorer', + platform: 'Windows 8', + version: '10' + }, + sl_chorme: { + base: 'SauceLabs', + browserName: 'chrome', + platform: 'Windows 7' + }, + sl_firefox: { + base: 'SauceLabs', + browserName: 'firefox', + platform: 'Windows 7', + version: '21' + } + }, + + + coverageReporter: { + type : 'html', + dir : 'coverage/' + } + }); +}; diff --git a/package.json b/package.json new file mode 100644 index 00000000..077d0751 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "flow.js", + "version": "3.0.0-snapshot", + "description": "Resumable file upload protocol. Flow.js library implements html5 file upload and provides stable, fault tolerant and resumable uploads.", + "scripts": { + "test": "grunt test" + }, + "repository": { + "type": "git", + "url": "git://github.com/flowjs/flow.js.git" + }, + "keywords": [ + "flow.js", + "flow", + "file upload", + "resumable upload", + "chunk upload", + "html5 upload", + "javascript upload", + "upload" + ], + "author": "Aidas Klimas", + "license": "MIT", + "readmeFilename": "README.md", + "bugs": { + "url": "https://github.com/flowjs/flow.js/issues" + }, + "devDependencies": { + "grunt": "*", + "grunt-contrib-uglify": "*", + "karma-chrome-launcher": "*", + "karma-firefox-launcher": "*", + "karma-ie-launcher": "*", + "karma-jasmine": "~0.1", + "karma": "0.10.1", + "grunt-karma": "0.6.1", + "grunt-saucelabs": "~4.0.4", + "karma-sauce-launcher": "~0.1.0", + "sinon": "~1.7.3", + "karma-coverage": "0.1.0", + "grunt-karma-coveralls": "~2.0.2", + "grunt-contrib-concat": "~0.3.0" + } +} diff --git a/src/api.js b/src/api.js new file mode 100644 index 00000000..67d66219 --- /dev/null +++ b/src/api.js @@ -0,0 +1,4 @@ +extend(flow, { + 'extend': extend, + 'each': each +}); \ No newline at end of file diff --git a/src/events.js b/src/events.js new file mode 100644 index 00000000..b70483af --- /dev/null +++ b/src/events.js @@ -0,0 +1,53 @@ +var events = {}; +/** + * Set a callback for an event + * @function + * @param {string} event + * @param {Function} callback + */ +function on(event, callback) { + if (!events.hasOwnProperty(event)) { + events[event] = []; + } + events[event].push(callback); +} + +/** + * Fire an event + * @function + * @param {string} event event name + * @param {...} [args] arguments of a callback + * @return {bool} value is false if at least one of the event handlers returned false. + * Otherwise returned value will be true. + */ +function fire(event, args) { + // in firefox `arguments` is an object, not array + args = Array.prototype.slice.call(arguments, 1); + var preventDefault = false; + if (events.hasOwnProperty(event)) { + each(events[event], function (callback) { + preventDefault = callback.apply(null, args) === false || preventDefault; + }); + } + return !preventDefault; +} + +/** + * Remove event callback + * @function + * @param {string} [event] removes all events if not specified + * @param {Function} [fn] removes all callbacks of event if not specified + */ +function off(event, fn) { + if (event !== undefined) { + if (fn !== undefined) { + if (events.hasOwnProperty(event)) { + arrayRemove(events[event], fn); + } + } else { + delete events[event]; + } + } else { + events = {}; + } +} \ No newline at end of file diff --git a/src/export.js b/src/export.js new file mode 100644 index 00000000..454eb14c --- /dev/null +++ b/src/export.js @@ -0,0 +1,21 @@ +if (typeof module === "object" && module && typeof module.exports === "object") { + // Expose Flow as module.exports in loaders that implement the Node + // module pattern (including browserify). Do not create the global, since + // the user will be storing it themselves locally, and globals are frowned + // upon in the Node module world. + module.exports = flow; +} else { + // Otherwise expose Flow to the global object as usual + window.flow = flow; + + // Register as a named AMD module, since Flow can be concatenated with other + // files that may use define, but not via a proper concatenation script that + // understands anonymous AMD modules. A named AMD is safest and most robust + // way to register. Lowercase flow is used because AMD module names are + // derived from file names, and Flow is normally delivered in a lowercase + // file name. Do this after creating the global so that if an AMD module wants + // to call noConflict to hide this version of Flow, it will work. + if (typeof define === "function" && define.amd) { + define("flow", [], function () { return flow; }); + } +} \ No newline at end of file diff --git a/src/flow.js b/src/flow.js new file mode 100644 index 00000000..57823f90 --- /dev/null +++ b/src/flow.js @@ -0,0 +1,2 @@ +/** @name flow */ +var flow = window.flow || {}; \ No newline at end of file diff --git a/src/flow.prefix b/src/flow.prefix new file mode 100644 index 00000000..5a097696 --- /dev/null +++ b/src/flow.prefix @@ -0,0 +1 @@ +(function(window, undefined) {'use strict'; \ No newline at end of file diff --git a/src/flow.suffix b/src/flow.suffix new file mode 100644 index 00000000..c8a62421 --- /dev/null +++ b/src/flow.suffix @@ -0,0 +1 @@ +})(window); \ No newline at end of file diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 00000000..174813bf --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,59 @@ + +/** + * Extends the destination object `dst` by copying all of the properties from + * the `src` object(s) to `dst`. You can specify multiple `src` objects. + * @function + * @param {Object} dst Destination object. + * @param {...Object} src Source object(s). + * @returns {Object} Reference to `dst`. + */ +function extend(dst, src) { + each(arguments, function(obj) { + if (obj !== dst) { + each(obj, function(value, key){ + dst[key] = value; + }); + } + }); + return dst; +} + +/** + * Iterate each element of an object + * @function + * @param {Array|Object} obj object or an array to iterate + * @param {Function} callback first argument is a value and second is a key. + * @param {Object=} context Object to become context (`this`) for the iterator function. + */ +function each(obj, callback, context) { + if (!obj) { + return ; + } + var key; + // Is Array? + if (typeof(obj.length) !== 'undefined') { + for (key = 0; key < obj.length; key++) { + if (callback.call(context, obj[key], key) === false) { + return ; + } + } + } else { + for (key in obj) { + if (obj.hasOwnProperty(key) && callback.call(context, obj[key], key) === false) { + return ; + } + } + } +} + +/** + * Remove value from array + * @param array + * @param value + */ +function arrayRemove(array, value) { + var index = array.indexOf(value); + if (index > -1) { + array.splice(index, 1); + } +} \ No newline at end of file diff --git a/test/FakeXMLHttpRequestUpload.js b/test/FakeXMLHttpRequestUpload.js new file mode 100644 index 00000000..4b336b6a --- /dev/null +++ b/test/FakeXMLHttpRequestUpload.js @@ -0,0 +1,95 @@ +/** + * Extends sinon.FakeXMLHttpRequest with upload functionality. + * Property `upload` to FakeXMLHttpRequest added. It works with the following events: + * "loadstart", "progress", "abort", "error", "load", "loadend" + * Events are instance of FakeXMLHttpRequestProgressEvent and has following properties: + * loaded - loaded request size. + * total - total request size. + * lengthComputable - boolean indicates if loaded and total attributes were computed. + * Helper method `progress`, such as `sinon.FakeXMLHttpRequest.respond(200...)`, was added. + * + */ +(function() { + function FakeXMLHttpRequestUpload() { + var xhr = this; + var events = ["loadstart", "progress", "abort", "error", "load", "loadend"]; + + function addEventListener(eventName) { + xhr.addEventListener(eventName, function (event) { + var listener = xhr["on" + eventName]; + + if (listener && typeof listener == "function") { + listener(event); + } + }); + } + + for (var i = events.length - 1; i >= 0; i--) { + addEventListener(events[i]); + } + } + + sinon.extend(FakeXMLHttpRequestUpload.prototype, sinon.EventTarget); + + function FakeXMLHttpRequestProgressEvent( + type, bubbles, cancelable, target, loaded, total, lengthComputable + ) { + this.initEvent(type, bubbles, cancelable, target); + this.initProgressEvent(loaded || 0, total || 0, lengthComputable || false); + } + + sinon.extend(FakeXMLHttpRequestProgressEvent.prototype, sinon.Event.prototype, { + initProgressEvent: function initProgressEvent(loaded, total, lengthComputable) { + this.loaded = loaded; + this.total = total; + this.lengthComputable = lengthComputable; + } + }); + + var originalFakeXMLHttpRequest = sinon.FakeXMLHttpRequest; + + function FakeXMLHttpRequestWithUpload() { + sinon.extend(this, new originalFakeXMLHttpRequest()); + this.upload = new FakeXMLHttpRequestUpload(); + if (typeof FakeXMLHttpRequestWithUpload.onCreate == "function") { + FakeXMLHttpRequestWithUpload.onCreate(this); + } + } + + sinon.extend(FakeXMLHttpRequestWithUpload.prototype, originalFakeXMLHttpRequest.prototype, { + send: function send(data) { + originalFakeXMLHttpRequest.prototype.send.call(this, data); + this.upload.dispatchEvent( + new FakeXMLHttpRequestProgressEvent("loadstart", false, false, this) + ); + }, + /** + * Report upload progress + * @name sinon.FakeXMLHttpRequest.progress + * @function + * @param loaded + * @param total + * @param lengthComputable + */ + progress: function progress(loaded, total, lengthComputable) { + this.upload.dispatchEvent( + new FakeXMLHttpRequestProgressEvent( + "progress", false, false, this, loaded, total, lengthComputable) + ); + }, + respond: function respond(status, headers, body) { + originalFakeXMLHttpRequest.prototype.respond.call(this, status, headers, body); + this.upload.dispatchEvent( + new FakeXMLHttpRequestProgressEvent("load", false, false, this) + ); + this.upload.dispatchEvent( + new FakeXMLHttpRequestProgressEvent("loadend", false, false, this) + ); + } + }); + + sinon.FakeXMLHttpRequest = FakeXMLHttpRequestWithUpload; + sinon.FakeXMLHttpRequestProgressEvent = FakeXMLHttpRequestProgressEvent; + sinon.FakeXMLHttpRequestWithUpload = FakeXMLHttpRequestWithUpload; + sinon.originalFakeXMLHttpRequest = originalFakeXMLHttpRequest; +})(); \ No newline at end of file diff --git a/test/eventsSpec.js b/test/eventsSpec.js new file mode 100644 index 00000000..11f6c392 --- /dev/null +++ b/test/eventsSpec.js @@ -0,0 +1,70 @@ +describe('events', function() { + it('should catch an event', function() { + var callback = jasmine.createSpy(); + on('test', callback); + fire('test'); + expect(callback).toHaveBeenCalled(); + }); + + it('should pass some arguments', function() { + var argumentOne = 123; + var argumentTwo = "dqw"; + var callback = jasmine.createSpy(); + on('test', callback); + fire('test', argumentOne, argumentTwo); + expect(callback).toHaveBeenCalledWith(argumentOne, argumentTwo); + }); + + it('should return event value', function() { + on('false', function () { + return false; + }); + on('true', function () { + + }); + expect(fire('true')).toBeTruthy(); + expect(fire('not existant')).toBeTruthy(); + expect(fire('false')).toBeFalsy(); + }); + + it('should return multiple event value', function() { + on('maybe', function () { + return false; + }); + on('maybe', function () { + + }); + expect(fire('maybe')).toBeFalsy(); + // opposite order + on('maybe2', function () { + + }); + on('maybe2', function () { + return false; + }); + expect(fire('maybe2')).toBeFalsy(); + }); + + describe('off', function () { + var event; + beforeEach(function () { + event = jasmine.createSpy('event'); + on('event', event); + }); + it('should remove event', function () { + off('event'); + fire('event'); + expect(event).not.toHaveBeenCalled(); + }); + it('should remove specific event', function () { + off('event', event); + fire('event'); + expect(event).not.toHaveBeenCalled(); + }); + it('should remove all events', function () { + off(); + fire('event'); + expect(event).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file From 35993ea05fade0c220e228debe3d9bfd8de16bf6 Mon Sep 17 00:00:00 2001 From: Aidas Klimas Date: Sun, 2 Feb 2014 14:56:09 +0200 Subject: [PATCH 2/6] feat: first thoughts --- karma.conf.js | 3 +- src/events.js | 101 +++++++++------- src/export.js | 9 +- src/flow.js | 111 +++++++++++++++++- src/helpers.js | 7 +- src/sliceFile.js | 17 +++ test/eventsSpec.js | 37 +++--- test/flowSpec.js | 89 ++++++++++++++ test/flowUploadSpec.js | 38 ++++++ .../{ => helpers}/FakeXMLHttpRequestUpload.js | 0 test/helpers/fileMock.js | 15 +++ 11 files changed, 359 insertions(+), 68 deletions(-) create mode 100644 src/sliceFile.js create mode 100644 test/flowSpec.js create mode 100644 test/flowUploadSpec.js rename test/{ => helpers}/FakeXMLHttpRequestUpload.js (100%) create mode 100644 test/helpers/fileMock.js diff --git a/karma.conf.js b/karma.conf.js index 544dc917..017bab48 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -12,7 +12,8 @@ module.exports = function(config) { // list of files / patterns to load in the browser files: [ 'node_modules/sinon/pkg/sinon-1.7.3.js', - 'test/FakeXMLHttpRequestUpload.js', + 'test/helpers/*.js', + 'src/sliceFile.js', 'src/flow.js', 'src/events.js', 'src/helpers.js', diff --git a/src/events.js b/src/events.js index b70483af..56e8622b 100644 --- a/src/events.js +++ b/src/events.js @@ -1,53 +1,66 @@ -var events = {}; -/** - * Set a callback for an event - * @function - * @param {string} event - * @param {Function} callback - */ -function on(event, callback) { - if (!events.hasOwnProperty(event)) { - events[event] = []; +function events() { + var events = {}; + + /** + * Set a callback for an event + * @function + * @param {string} event + * @param {Function} callback + */ + function on(event, callback) { + if (!events.hasOwnProperty(event)) { + events[event] = []; + } + events[event].push(callback); } - events[event].push(callback); -} -/** - * Fire an event - * @function - * @param {string} event event name - * @param {...} [args] arguments of a callback - * @return {bool} value is false if at least one of the event handlers returned false. - * Otherwise returned value will be true. - */ -function fire(event, args) { - // in firefox `arguments` is an object, not array - args = Array.prototype.slice.call(arguments, 1); - var preventDefault = false; - if (events.hasOwnProperty(event)) { - each(events[event], function (callback) { - preventDefault = callback.apply(null, args) === false || preventDefault; - }); + /** + * Fire an event + * @function + * @param {string} name event name + * @param {...*} [args] arguments of a callback + * @return {Object} value is false if at least one of the event handlers returned false. + * Otherwise returned value will be true. + */ + function fire(name, args) { + var event = { + name: name, + preventDefault: function() { + event.defaultPrevented = true; + }, + defaultPrevented: false + }; + if (events.hasOwnProperty(name)) { + var callArgs = [event].concat(Array.prototype.slice.call(arguments, 1)); + each(events[name], function (callback) { + callback.apply(null, callArgs); + }); + } + return event; } - return !preventDefault; -} -/** - * Remove event callback - * @function - * @param {string} [event] removes all events if not specified - * @param {Function} [fn] removes all callbacks of event if not specified - */ -function off(event, fn) { - if (event !== undefined) { - if (fn !== undefined) { - if (events.hasOwnProperty(event)) { - arrayRemove(events[event], fn); + /** + * Remove event callback + * @function + * @param {string} [event] removes all events if not specified + * @param {Function} [fn] removes all callbacks of event if not specified + */ + function off(event, fn) { + if (event !== undefined) { + if (fn !== undefined) { + if (events.hasOwnProperty(event)) { + arrayRemove(events[event], fn); + } + } else { + delete events[event]; } } else { - delete events[event]; + events = {}; } - } else { - events = {}; + } + return { + on: on, + off: off, + fire: fire } } \ No newline at end of file diff --git a/src/export.js b/src/export.js index 454eb14c..5d1dd799 100644 --- a/src/export.js +++ b/src/export.js @@ -1,4 +1,4 @@ -if (typeof module === "object" && module && typeof module.exports === "object") { +if (typeof module === 'object' && module && typeof module.exports === 'object') { // Expose Flow as module.exports in loaders that implement the Node // module pattern (including browserify). Do not create the global, since // the user will be storing it themselves locally, and globals are frowned @@ -13,9 +13,8 @@ if (typeof module === "object" && module && typeof module.exports === "object") // understands anonymous AMD modules. A named AMD is safest and most robust // way to register. Lowercase flow is used because AMD module names are // derived from file names, and Flow is normally delivered in a lowercase - // file name. Do this after creating the global so that if an AMD module wants - // to call noConflict to hide this version of Flow, it will work. - if (typeof define === "function" && define.amd) { - define("flow", [], function () { return flow; }); + // file name. + if (typeof define === 'function' && define.amd) { + define('flow', [], function () { return flow; }); } } \ No newline at end of file diff --git a/src/flow.js b/src/flow.js index 57823f90..2ce0d8aa 100644 --- a/src/flow.js +++ b/src/flow.js @@ -1,2 +1,109 @@ -/** @name flow */ -var flow = window.flow || {}; \ No newline at end of file +/** + * File uploader + * @name flow + * @param {Object} $options + */ +function flow($options) { + + /** + * @name flow.options + * @type {Object} + */ + $options = extend({ + fileConstructor: noop, + sliceConstructor: noop, + defaultChunkSize: 41943040//4Mb + }, $options); + + var event = events(); + var $fire = event.fire; + var $chunkSize = $options.defaultChunkSize; + + /** + * @name flow.files + * @type {Array} + */ + var $files = []; + + var $flow = { + 'files': $files, + 'options': $options, + + 'on': event.on, + 'off': event.off, + 'addFile': addFile, + 'addFiles': addFiles, + 'upload': upload + }; + return $flow; + + /** + * @param {File} file + */ + function fileConstructor(file) { + var obj = { + file: file, + name: file.name, + size: file.size + }; + $options.fileConstructor.call(obj, $flow); + return obj; + } + + + /** + * Construct and validate file + * @name flow.addFile + * @param {File|Blob} file + */ + function addFile(file) { + return addFiles([file]); + } + + /** + * Construct and validate file list + * @name flow.addFiles + * @param {FileList|Array.} fileList + */ + function addFiles(fileList) { + var list = []; + each(fileList, function (file) { + list.push(fileConstructor(file)); + }); + if ($fire('validateFileList', list).defaultPrevented) { + return []; + } + each(list, function (file) { + if (!$fire('validateFile', file).defaultPrevented) { + $files.push(file); + } + }); + $fire('filesAdded', list); + return list; + } + + function upload() { + uploadNext(); + } + + function uploadNext() { + var requestSize = 0; + var data = []; + var file; + while (requestSize < $chunkSize && (file = next())) { + var slice = sliceFile(file, $chunkSize - requestSize); + $options.sliceConstructor.call(slice, $flow); + requestSize += slice.size; + data.push(file); + } + } + + function next(i) { + for (i = i || 0; i < $files.length; i++) { + if (!$files[i].completed) { + return $files[i]; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/helpers.js b/src/helpers.js index 174813bf..8c715cdd 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -56,4 +56,9 @@ function arrayRemove(array, value) { if (index > -1) { array.splice(index, 1); } -} \ No newline at end of file +} + +/** + * A function that performs no operations. + */ +function noop() {} \ No newline at end of file diff --git a/src/sliceFile.js b/src/sliceFile.js new file mode 100644 index 00000000..b39c8ed4 --- /dev/null +++ b/src/sliceFile.js @@ -0,0 +1,17 @@ +var sliceFn = Blob.prototype.slice || Blob.prototype.mozSlice || Blob.prototype.webkitSlice; +/** + * Creates file slice with params + * @param file + * @param size + * @returns {Object} + */ +function sliceFile(file, size) { + return { + blob: sliceFn.call(file.file, file.offset, size), + data: { + name: file.name, + size: file.size, + offset: file.offset + } + } +} \ No newline at end of file diff --git a/test/eventsSpec.js b/test/eventsSpec.js index 11f6c392..f723fdc0 100644 --- a/test/eventsSpec.js +++ b/test/eventsSpec.js @@ -1,4 +1,11 @@ describe('events', function() { + var on, off, fire; + beforeEach(function () { + var event = events(); + on = event.on; + off = event.off; + fire = event.fire; + }); it('should catch an event', function() { var callback = jasmine.createSpy(); on('test', callback); @@ -9,40 +16,40 @@ describe('events', function() { it('should pass some arguments', function() { var argumentOne = 123; var argumentTwo = "dqw"; - var callback = jasmine.createSpy(); + var callback = jasmine.createSpy('test'); on('test', callback); - fire('test', argumentOne, argumentTwo); - expect(callback).toHaveBeenCalledWith(argumentOne, argumentTwo); + var event = fire('test', argumentOne, argumentTwo); + expect(callback).toHaveBeenCalledWith(event, argumentOne, argumentTwo); }); it('should return event value', function() { - on('false', function () { - return false; + on('prevent', function (event) { + event.preventDefault(); }); - on('true', function () { + on('noop', function () { }); - expect(fire('true')).toBeTruthy(); - expect(fire('not existant')).toBeTruthy(); - expect(fire('false')).toBeFalsy(); + expect(fire('noop').defaultPrevented).toBeFalsy(); + expect(fire('not existant').defaultPrevented).toBeFalsy(); + expect(fire('prevent').defaultPrevented).toBeTruthy(); }); it('should return multiple event value', function() { - on('maybe', function () { - return false; + on('maybe', function (event) { + event.preventDefault(); }); on('maybe', function () { }); - expect(fire('maybe')).toBeFalsy(); + expect(fire('maybe').defaultPrevented).toBeTruthy(); // opposite order on('maybe2', function () { }); - on('maybe2', function () { - return false; + on('maybe2', function (event) { + event.preventDefault(); }); - expect(fire('maybe2')).toBeFalsy(); + expect(fire('maybe2').defaultPrevented).toBeTruthy(); }); describe('off', function () { diff --git a/test/flowSpec.js b/test/flowSpec.js new file mode 100644 index 00000000..63883ece --- /dev/null +++ b/test/flowSpec.js @@ -0,0 +1,89 @@ +describe('flow', function () { + /** @type {flow} */ + var flowObj; + + beforeEach(function () { + flowObj = flow(); + }); + + it('should create flow object with event handling', function () { + expect(flowObj.on).toBeDefined(); + expect(flowObj.off).toBeDefined(); + }); + + + describe('addFiles', function () { + function addFiles() { + flowObj.addFiles([ + fileMock([], 'one'), + fileMock([], 'two') + ]); + } + + it('should add new files', function () { + addFiles(); + expect(flowObj.files.length).toBe(2); + }); + + it('should use custom constructor', function () { + var i = 0; + flowObj.options.fileConstructor = function () { + this.id = i++; + }; + addFiles(); + expect(flowObj.files.length).toBe(2); + expect(flowObj.files[0].id).toBe(0); + expect(flowObj.files[1].id).toBe(1); + }); + + it('should validate all added files', function () { + var validate = jasmine.createSpy('validate files'); + flowObj.on('validateFileList', validate); + addFiles(); + expect(validate.callCount).toBe(1); + expect(flowObj.files.length).toBe(2); + }); + + it('should reject all added files', function () { + var validate = jasmine.createSpy('validate files').andCallFake(function (event, file) { + event.preventDefault(); + }); + flowObj.on('validateFileList', validate); + addFiles(); + expect(validate.callCount).toBe(1); + expect(flowObj.files.length).toBe(0); + }); + + it('should validate every file', function () { + var validate = jasmine.createSpy('validate file').andCallFake(function (event, file) { + if (file.name == 'one') { + event.preventDefault(); + } + }); + flowObj.on('validateFile', validate); + addFiles(); + expect(validate.callCount).toBe(2); + expect(flowObj.files.length).toBe(1); + expect(flowObj.files[0].name).toBe('two'); + }); + + it('should notify then files are added to queue', function () { + var notify = jasmine.createSpy('files added callback') + flowObj.on('filesAdded', notify); + addFiles(); + expect(notify.callCount).toBe(1); + expect(notify.mostRecentCall.args[1].length).toBe(2); + }); + + }); + + + describe('addFile', function () { + it('should behave same as addFiles', function () { + var file = fileMock([], 'file'); + flowObj.addFile(file); + expect(flowObj.files.length).toBe(1); + }); + }); + +}); \ No newline at end of file diff --git a/test/flowUploadSpec.js b/test/flowUploadSpec.js new file mode 100644 index 00000000..60ce170c --- /dev/null +++ b/test/flowUploadSpec.js @@ -0,0 +1,38 @@ +describe('flow.upload', function () { + + /** @type {flow} */ + var flowObj; + + /** + * @type {FakeXMLHttpRequest[]} + */ + var requests = []; + + /** + * @type {FakeXMLHttpRequest} + */ + var xhr; + + beforeEach(function () { + flowObj = flow(); + flowObj.addFiles([ + fileMock([], 'one'), + fileMock([], 'two') + ]); + + requests = []; + xhr = sinon.useFakeXMLHttpRequest(); + xhr.onCreate = function (xhr) { + requests.push(xhr); + }; + }); + + afterEach(function () { + xhr.restore(); + }); + + it('should start upload', function () { + flowObj.upload(); + expect(requests.length).toBe(0); + }); +}); \ No newline at end of file diff --git a/test/FakeXMLHttpRequestUpload.js b/test/helpers/FakeXMLHttpRequestUpload.js similarity index 100% rename from test/FakeXMLHttpRequestUpload.js rename to test/helpers/FakeXMLHttpRequestUpload.js diff --git a/test/helpers/fileMock.js b/test/helpers/fileMock.js new file mode 100644 index 00000000..0d5d8bba --- /dev/null +++ b/test/helpers/fileMock.js @@ -0,0 +1,15 @@ +/** + * Html5 File mock + * @param {Array.} data + * @param {string} name + * @param {Object} properties + * @returns {Blob} + */ +function fileMock(data, name, properties) { + var b = new Blob(data, properties || {}); + b.name = name; + if (properties && properties.lastModified) { + b.lastModified = properties.lastModified; + } + return b; +} \ No newline at end of file From 56d8ebb8e9a90a4b62f6f1b2fd69e27d12304566 Mon Sep 17 00:00:00 2001 From: Aidas Klimas Date: Sat, 12 Apr 2014 16:40:38 +0300 Subject: [PATCH 3/6] feat: one step thurther --- THOUGHTS.md | 31 +++++ karma.conf.js | 5 +- package.json | 18 +-- src/events.js | 66 ---------- src/flow.js | 157 ++++++++++++++--------- src/formData.js | 25 ++++ src/helpers.js | 8 +- src/http.js | 101 +++++++++++++++ src/sliceFile.js | 9 +- test/eventsSpec.js | 77 ----------- test/flowSpec.js | 127 ++++++++++++------ test/flowUploadSpec.js | 48 ++++++- test/formDataSpec.js | 39 ++++++ test/helpers/FakeXMLHttpRequestUpload.js | 95 -------------- test/helpers/fakeFormData.js | 13 ++ test/helpers/fileMock.js | 3 + test/httpSpec.js | 78 +++++++++++ 17 files changed, 532 insertions(+), 368 deletions(-) create mode 100644 THOUGHTS.md delete mode 100644 src/events.js create mode 100644 src/formData.js create mode 100644 src/http.js delete mode 100644 test/eventsSpec.js create mode 100644 test/formDataSpec.js delete mode 100644 test/helpers/FakeXMLHttpRequestUpload.js create mode 100644 test/helpers/fakeFormData.js create mode 100644 test/httpSpec.js diff --git a/THOUGHTS.md b/THOUGHTS.md new file mode 100644 index 00000000..173a5a05 --- /dev/null +++ b/THOUGHTS.md @@ -0,0 +1,31 @@ +Resumable upload solutions: + 1. On client side generate file id. + Lookup in localStorage for file offset. + If found, continue to upload from it. + + Pros: + No unnecessary requests + Cons: + Information might be expired + No duplicate file uploads + + 2. On client side generate file id. + Request file information on server-side + + Pros: + Build it server side validation + Cons: + Complexity + + 3. Server generates file id with GET request + Cons: + Complexity + More requests + + Best solution - no 1, with some requirements: + To take advantage of server-side validation we need: + - Async file validation. + - Solution, always invalidate all added files and add them then possible + - Async chunk validation. + - TBD + diff --git a/karma.conf.js b/karma.conf.js index 017bab48..69baf5bb 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -11,11 +11,12 @@ module.exports = function(config) { // list of files / patterns to load in the browser files: [ - 'node_modules/sinon/pkg/sinon-1.7.3.js', + 'node_modules/sinon/pkg/sinon.js', 'test/helpers/*.js', + 'src/http.js', 'src/sliceFile.js', 'src/flow.js', - 'src/events.js', + 'src/formData.js', 'src/helpers.js', 'src/api.js', 'test/*Spec.js' diff --git a/package.json b/package.json index 077d0751..1c90f263 100644 --- a/package.json +++ b/package.json @@ -31,14 +31,14 @@ "karma-chrome-launcher": "*", "karma-firefox-launcher": "*", "karma-ie-launcher": "*", - "karma-jasmine": "~0.1", - "karma": "0.10.1", - "grunt-karma": "0.6.1", - "grunt-saucelabs": "~4.0.4", - "karma-sauce-launcher": "~0.1.0", - "sinon": "~1.7.3", - "karma-coverage": "0.1.0", - "grunt-karma-coveralls": "~2.0.2", - "grunt-contrib-concat": "~0.3.0" + "karma-jasmine": "*", + "karma": "~0", + "grunt-karma": "*", + "grunt-saucelabs": "*", + "karma-sauce-launcher": "*", + "sinon": "~1", + "karma-coverage": "*", + "grunt-karma-coveralls": "*", + "grunt-contrib-concat": "*" } } diff --git a/src/events.js b/src/events.js deleted file mode 100644 index 56e8622b..00000000 --- a/src/events.js +++ /dev/null @@ -1,66 +0,0 @@ -function events() { - var events = {}; - - /** - * Set a callback for an event - * @function - * @param {string} event - * @param {Function} callback - */ - function on(event, callback) { - if (!events.hasOwnProperty(event)) { - events[event] = []; - } - events[event].push(callback); - } - - /** - * Fire an event - * @function - * @param {string} name event name - * @param {...*} [args] arguments of a callback - * @return {Object} value is false if at least one of the event handlers returned false. - * Otherwise returned value will be true. - */ - function fire(name, args) { - var event = { - name: name, - preventDefault: function() { - event.defaultPrevented = true; - }, - defaultPrevented: false - }; - if (events.hasOwnProperty(name)) { - var callArgs = [event].concat(Array.prototype.slice.call(arguments, 1)); - each(events[name], function (callback) { - callback.apply(null, callArgs); - }); - } - return event; - } - - /** - * Remove event callback - * @function - * @param {string} [event] removes all events if not specified - * @param {Function} [fn] removes all callbacks of event if not specified - */ - function off(event, fn) { - if (event !== undefined) { - if (fn !== undefined) { - if (events.hasOwnProperty(event)) { - arrayRemove(events[event], fn); - } - } else { - delete events[event]; - } - } else { - events = {}; - } - } - return { - on: on, - off: off, - fire: fire - } -} \ No newline at end of file diff --git a/src/flow.js b/src/flow.js index 2ce0d8aa..dbf02de4 100644 --- a/src/flow.js +++ b/src/flow.js @@ -11,29 +11,100 @@ function flow($options) { */ $options = extend({ fileConstructor: noop, - sliceConstructor: noop, + onFilesAdded: noop, + filterFileList: identity, defaultChunkSize: 41943040//4Mb }, $options); - var event = events(); - var $fire = event.fire; var $chunkSize = $options.defaultChunkSize; /** * @name flow.files + * @readonly * @type {Array} */ var $files = []; + /** + * Files to be uploaded + * @name flow.pendingFiles + * @readonly + * @type {Array} + */ + var $pendingFiles = []; + + /** + * id - file map + * @name flow.map + * @readonly + * @type {Object} + */ + var $map = {}; + var $flow = { + 'files': $files, + 'pendingFiles': $pendingFiles, + + /** + * Indicates if file si uploading + * @name flow.isUploading + * @type {boolean} + */ + 'isUploading': false, + 'options': $options, + 'map': $map, + + /** + * GEt file by id + * @name flow.getById + * @param id + * @returns {Object|null} + */ + 'getById': function getById(id) { + return $map[id] || null; + }, + + /** + * Construct and validate file. + * Shortcut for addFiles + * @name flow.addFile + * @param {Blob|File} file + */ + 'addFile': function addFile(file) { + $flow.addFiles([file]); + }, + + /** + * Construct and validate file list + * @name flow.addFiles + * @param {FileList|Array.} fileList + */ + 'addFiles': function addFiles(fileList) { + var list = [].concat(fileList).map(fileConstructor); + list = $options.filterFileList(list); + each(list, function (file) { + if (file && !$flow.getById(file.id)) { + $files.push(file); + $pendingFiles.push(file); + $map[file.id] = file; + } + }); + $options.onFilesAdded(list); + return list; + }, - 'on': event.on, - 'off': event.off, - 'addFile': addFile, - 'addFiles': addFiles, - 'upload': upload + 'upload': function upload() { + if ($flow.isUploading) { + return ; + } + if (!$pendingFiles.length) { + return ; + } + $flow.isUploading = true; + openConnection(); + } }; return $flow; @@ -44,66 +115,24 @@ function flow($options) { var obj = { file: file, name: file.name, - size: file.size + size: file.size, + relativePath: file.relativePath || file.webkitRelativePath || file.name, + extension: file.name.substr((~-file.name.lastIndexOf(".") >>> 0) + 2).toLowerCase(), + + inProgress: false, + completed: false, + progress: 0 }; + obj.id = obj.size + '-' + obj.relativePath; $options.fileConstructor.call(obj, $flow); return obj; } - - /** - * Construct and validate file - * @name flow.addFile - * @param {File|Blob} file - */ - function addFile(file) { - return addFiles([file]); - } - - /** - * Construct and validate file list - * @name flow.addFiles - * @param {FileList|Array.} fileList - */ - function addFiles(fileList) { - var list = []; - each(fileList, function (file) { - list.push(fileConstructor(file)); - }); - if ($fire('validateFileList', list).defaultPrevented) { - return []; - } - each(list, function (file) { - if (!$fire('validateFile', file).defaultPrevented) { - $files.push(file); - } - }); - $fire('filesAdded', list); - return list; - } - - function upload() { - uploadNext(); - } - - function uploadNext() { - var requestSize = 0; - var data = []; - var file; - while (requestSize < $chunkSize && (file = next())) { - var slice = sliceFile(file, $chunkSize - requestSize); - $options.sliceConstructor.call(slice, $flow); - requestSize += slice.size; - data.push(file); - } - } - - function next(i) { - for (i = i || 0; i < $files.length; i++) { - if (!$files[i].completed) { - return $files[i]; - } - } - return null; + function openConnection() { +// var request = requestConstructor(); + http({ + method: 'POST', + data: new FormData + }); } } \ No newline at end of file diff --git a/src/formData.js b/src/formData.js new file mode 100644 index 00000000..1300ea51 --- /dev/null +++ b/src/formData.js @@ -0,0 +1,25 @@ +function toFormData(value) { + var form = new FormData(); + iterate('', value, true); + return form; + + function iterate(name, value, first) { + if (Array.isArray(value)) { + each(value, function (value, key) { + iterate(name + '[' + key + ']', value); + }); + } else if (value instanceof Blob) { + form.append(name, value, value.name); + } else if (value === Object(value)) { + each(value, function (value, key) { + if (first) { + iterate(key, value); + } else { + iterate(name + '[' + key + ']', value); + } + }); + } else { + form.append(name, value); + } + } +} \ No newline at end of file diff --git a/src/helpers.js b/src/helpers.js index 8c715cdd..103a2306 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -61,4 +61,10 @@ function arrayRemove(array, value) { /** * A function that performs no operations. */ -function noop() {} \ No newline at end of file +function noop() {} + + +/** + * A function that returns first argument. + */ +function identity(i) {return i;} \ No newline at end of file diff --git a/src/http.js b/src/http.js new file mode 100644 index 00000000..c8208bfb --- /dev/null +++ b/src/http.js @@ -0,0 +1,101 @@ +/** + * @param {object} config Object describing the request to be made and how it should be + * processed. The object has following properties: + * + * - **method** – `{string}` – HTTP method (e.g. 'GET', 'POST', etc) + * - **url** – `{string}` – Absolute or relative URL of the resource that is being requested. + * - **params** – `{Object.}` – Map of strings or objects which will be turned + * to `?key1=value1&key2=value2` after the url. + * - **data** – `{string|Object}` – Data to be sent as the request message data. + * - **headers** – `{Object}` – Map of strings representing HTTP headers to send to the server. + * - **timeout** – `{number}` – timeout in milliseconds + * - **withCredentials** - `{boolean}` - whether to to set the `withCredentials` flag on the + * XHR object. See [requests with credentials]https://developer.mozilla.org/en/http_access_control#section_5 for more information. + * - **responseType** - `{string}` - see + * [requestType](https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#responseType). + * + * - **responseType** - `{string}` - see + */ +function http(config) { + config = extend({ + method: 'GET', + data: null, + onProgress: noop, + onComplete: noop + }, config); + config.headers = extend({'Accept': 'application/json, text/plain, */*'}, config.headers); + + var url = buildUrl(config.url, config.params); + + var method = config.method.toUpperCase(); + + var xhr = new XMLHttpRequest(); + xhr.open(method, url); + + each(config.headers, function (value, key) { + xhr.setRequestHeader(key, value); + }); + + if (config.withCredentials) { + xhr.withCredentials = true; + } + + xhr.onreadystatechange = function () { + // onreadystatechange might get called multiple times with readyState === 4 on mobile webkit caused by + // xhrs that are resolved while the app is in the background (see #5426). + if (xhr && xhr.readyState == 4) { + var response = xhr.response; + var status = xhr.status; + // fix status code when it is 0 (0 status is undocumented). + // Occurs when accessing file resources. + // On Android 4.1 stock browser it occurs while retrieving files from application cache. + if (status === 0 && response) { + status = 200; + } + config.onComplete({ + status: status, + response: response, + xhr: xhr + }); + xhr = null; + } + }; + + xhr.upload.addEventListener('progress', function (event) { + if (event.lengthComputable) { + config.onProgress(event.loaded, event.total, event, xhr); + } + }, false); + xhr.send(config.data); + + return { + xhr: xhr + }; +} + +function buildUrl(url, query) { + if (!query) { + return url; + } + var params = []; + each(query, function (v, k) { + params.push([encodeUriQuery(k), encodeUriQuery(v)].join('=')); + }); + if(url.indexOf('?') < 0) { + url += '?'; + } else { + url += '&'; + } + return url + params.join('&'); +} + +function encodeUriQuery(val) { + return encodeURIComponent(val). + replace(/%40/gi, '@'). + replace(/%3A/gi, ':'). + replace(/%24/g, '$'). + replace(/%2C/gi, ','). + replace(/%5B/gi, '['). + replace(/%5D/gi, ']'). + replace(/%20/g, '+'); +} \ No newline at end of file diff --git a/src/sliceFile.js b/src/sliceFile.js index b39c8ed4..dd09f062 100644 --- a/src/sliceFile.js +++ b/src/sliceFile.js @@ -6,12 +6,5 @@ var sliceFn = Blob.prototype.slice || Blob.prototype.mozSlice || Blob.prototype. * @returns {Object} */ function sliceFile(file, size) { - return { - blob: sliceFn.call(file.file, file.offset, size), - data: { - name: file.name, - size: file.size, - offset: file.offset - } - } + return sliceFn.call(file.file, file.offset, size); } \ No newline at end of file diff --git a/test/eventsSpec.js b/test/eventsSpec.js deleted file mode 100644 index f723fdc0..00000000 --- a/test/eventsSpec.js +++ /dev/null @@ -1,77 +0,0 @@ -describe('events', function() { - var on, off, fire; - beforeEach(function () { - var event = events(); - on = event.on; - off = event.off; - fire = event.fire; - }); - it('should catch an event', function() { - var callback = jasmine.createSpy(); - on('test', callback); - fire('test'); - expect(callback).toHaveBeenCalled(); - }); - - it('should pass some arguments', function() { - var argumentOne = 123; - var argumentTwo = "dqw"; - var callback = jasmine.createSpy('test'); - on('test', callback); - var event = fire('test', argumentOne, argumentTwo); - expect(callback).toHaveBeenCalledWith(event, argumentOne, argumentTwo); - }); - - it('should return event value', function() { - on('prevent', function (event) { - event.preventDefault(); - }); - on('noop', function () { - - }); - expect(fire('noop').defaultPrevented).toBeFalsy(); - expect(fire('not existant').defaultPrevented).toBeFalsy(); - expect(fire('prevent').defaultPrevented).toBeTruthy(); - }); - - it('should return multiple event value', function() { - on('maybe', function (event) { - event.preventDefault(); - }); - on('maybe', function () { - - }); - expect(fire('maybe').defaultPrevented).toBeTruthy(); - // opposite order - on('maybe2', function () { - - }); - on('maybe2', function (event) { - event.preventDefault(); - }); - expect(fire('maybe2').defaultPrevented).toBeTruthy(); - }); - - describe('off', function () { - var event; - beforeEach(function () { - event = jasmine.createSpy('event'); - on('event', event); - }); - it('should remove event', function () { - off('event'); - fire('event'); - expect(event).not.toHaveBeenCalled(); - }); - it('should remove specific event', function () { - off('event', event); - fire('event'); - expect(event).not.toHaveBeenCalled(); - }); - it('should remove all events', function () { - off(); - fire('event'); - expect(event).not.toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file diff --git a/test/flowSpec.js b/test/flowSpec.js index 63883ece..36fa82e0 100644 --- a/test/flowSpec.js +++ b/test/flowSpec.js @@ -6,12 +6,6 @@ describe('flow', function () { flowObj = flow(); }); - it('should create flow object with event handling', function () { - expect(flowObj.on).toBeDefined(); - expect(flowObj.off).toBeDefined(); - }); - - describe('addFiles', function () { function addFiles() { flowObj.addFiles([ @@ -36,54 +30,109 @@ describe('flow', function () { expect(flowObj.files[1].id).toBe(1); }); - it('should validate all added files', function () { - var validate = jasmine.createSpy('validate files'); - flowObj.on('validateFileList', validate); - addFiles(); - expect(validate.callCount).toBe(1); - expect(flowObj.files.length).toBe(2); - }); + describe('filterFileList', function () { + var filterFileList; + function createFilter(cb) { + filterFileList = jasmine.createSpy('filter files').andCallFake(cb); + flowObj.options.filterFileList = filterFileList; + } + it('should reject all added files', function () { + createFilter(function () { + return []; + }) + addFiles(); + expect(filterFileList.callCount).toBe(1); + expect(flowObj.files.length).toBe(0); + }); - it('should reject all added files', function () { - var validate = jasmine.createSpy('validate files').andCallFake(function (event, file) { - event.preventDefault(); + it('should validate every file', function () { + createFilter(function (files) { + var list = []; + each(files, function (file) { + if (file.name != 'one') { + list.push(file); + } + }); + return list; + }); + addFiles(); + expect(filterFileList.callCount).toBe(1); + expect(flowObj.files.length).toBe(1); + expect(flowObj.files[0].name).toBe('two'); }); - flowObj.on('validateFileList', validate); - addFiles(); - expect(validate.callCount).toBe(1); - expect(flowObj.files.length).toBe(0); - }); - it('should validate every file', function () { - var validate = jasmine.createSpy('validate file').andCallFake(function (event, file) { - if (file.name == 'one') { - event.preventDefault(); - } + it('should validate every file in short syntax', function () { + createFilter(function (files) { + return files.map(function (file) { + return file.name != 'one' && file; + }); + }); + addFiles(); + expect(filterFileList.callCount).toBe(1); + expect(flowObj.files.length).toBe(1); + expect(flowObj.files[0].name).toBe('two'); }); - flowObj.on('validateFile', validate); - addFiles(); - expect(validate.callCount).toBe(2); - expect(flowObj.files.length).toBe(1); - expect(flowObj.files[0].name).toBe('two'); }); + it('should notify then files are added to queue', function () { - var notify = jasmine.createSpy('files added callback') - flowObj.on('filesAdded', notify); + var notify = jasmine.createSpy('files added callback'); + flowObj.options.onFilesAdded = notify; addFiles(); expect(notify.callCount).toBe(1); - expect(notify.mostRecentCall.args[1].length).toBe(2); + expect(notify.mostRecentCall.args[0].length).toBe(2); }); }); - - describe('addFile', function () { - it('should behave same as addFiles', function () { - var file = fileMock([], 'file'); - flowObj.addFile(file); + describe('every file should have id by default', function () { + beforeEach(function () { + flowObj.addFile( + fileMock(['abc'], 'one', { + relativePath: 'home/one' + }) + ); + }); + it('should generate id', function () { + expect(flowObj.files[0].id).toBe('3-home/one'); + }); + it('should reject same files', function () { + flowObj.addFile( + fileMock(['abc'], 'one', { + relativePath: 'home/one' + }) + ); expect(flowObj.files.length).toBe(1); }); + describe('getById', function () { + it('should get file by id', function () { + expect(flowObj.getById('3-home/one')).toBe(flowObj.files[0]) + }); + it('should return null if file was not found', function () { + expect(flowObj.getById('fqwf')).toBe(null); + }); + }); + }); + + describe('extension', function () { + function fileExtension(name) { + flowObj.addFile( + fileMock([], name) + ); + return flowObj.files[0].extension; + } + it('should get extension', function() { + expect(fileExtension('image.jpg')).toBe('jpg'); + }); + it('should get extension for empty file name', function() { + expect(fileExtension('')).toBe(''); + }); + it('should get extension for file without it', function() { + expect(fileExtension('image')).toBe(''); + }); + it('should get extension in lowercase', function() { + expect(fileExtension('.dwq.dq.wd.qdw.E')).toBe('e'); + }); }); }); \ No newline at end of file diff --git a/test/flowUploadSpec.js b/test/flowUploadSpec.js index 60ce170c..7462154a 100644 --- a/test/flowUploadSpec.js +++ b/test/flowUploadSpec.js @@ -15,10 +15,6 @@ describe('flow.upload', function () { beforeEach(function () { flowObj = flow(); - flowObj.addFiles([ - fileMock([], 'one'), - fileMock([], 'two') - ]); requests = []; xhr = sinon.useFakeXMLHttpRequest(); @@ -31,8 +27,46 @@ describe('flow.upload', function () { xhr.restore(); }); - it('should start upload', function () { - flowObj.upload(); - expect(requests.length).toBe(0); + describe('isUploading', function () { + beforeEach(function () { + flowObj.addFile(fileMock([], 'one')); + }); + it('should set isUploading to true', function () { + expect(flowObj.isUploading).toBeFalsy(); + flowObj.upload(); + expect(flowObj.isUploading).toBeTruthy(); + }); + }); + + describe('single file', function () { + beforeEach(function () { + flowObj.addFile(fileMock([], 'one')); + }); + it('should upload single file', function () { + flowObj.upload(); + expect(requests.length).toBe(1); + }); + it('should upload once', function () { + flowObj.upload(); + flowObj.upload(); + expect(requests.length).toBe(1); + }); + it('should post as formdata', function () { + flowObj.upload(); + expect(requests[0].method).toBe('POST'); + expect(requests[0].requestBody instanceof FormData).toBeTruthy(); + }); + }); + + describe('file params', function () { + var requestBody; + beforeEach(function () { + flowObj.addFile(fileMock([], 'one')); + flowObj.upload(); + requestBody = requests[0].requestBody; + }); + it('should have default params', function () { + expect(requestBody).toBeTruthy(); + }); }); }); \ No newline at end of file diff --git a/test/formDataSpec.js b/test/formDataSpec.js new file mode 100644 index 00000000..37e91f5b --- /dev/null +++ b/test/formDataSpec.js @@ -0,0 +1,39 @@ +describe('toFormData', function () { + + it('should return form data object', function () { + expect(toFormData({}) instanceof FormData).toBeTruthy(); + }); + + it('should format objects', function () { + expect(toFormData({'a':1, 'b':'c'}).keys).toEqual(['a', 'b']); + }); + + it('should format deep objects', function () { + expect(toFormData({'a': {'d': 5}, 'b':'c'}).keys).toEqual(['a[d]', 'b']); + }); + + it('should format array', function () { + expect(toFormData(['a', 1]).keys).toEqual(['[0]','[1]']); + }); + + it('should format all', function () { + expect(toFormData({ + a: [ + { + 'a': 5, + 'b': [1,2,3] + }, + { + 'a': 'c' + } + ] + }).keys).toEqual([ + 'a[0][a]', + 'a[0][b][0]', + 'a[0][b][1]', + 'a[0][b][2]', + 'a[1][a]' + ]); + }); + +}); \ No newline at end of file diff --git a/test/helpers/FakeXMLHttpRequestUpload.js b/test/helpers/FakeXMLHttpRequestUpload.js deleted file mode 100644 index 4b336b6a..00000000 --- a/test/helpers/FakeXMLHttpRequestUpload.js +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Extends sinon.FakeXMLHttpRequest with upload functionality. - * Property `upload` to FakeXMLHttpRequest added. It works with the following events: - * "loadstart", "progress", "abort", "error", "load", "loadend" - * Events are instance of FakeXMLHttpRequestProgressEvent and has following properties: - * loaded - loaded request size. - * total - total request size. - * lengthComputable - boolean indicates if loaded and total attributes were computed. - * Helper method `progress`, such as `sinon.FakeXMLHttpRequest.respond(200...)`, was added. - * - */ -(function() { - function FakeXMLHttpRequestUpload() { - var xhr = this; - var events = ["loadstart", "progress", "abort", "error", "load", "loadend"]; - - function addEventListener(eventName) { - xhr.addEventListener(eventName, function (event) { - var listener = xhr["on" + eventName]; - - if (listener && typeof listener == "function") { - listener(event); - } - }); - } - - for (var i = events.length - 1; i >= 0; i--) { - addEventListener(events[i]); - } - } - - sinon.extend(FakeXMLHttpRequestUpload.prototype, sinon.EventTarget); - - function FakeXMLHttpRequestProgressEvent( - type, bubbles, cancelable, target, loaded, total, lengthComputable - ) { - this.initEvent(type, bubbles, cancelable, target); - this.initProgressEvent(loaded || 0, total || 0, lengthComputable || false); - } - - sinon.extend(FakeXMLHttpRequestProgressEvent.prototype, sinon.Event.prototype, { - initProgressEvent: function initProgressEvent(loaded, total, lengthComputable) { - this.loaded = loaded; - this.total = total; - this.lengthComputable = lengthComputable; - } - }); - - var originalFakeXMLHttpRequest = sinon.FakeXMLHttpRequest; - - function FakeXMLHttpRequestWithUpload() { - sinon.extend(this, new originalFakeXMLHttpRequest()); - this.upload = new FakeXMLHttpRequestUpload(); - if (typeof FakeXMLHttpRequestWithUpload.onCreate == "function") { - FakeXMLHttpRequestWithUpload.onCreate(this); - } - } - - sinon.extend(FakeXMLHttpRequestWithUpload.prototype, originalFakeXMLHttpRequest.prototype, { - send: function send(data) { - originalFakeXMLHttpRequest.prototype.send.call(this, data); - this.upload.dispatchEvent( - new FakeXMLHttpRequestProgressEvent("loadstart", false, false, this) - ); - }, - /** - * Report upload progress - * @name sinon.FakeXMLHttpRequest.progress - * @function - * @param loaded - * @param total - * @param lengthComputable - */ - progress: function progress(loaded, total, lengthComputable) { - this.upload.dispatchEvent( - new FakeXMLHttpRequestProgressEvent( - "progress", false, false, this, loaded, total, lengthComputable) - ); - }, - respond: function respond(status, headers, body) { - originalFakeXMLHttpRequest.prototype.respond.call(this, status, headers, body); - this.upload.dispatchEvent( - new FakeXMLHttpRequestProgressEvent("load", false, false, this) - ); - this.upload.dispatchEvent( - new FakeXMLHttpRequestProgressEvent("loadend", false, false, this) - ); - } - }); - - sinon.FakeXMLHttpRequest = FakeXMLHttpRequestWithUpload; - sinon.FakeXMLHttpRequestProgressEvent = FakeXMLHttpRequestProgressEvent; - sinon.FakeXMLHttpRequestWithUpload = FakeXMLHttpRequestWithUpload; - sinon.originalFakeXMLHttpRequest = originalFakeXMLHttpRequest; -})(); \ No newline at end of file diff --git a/test/helpers/fakeFormData.js b/test/helpers/fakeFormData.js new file mode 100644 index 00000000..71e43f8d --- /dev/null +++ b/test/helpers/fakeFormData.js @@ -0,0 +1,13 @@ +(function (window) { + window.FormData.prototype._append = window.FormData.prototype.append; + window.FormData.prototype.append = function (key, value) { + this.keys = this.keys || []; + this.values = this.values || []; + this.keys.push(key); + this.values.push(value); + this._append.apply(this, arguments); + }; + window.FormData.prototype.get = function (key) { + return this.values[this.keys.indexOf(key)] || null; + }; +})(window); \ No newline at end of file diff --git a/test/helpers/fileMock.js b/test/helpers/fileMock.js index 0d5d8bba..01f4c7f1 100644 --- a/test/helpers/fileMock.js +++ b/test/helpers/fileMock.js @@ -11,5 +11,8 @@ function fileMock(data, name, properties) { if (properties && properties.lastModified) { b.lastModified = properties.lastModified; } + if (properties && properties.relativePath) { + b.relativePath = properties.relativePath; + } return b; } \ No newline at end of file diff --git a/test/httpSpec.js b/test/httpSpec.js new file mode 100644 index 00000000..6eb2408a --- /dev/null +++ b/test/httpSpec.js @@ -0,0 +1,78 @@ +describe('http', function () { + /** + * @type {FakeXMLHttpRequest[]} + */ + var requests = []; + + /** + * @type {FakeXMLHttpRequest} + */ + var xhr; + + beforeEach(function () { + requests = []; + xhr = sinon.useFakeXMLHttpRequest(); + xhr.onCreate = function (xhr) { + requests.push(xhr); + }; + }); + + afterEach(function () { + xhr.restore(); + }); + + describe('execute request', function () { + var request; + beforeEach(function () { + request = http({url:'a.txt'}); + }); + it('should return xhr', function () { + expect(request.xhr).toBeDefined(); + }); + it('should send http request', function () { + expect(requests.length).toBe(1); + expect(requests[0].url).toBe('a.txt'); + }); + it('should set accept header', function () { + expect(requests[0].requestHeaders.Accept).toBe('application/json, text/plain, */*'); + }); + }); + + + it('should call complete event', function () { + var complete = jasmine.createSpy('complete'); + http({ + url:'a.txt', + onComplete: complete + }); + requests[0].respond(200); + expect(complete).toHaveBeenCalled(); + }); + it('should call progress event', function () { + var progress = jasmine.createSpy('progress'); + http({ + url:'a.txt', + onProgress: progress + }); + requests[0].uploadProgress({loaded: 1, total:55, lengthComputable: true}); + expect(progress).toHaveBeenCalled(); + expect(progress.mostRecentCall.args[0]).toBe(1); + expect(progress.mostRecentCall.args[1]).toBe(55); + }); + + + describe('params', function () { + it('should append parameters to url', function () { + http({url:'a.txt', params: {a:1}}); + expect(requests[0].url).toBe('a.txt?a=1'); + }); + it('should append parameters to url with query', function () { + http({url:'a.txt?b=1', params: {a:'b'}}); + expect(requests[0].url).toBe('a.txt?b=1&a=b'); + }); + it('should encode parameters', function () { + http({url:'a.txt', params: {'a[b ]':'c&d'}}); + expect(requests[0].url).toBe('a.txt?a[b+]=c%26d'); + }); + }); +}); \ No newline at end of file From b519f4bea71c2ecaf9c7919f6ce5d9deb21ea4a5 Mon Sep 17 00:00:00 2001 From: Aidas Klimas Date: Thu, 23 Oct 2014 21:27:49 +0300 Subject: [PATCH 4/6] init: some code to get started --- CHANGELOG.md | 37 ------- README.md | 20 +++- karma.conf.js | 1 + package.json | 4 +- src/flow.js | 173 ++++++++++++++++++++++--------- src/flowFile.js | 23 ++++ src/helpers.js | 39 +++++++ src/http.js | 2 +- src/sliceFile.js | 5 +- test/deepExtendSpec.js | 30 ++++++ test/evelOptsSpec.js | 16 +++ test/flowPauseSpec.js | 78 ++++++++++++++ test/flowSpec.js | 155 ++++++++++++++++----------- test/flowUploadSpec.js | 154 +++++++++++++++++++++++++-- test/helpers/fakeFormData.js | 45 ++++++++ test/helpers/fakeFormDataSpec.js | 19 ++++ test/httpSpec.js | 6 +- 17 files changed, 634 insertions(+), 173 deletions(-) delete mode 100644 CHANGELOG.md create mode 100644 src/flowFile.js create mode 100644 test/deepExtendSpec.js create mode 100644 test/evelOptsSpec.js create mode 100644 test/flowPauseSpec.js create mode 100644 test/helpers/fakeFormDataSpec.js diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 60bc6629..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,37 +0,0 @@ -# 2.0.0 - -## Features - - - All code follows Google javascript style guide - - Target url can be provided with query string - - Events **fileAdded** and **filesAdded** can prevent file from being added to $.files list by - returning false. Custom validators can be ran here. - - **ResumableFile.getType()** and **ResumableFile.getExtension()** helper methods added. Can be - used for custom validation. - - **fileProgress** and **progress** events are always asynchronous. - - **ResumableFile.pause()** and **ResumableFile.resume()** methods for single file pausing and - resuming. - - **filesSubmitted** event added. Can be used to start file upload. Event is thrown then files are - added to queue. - - **progressCallbacksInterval** parameter added. Minimum interval between callbacks execution in - milliseconds. - - **averageSpeed** and **currentSpeed** parameters added for `ResumableFile`. These params - accuracy can be adjusted with `speedSmoothingFactor` and `progressCallbacksInterval` parameters. - - **timeRemaining** method added for `ResumableFile`. Returns remaining time to upload in seconds. Accuracy is based on average speed. - - **sizeUploaded** method added for `ResumableFile`. Returns size uploaded in bytes. - - **singleFile** parameter added. Then enabled, uploaded file will replace current one. - -## Breaking Changes - - **Resumable** was renamed to **Flow** - - **ResumableFile.fileName** parameter renamed to **ResumableFile.name** - - **Resumable.getOpt** method dropped, use Resumable.opts parameter instead if needed. - - **Resumable.maxFiles**, **Resumable.minFileSize**, **Resumable.maxFileSize**, - **Resumable.fileType** validators dropped. Use **fileAdded** and **filesAdded** events for - custom validation. - - **fileProgress** and **progress** events are not thrown on ResumableFile.abort() and ResumableFile.cancel() methods execution. - - **cancel** event was removed. Event was always called after **Resumable.cancel()** function. - - **fileAdded**, **filesAdded** events are thrown before file is added to upload queue. This means - that calling **Resumable.upload()** method in these events will not start uploading current - files. To start upload use **filesSubmitted** event instead. - - **throttleProgressCallbacks** parameter was replaced with **progressCallbacksInterval** and it - is now measured in milliseconds. \ No newline at end of file diff --git a/README.md b/README.md index 1d794086..ee0ef934 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,20 @@ +### Contribute! +This is an early version of flow.js. It is still missing most of its features, +so please contribute. All crazy ideas are welcome! + +Feature list: + + * Batch uploads (can upload many files in one request) + * Chunk uploads (Chunk size depends on connection speed) + * Preprocessing (Zip, Resize, ...) + * Client side validation (Invalid extension, ...) + * Server side validation (Not enough space, Invalid user, ...) + * Fault tolerance (Checksum validation, Retry on server crash, ...) + * Pause, Resume, Avg. file speed calculation, Progress + * (Optional, for later) Files balancing and uploading to multiple targets(servers) at once. + +Read more at our [General discussion](https://github.com/flowjs/flow.js/issues/4) + ## Flow.js Flow.js is a JavaScript library providing multiple simultaneous, stable and resumable uploads via the HTML5 File API. @@ -8,9 +25,6 @@ Library follows simple file upload protocol, which can be easily implemented in The library is designed to introduce fault-tolerance into the upload of large files through HTTP. This is done by splitting each file into small chunks. Then, whenever the upload of a chunk fails, uploading is retried until the procedure completes. This allows uploads to automatically resume uploading after a network connection is lost either locally or to the server. Additionally, it allows for users to pause, resume and even recover uploads without losing state because only the currently uploading chunks will be aborted, not the entire upload. - -Examples are available in the `examples/` folder. Please push your own as Markdown to help document the project. - ## Contribution To ensure consistency throughout the source code, keep these rules in mind as you are working: diff --git a/karma.conf.js b/karma.conf.js index 69baf5bb..94e5f50f 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,6 +15,7 @@ module.exports = function(config) { 'test/helpers/*.js', 'src/http.js', 'src/sliceFile.js', + 'src/flowFile.js', 'src/flow.js', 'src/formData.js', 'src/helpers.js', diff --git a/package.json b/package.json index 1c90f263..a42fdeb5 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,12 @@ "karma-chrome-launcher": "*", "karma-firefox-launcher": "*", "karma-ie-launcher": "*", - "karma-jasmine": "*", + "karma-jasmine": "~0.2", "karma": "~0", "grunt-karma": "*", "grunt-saucelabs": "*", "karma-sauce-launcher": "*", - "sinon": "~1", + "sinon": "~1.8", "karma-coverage": "*", "grunt-karma-coveralls": "*", "grunt-contrib-concat": "*" diff --git a/src/flow.js b/src/flow.js index dbf02de4..42839359 100644 --- a/src/flow.js +++ b/src/flow.js @@ -1,63 +1,68 @@ /** * File uploader - * @name flow - * @param {Object} $options + * @function + * @param {Object} [opts] */ -function flow($options) { +function flow(opts) { /** * @name flow.options * @type {Object} */ - $options = extend({ + var $options = extend({ + request: {}, fileConstructor: noop, onFilesAdded: noop, filterFileList: identity, - defaultChunkSize: 41943040//4Mb - }, $options); + maxRequestSize: 41943040//4Mb + }, opts); - var $chunkSize = $options.defaultChunkSize; - - /** - * @name flow.files - * @readonly - * @type {Array} - */ var $files = []; - /** - * Files to be uploaded - * @name flow.pendingFiles - * @readonly - * @type {Array} - */ - var $pendingFiles = []; + var $map = {}; /** - * id - file map - * @name flow.map - * @readonly - * @type {Object} + * Last request */ - var $map = {}; + var $xhr; var $flow = { + 'options': $options, + /** + * @name flow.files + * @readonly + * @type {Array} + */ 'files': $files, - 'pendingFiles': $pendingFiles, /** - * Indicates if file si uploading + * id - file map + * @name flow.map + * @readonly + * @type {Object} + */ + 'map': $map, + + /** + * Indicates if file is uploading * @name flow.isUploading + * @readonly * @type {boolean} */ 'isUploading': false, - 'options': $options, - 'map': $map, + /** + * Indicates if file upload is paused + * @name flow.isPaused + * @readonly + * @type {boolean} + */ + 'isPaused': false, /** * GEt file by id + * @function * @name flow.getById * @param id * @returns {Object|null} @@ -69,6 +74,7 @@ function flow($options) { /** * Construct and validate file. * Shortcut for addFiles + * @function * @name flow.addFile * @param {Blob|File} file */ @@ -78,6 +84,7 @@ function flow($options) { /** * Construct and validate file list + * @function * @name flow.addFiles * @param {FileList|Array.} fileList */ @@ -87,7 +94,6 @@ function flow($options) { each(list, function (file) { if (file && !$flow.getById(file.id)) { $files.push(file); - $pendingFiles.push(file); $map[file.id] = file; } }); @@ -95,15 +101,52 @@ function flow($options) { return list; }, + /** + * Start file upload + * @function + * @name flow.upload + */ 'upload': function upload() { if ($flow.isUploading) { return ; } - if (!$pendingFiles.length) { - return ; + $flow.isPaused = false; + uploadNext(); + }, + + /** + * Pause file upload + * @function + * @name flow.pause + */ + 'pause': function pause() { + $flow.isPaused = true; + $xhr && $xhr.abort(); + }, + + /** + * Remove file from queue + * @function + * @name flow.remove + * @param id + */ + remove: function(id) { + if ($map.hasOwnProperty(id)) { + arrayRemove($files, $map[id]); + delete $map[id]; } - $flow.isUploading = true; - openConnection(); + }, + + /** + * Remove all files from queue + * @function + * @name flow.removeAll + */ + removeAll: function() { + $files.length = 0; + each($map, function (value, key) { + delete $map[key]; + }); } }; return $flow; @@ -112,27 +155,55 @@ function flow($options) { * @param {File} file */ function fileConstructor(file) { - var obj = { - file: file, - name: file.name, - size: file.size, - relativePath: file.relativePath || file.webkitRelativePath || file.name, - extension: file.name.substr((~-file.name.lastIndexOf(".") >>> 0) + 2).toLowerCase(), - - inProgress: false, - completed: false, - progress: 0 - }; - obj.id = obj.size + '-' + obj.relativePath; + var obj = new FlowFile(file); $options.fileConstructor.call(obj, $flow); return obj; } - function openConnection() { -// var request = requestConstructor(); - http({ + function uploadNext() { + var data = processFiles(); + if (data.count > 0) { + $flow.isUploading = true; + $xhr = http(extend({ method: 'POST', - data: new FormData - }); + url: '/', + data: toFormData(data), + onComplete: handleResponse + }, evalOpts($options.request, data))).xhr; + } + } + + function handleResponse(response) { + $flow.isUploading = false; + if (!$flow.isPaused) { + uploadNext(); + } + } + + function processFiles() { + var data = { + files: {}, + count: 0 + }; + var size = 0; + each($files, function (file) { + if (file.completed || $options.maxRequestSize === size) { + return ; + } + var slice = Math.min($options.maxRequestSize - size, file.size - file.offset); + size += slice; + data.files[file.id] = { + name: file.name, + size: file.size, + offset: file.offset, + content: sliceFile(file.file, file.offset, file.offset + slice) + }; + file.offset += slice; + data.count++; + if (file.offset === file.size) { + file.completed = true; + } + }); + return data; } } \ No newline at end of file diff --git a/src/flowFile.js b/src/flowFile.js new file mode 100644 index 00000000..39c71edc --- /dev/null +++ b/src/flowFile.js @@ -0,0 +1,23 @@ +function FlowFile(file) { + var f = this; + f.file = file; + f.name = file.name; + f.size = file.size; + f.relativePath = file.relativePath || file.webkitRelativePath || file.name; + f.extension = file.name.substr((~-file.name.lastIndexOf(".") >>> 0) + 2).toLowerCase(); + + f.offset = 0; + f.inProgress = false; + f.isPaused = false; + f.isCompleted = false; + + f.id = f.size + '-' + f.relativePath; +} +FlowFile.prototype = { + 'pause': function () { + if (!this.inProgress && !this.isCompleted) { + this.isPaused = true; + } + return this.isPaused; + } +}; \ No newline at end of file diff --git a/src/helpers.js b/src/helpers.js index 103a2306..3927d3e1 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -18,6 +18,30 @@ function extend(dst, src) { return dst; } +/** + * Extends the destination object `dst` by copying all of the properties from + * the `src` object(s) to `dst`. You can specify multiple `src` objects. + * Deep extend follows `dst` object and only extends dst defined attributes. + * @function + * @param {Object} dst Destination object. + * @param {...Object} src Source object(s). + * @returns {Object} Reference to `dst`. + */ +function deepExtend(dst, src) { + each(arguments, function(obj) { + if (obj !== dst) { + each(obj, function(value, key){ + if (dst[key] !== null && typeof dst[key] === 'object') { + deepExtend(dst[key], value); + } else { + dst[key] = value; + } + }); + } + }); + return dst; +} + /** * Iterate each element of an object * @function @@ -46,6 +70,21 @@ function each(obj, callback, context) { } } +/** + * If option is a function, evaluate it with given params + * @param {*} data + * @param {...} args arguments of a callback + * @returns {*} + */ +function evalOpts(data, args) { + if (typeof data === "function") { + // `arguments` is an object, not array, in FF, so: + args = Array.prototype.slice.call(arguments); + data = data.apply(null, args.slice(1)); + } + return data; +} + /** * Remove value from array * @param array diff --git a/src/http.js b/src/http.js index c8208bfb..77bb2b1c 100644 --- a/src/http.js +++ b/src/http.js @@ -30,7 +30,7 @@ function http(config) { var method = config.method.toUpperCase(); var xhr = new XMLHttpRequest(); - xhr.open(method, url); + xhr.open(method, url, true); each(config.headers, function (value, key) { xhr.setRequestHeader(key, value); diff --git a/src/sliceFile.js b/src/sliceFile.js index dd09f062..3cb22031 100644 --- a/src/sliceFile.js +++ b/src/sliceFile.js @@ -2,9 +2,10 @@ var sliceFn = Blob.prototype.slice || Blob.prototype.mozSlice || Blob.prototype. /** * Creates file slice with params * @param file + * @param offset * @param size * @returns {Object} */ -function sliceFile(file, size) { - return sliceFn.call(file.file, file.offset, size); +function sliceFile(file, offset, size) { + return sliceFn.call(file, offset, size, file.type); } \ No newline at end of file diff --git a/test/deepExtendSpec.js b/test/deepExtendSpec.js new file mode 100644 index 00000000..970cae6d --- /dev/null +++ b/test/deepExtendSpec.js @@ -0,0 +1,30 @@ +describe('deepExtend', function () { + it('should extend one level objects', function () { + expect(deepExtend({ + a:1, b:2 + }, { + a: 2, c :3 + })).toEqual({ + a:2, b:2, c:3 + }); + }); + it('should extend two level objects', function () { + expect(deepExtend({ + a: 1, + b: { + c: 2 + } + }, { + a: 2, + b: { + d: 3 + } + })).toEqual({ + a: 2, + b: { + c: 2, + d: 3 + } + }); + }); +}); \ No newline at end of file diff --git a/test/evelOptsSpec.js b/test/evelOptsSpec.js new file mode 100644 index 00000000..187963f2 --- /dev/null +++ b/test/evelOptsSpec.js @@ -0,0 +1,16 @@ +describe('evalOpts', function () { + it('should return same object for non functions', function() { + var obj = {}; + expect(evalOpts(obj)).toBe(obj); + }); + it('should return same type for non functions', function() { + expect(evalOpts(5)).toBe(5); + }); + it('should evaluate function', function() { + expect(evalOpts(function () {return 5;})).toBe(5); + }); + it('should evaluate function with given arguments', function() { + var obj = {}; + expect(evalOpts(function (a) {return a;}, obj)).toBe(obj); + }); +}); \ No newline at end of file diff --git a/test/flowPauseSpec.js b/test/flowPauseSpec.js new file mode 100644 index 00000000..a25532b9 --- /dev/null +++ b/test/flowPauseSpec.js @@ -0,0 +1,78 @@ +describe('flow.pause', function () { + + /** @type {flow} */ + var flowObj; + + /** + * @type {FakeXMLHttpRequest[]} + */ + var requests = []; + + /** + * @type {FakeXMLHttpRequest} + */ + var xhr; + + beforeEach(function () { + flowObj = flow({maxRequestSize:1}); + requests = []; + xhr = sinon.useFakeXMLHttpRequest(); + xhr.onCreate = function (xhr) { + requests.push(xhr); + }; + }); + + afterEach(function () { + xhr.restore(); + }); + + describe('pause', function () { + beforeEach(function () { + flowObj.addFile(fileMock(['123456'], 'one')); + flowObj.upload(); + flowObj.pause(); + }); + it('should abort active request', function () { + expect(requests[0].aborted).toBeTruthy(); + }); + it('should not fire additional upload requests', function () { + expect(requests.length).toBe(1); + }); + it('should set isPaused to true', function () { + expect(flowObj.isPaused).toBeTruthy(); + }); + describe('resume', function () { + beforeEach(function () { + flowObj.upload(); + }); + it('should set isPaused to false on resume', function () { + expect(flowObj.isPaused).toBeFalsy(); + }); + it('should fire additional upload request', function () { + expect(requests.length).toBe(2); + }); + }); + }); + + describe('pause single file', function () { + beforeEach(function () { + flowObj.addFile(fileMock(['123456'], 'one')); + flowObj.addFile(fileMock(['123456'], 'two')); + }); + it('should add pause method for every file', function () { + expect(flowObj.files[0].pause).toBeDefined(); + }); + it('should add isPaused property for every file', function () { + expect(flowObj.files[0].isPaused).toBeFalsy(); + }); + it('should set isPaused property to true on pause', function () { + flowObj.files[0].pause(); + expect(flowObj.files[0].isPaused).toBeTruthy(); + }); + // it should not upload paused file + // it should not upload remaining data of paused file + // it should not allow to pause in progress file? + // it should be able to resume paused file + }); +}); + diff --git a/test/flowSpec.js b/test/flowSpec.js index 36fa82e0..ef27b15b 100644 --- a/test/flowSpec.js +++ b/test/flowSpec.js @@ -6,86 +6,91 @@ describe('flow', function () { flowObj = flow(); }); - describe('addFiles', function () { - function addFiles() { - flowObj.addFiles([ - fileMock([], 'one'), - fileMock([], 'two') - ]); - } + function addTwoFiles() { + flowObj.addFiles([ + fileMock([], 'one'), + fileMock([], 'two') + ]); + } - it('should add new files', function () { - addFiles(); + describe('addFiles', function () { + it('should add new files to queue', function () { + addTwoFiles(); expect(flowObj.files.length).toBe(2); }); - it('should use custom constructor', function () { + it('should allow to add custom properties for files with custom constructor', function () { var i = 0; flowObj.options.fileConstructor = function () { this.id = i++; }; - addFiles(); + addTwoFiles(); expect(flowObj.files.length).toBe(2); expect(flowObj.files[0].id).toBe(0); expect(flowObj.files[1].id).toBe(1); }); + }); - describe('filterFileList', function () { - var filterFileList; - function createFilter(cb) { - filterFileList = jasmine.createSpy('filter files').andCallFake(cb); - flowObj.options.filterFileList = filterFileList; - } - it('should reject all added files', function () { - createFilter(function () { - return []; - }) - addFiles(); - expect(filterFileList.callCount).toBe(1); - expect(flowObj.files.length).toBe(0); + describe('filterFileList', function () { + var filterFileList; + function createFilter(cb) { + filterFileList = jasmine.createSpy('filter files').and.callFake(cb); + flowObj.options.filterFileList = filterFileList; + } + it('should reject all added files', function () { + createFilter(function () { + return []; }); + addTwoFiles(); + expect(filterFileList.calls.count()).toBe(1); + expect(flowObj.files.length).toBe(0); + }); - it('should validate every file', function () { - createFilter(function (files) { - var list = []; - each(files, function (file) { - if (file.name != 'one') { - list.push(file); - } - }); - return list; + it('should reject files with name "one"', function () { + createFilter(function (files) { + var list = []; + each(files, function (file) { + if (file.name != 'one') { + list.push(file); + } }); - addFiles(); - expect(filterFileList.callCount).toBe(1); - expect(flowObj.files.length).toBe(1); - expect(flowObj.files[0].name).toBe('two'); + return list; }); + addTwoFiles(); + expect(filterFileList.calls.count()).toBe(1); + expect(flowObj.files.length).toBe(1); + expect(flowObj.files[0].name).toBe('two'); + }); - it('should validate every file in short syntax', function () { - createFilter(function (files) { - return files.map(function (file) { - return file.name != 'one' && file; - }); + it('should allow to change current files array and reject false values', function () { + createFilter(function (files) { + return files.map(function (file) { + return file.name != 'one' && file; }); - addFiles(); - expect(filterFileList.callCount).toBe(1); - expect(flowObj.files.length).toBe(1); - expect(flowObj.files[0].name).toBe('two'); }); + addTwoFiles(); + expect(filterFileList.calls.count()).toBe(1); + expect(flowObj.files.length).toBe(1); + expect(flowObj.files[0].name).toBe('two'); }); + }); - - it('should notify then files are added to queue', function () { - var notify = jasmine.createSpy('files added callback'); + describe('onFilesAdded', function () { + var notify; + beforeEach(function () { + notify = jasmine.createSpy('files added callback'); flowObj.options.onFilesAdded = notify; - addFiles(); - expect(notify.callCount).toBe(1); - expect(notify.mostRecentCall.args[0].length).toBe(2); + addTwoFiles(); + }); + it('should notify then files are added to queue', function () { + expect(notify.calls.count()).toBe(1); + }); + it('first argument should be array of files', function () { + expect(notify.calls.mostRecent().args[0].length).toBe(2); }); - }); - describe('every file should have id by default', function () { + describe('every file should have unique id by default', function () { beforeEach(function () { flowObj.addFile( fileMock(['abc'], 'one', { @@ -104,17 +109,42 @@ describe('flow', function () { ); expect(flowObj.files.length).toBe(1); }); - describe('getById', function () { - it('should get file by id', function () { - expect(flowObj.getById('3-home/one')).toBe(flowObj.files[0]) - }); - it('should return null if file was not found', function () { - expect(flowObj.getById('fqwf')).toBe(null); - }); + }); + + describe('onDuplicateFilesAdded', function () { + // duplicate files must be filtered in filterFileList + }); + + describe('getById', function () { + beforeEach(function () { + addTwoFiles(); + }); + it('should get file by id', function () { + expect(flowObj.getById('0-one')).toBe(flowObj.files[0]) + }); + it('should return null if file was not found', function () { + expect(flowObj.getById('none')).toBe(null); }); }); - describe('extension', function () { + describe('relativePath', function () { + beforeEach(function () { + var file = fileMock([], 'one'); + file.relativePath = 'C:/one'; + flowObj.addFiles([ + file, + fileMock([], 'two') + ]); + }); + it('should get file relative path', function () { + expect(flowObj.files[0].relativePath).toBe('C:/one') + }); + it('should return name if relative path is not available', function () { + expect(flowObj.files[1].relativePath).toBe('two'); + }); + }); + + describe('extension - file should have a valid extension property', function () { function fileExtension(name) { flowObj.addFile( fileMock([], name) @@ -134,5 +164,4 @@ describe('flow', function () { expect(fileExtension('.dwq.dq.wd.qdw.E')).toBe('e'); }); }); - }); \ No newline at end of file diff --git a/test/flowUploadSpec.js b/test/flowUploadSpec.js index 7462154a..f121d090 100644 --- a/test/flowUploadSpec.js +++ b/test/flowUploadSpec.js @@ -15,7 +15,6 @@ describe('flow.upload', function () { beforeEach(function () { flowObj = flow(); - requests = []; xhr = sinon.useFakeXMLHttpRequest(); xhr.onCreate = function (xhr) { @@ -27,46 +26,181 @@ describe('flow.upload', function () { xhr.restore(); }); + describe('request', function () { + beforeEach(function () { + flowObj.addFile(fileMock([], 'one')); + }); + describe('url', function () { + it('should set upload target', function () { + flowObj.options.request.url = '/target'; + flowObj.upload(); + expect(requests[0].url).toBe('/target'); + }); + }); + describe('callback', function () { + beforeEach(function () { + flowObj.options.request = function () { + return {url: '/target'}; + }; + flowObj.upload(); + }); + it('should allow to set a callback for a request', function () { + expect(requests[0].url).toBe('/target'); + }); + it('should use POST method as a default', function () { + expect(requests[0].method).toBe('POST'); + }); + }); + }); + describe('isUploading', function () { beforeEach(function () { flowObj.addFile(fileMock([], 'one')); }); - it('should set isUploading to true', function () { + it('should set isUploading to true then upload has started', function () { expect(flowObj.isUploading).toBeFalsy(); flowObj.upload(); expect(flowObj.isUploading).toBeTruthy(); }); + it('should set isUploading to false then upload has finished', function () { + flowObj.upload(); + requests[0].respond(200); + expect(flowObj.isUploading).toBeFalsy(); + }); }); - describe('single file', function () { + describe('single file upload', function () { beforeEach(function () { flowObj.addFile(fileMock([], 'one')); + flowObj.upload(); }); it('should upload single file', function () { - flowObj.upload(); expect(requests.length).toBe(1); }); it('should upload once', function () { - flowObj.upload(); flowObj.upload(); expect(requests.length).toBe(1); }); it('should post as formdata', function () { - flowObj.upload(); expect(requests[0].method).toBe('POST'); expect(requests[0].requestBody instanceof FormData).toBeTruthy(); }); }); - describe('file params', function () { + describe('request variables', function () { var requestBody; beforeEach(function () { - flowObj.addFile(fileMock([], 'one')); + flowObj.addFile(fileMock(['abc'], 'one')); flowObj.upload(); requestBody = requests[0].requestBody; }); - it('should have default params', function () { - expect(requestBody).toBeTruthy(); + it('should have count', function () { + expect(requestBody.toObject().count).toBe(1); + }); + it('should have files', function () { + expect(requestBody.toObject().hasOwnProperty('files')).toBeTruthy(); + }); + describe('file variables', function () { + var file; + beforeEach(function () { + file = requestBody.toObject().files['3-one']; + }); + it('should have name', function () { + expect(file.hasOwnProperty('name')).toBeTruthy(); + expect(file.name).toBe('one'); + }); + it('should have size', function () { + expect(file.hasOwnProperty('size')).toBeTruthy(); + expect(file.size).toBe(3); + }); + it('should have offset', function () { + expect(file.hasOwnProperty('offset')).toBeTruthy(); + expect(file.offset).toBe(0); + }); + it('should have file content', function () { + expect(file.hasOwnProperty('content')).toBeTruthy(); + expect(file.content instanceof Blob).toBeTruthy(); + }); + }); + }); + + describe('maxRequestSize - request should not exceed defined request max size', function () { + var request; + beforeEach(function () { + flowObj = flow({ + maxRequestSize: 10 + }); + flowObj.addFile(fileMock(['123456'], 'one')); + flowObj.addFile(fileMock(['123456'], 'two')); + flowObj.upload(); + request = requests[0].requestBody.toObject(); + }); + it('should transfer first and second file', function () { + expect(request.files.hasOwnProperty('6-one')).toBeTruthy(); + expect(request.files.hasOwnProperty('6-two')).toBeTruthy(); + }); + it('should send all data of first file', function () { + var first = request.files['6-one']; + expect(first.content.size).toEqual(6); + }); + it('should slice second file to fill remaining request size', function () { + var second = request.files['6-two']; + expect(second.content.size).toEqual(4); + }); + it('should call second request to finish transferring all files in queue', function () { + requests[0].respond(200); + expect(requests.length).toBe(2); + }); + describe('final request', function () { + beforeEach(function () { + requests[0].respond(200); + request = requests[1].requestBody.toObject(); + }); + it('should transfer second file', function () { + expect(request.files.hasOwnProperty('6-one')).toBeFalsy(); + expect(request.files.hasOwnProperty('6-two')).toBeTruthy(); + }); + it('should send remaining content of second file', function () { + var second = request.files['6-two']; + expect(second.content.size).toEqual(2); + }); + it('should not send third request', function () { + requests[1].respond(200); + expect(requests.length).toBe(2); + }); + }); + }); + + describe('remove', function () { + beforeEach(function () { + flowObj.addFile(fileMock(['123456'], 'one')); + flowObj.addFile(fileMock(['123456'], 'two')); + }); + it('should remove file by its id', function () { + expect(flowObj.getById('6-one')).not.toBeNull(); + flowObj.remove('6-one'); + expect(flowObj.getById('6-one')).toBeNull(); + }); + it('should not remove not existent files', function () { + flowObj.remove('one'); + expect(flowObj.files.length).toBe(2); + }); + it('should update files array length', function () { + flowObj.remove('6-two'); + expect(flowObj.files.length).toBe(1); + }); + }); + + describe('removeAll', function () { + beforeEach(function () { + flowObj.addFile(fileMock(['123456'], 'one')); + flowObj.addFile(fileMock(['123456'], 'two')); + }); + it('should remove all files in queue', function () { + flowObj.removeAll(); + expect(flowObj.files.length).toBe(0); + expect(flowObj.getById('6-one')).toBeNull(); + expect(flowObj.getById('6-two')).toBeNull(); }); }); }); \ No newline at end of file diff --git a/test/helpers/fakeFormData.js b/test/helpers/fakeFormData.js index 71e43f8d..ab1ad9dc 100644 --- a/test/helpers/fakeFormData.js +++ b/test/helpers/fakeFormData.js @@ -10,4 +10,49 @@ window.FormData.prototype.get = function (key) { return this.values[this.keys.indexOf(key)] || null; }; + window.FormData.prototype.toObject = function () { + this.keys = this.keys || []; + var obj = {}; + for (var i=0; i Date: Sun, 26 Oct 2014 15:05:00 +0200 Subject: [PATCH 5/6] fix(travis): build status --- Gruntfile.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index e7dd36e0..ecaa0e97 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -90,5 +90,5 @@ module.exports = function(grunt) { // Release grunt.registerTask('build', ['concat', 'uglify']); // Development - grunt.registerTask('travis', ["karma:travis", "coveralls"]); -}; \ No newline at end of file + grunt.registerTask('travis', ["karma:travis"]); +}; From f5cdc805f1688485d82e0abb5bb481dd96a0c5c2 Mon Sep 17 00:00:00 2001 From: Aidas Klimas Date: Thu, 19 Feb 2015 10:06:28 +0200 Subject: [PATCH 6/6] docs: readme --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee0ef934..1c06a538 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ so please contribute. All crazy ideas are welcome! Feature list: - * Batch uploads (can upload many files in one request) * Chunk uploads (Chunk size depends on connection speed) * Preprocessing (Zip, Resize, ...) * Client side validation (Invalid extension, ...) @@ -12,8 +11,8 @@ Feature list: * Fault tolerance (Checksum validation, Retry on server crash, ...) * Pause, Resume, Avg. file speed calculation, Progress * (Optional, for later) Files balancing and uploading to multiple targets(servers) at once. - -Read more at our [General discussion](https://github.com/flowjs/flow.js/issues/4) + * Server side api should be simple and easy to adapt with any other client side language + * ~~Batch uploads (can upload many files in one request)~~ useless, adds lots of compexity without any benefits ## Flow.js @@ -49,6 +48,10 @@ To ensure consistency throughout the source code, keep these rules in mind as yo npm install +## Build + + grunt build + ## Testing Our unit and integration tests are written with Jasmine and executed with Karma. To run all of the