3781: Add browser->api/keepproxy angular app as Upload tab on collections#show
authorTom Clegg <tom@curoverse.com>
Sat, 22 Nov 2014 19:40:34 +0000 (14:40 -0500)
committerTom Clegg <tom@curoverse.com>
Wed, 26 Nov 2014 05:51:38 +0000 (00:51 -0500)
14 files changed:
apps/workbench/Gemfile
apps/workbench/Gemfile.lock
apps/workbench/app/assets/javascripts/angular_shim.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/application.js
apps/workbench/app/assets/javascripts/arvados_client.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/tab_panes.js
apps/workbench/app/assets/javascripts/upload_to_collection.js [new file with mode: 0644]
apps/workbench/app/assets/stylesheets/application.css.scss
apps/workbench/app/controllers/application_controller.rb
apps/workbench/app/controllers/collections_controller.rb
apps/workbench/app/views/collections/_show_upload.html.erb [new file with mode: 0644]
apps/workbench/app/views/layouts/application.html.erb
apps/workbench/app/views/projects/show.html.erb
apps/workbench/test/integration/pipeline_instances_test.rb

index 5ab6eace2c8de0777e23daa62cc6de1d0863ce94..365cefc458b40811ddfcd047c82efb83a4dfb9df 100644 (file)
@@ -56,6 +56,8 @@ gem 'bootstrap-sass', '~> 3.1.0'
 gem 'bootstrap-x-editable-rails'
 gem 'bootstrap-tab-history-rails'
 
+gem 'angularjs-rails'
+
 gem 'less'
 gem 'less-rails'
 gem 'wiselinks'
index 8b9ea947bb0ba9498ab347b673e887e664132414..8c188476c8c18c5f01d15113390512958391e281 100644 (file)
@@ -38,6 +38,7 @@ GEM
       tzinfo (~> 1.1)
     addressable (2.3.6)
     andand (1.3.3)
+    angularjs-rails (1.3.3)
     arel (5.0.1.20140414130214)
     arvados (0.1.20141114230720)
       activesupport (>= 3.2.13)
@@ -242,6 +243,7 @@ PLATFORMS
 DEPENDENCIES
   RedCloth
   andand
+  angularjs-rails
   arvados (>= 0.1.20141114230720)
   bootstrap-sass (~> 3.1.0)
   bootstrap-tab-history-rails
diff --git a/apps/workbench/app/assets/javascripts/angular_shim.js b/apps/workbench/app/assets/javascripts/angular_shim.js
new file mode 100644 (file)
index 0000000..a480eaf
--- /dev/null
@@ -0,0 +1,12 @@
+// Compile any new HTML content that was loaded via jQuery.ajax().
+// Currently this only works for tabs because they emit an
+// arv:pane:loaded event after updating the DOM.
+
+$(document).on('arv:pane:loaded', function(event, updatedElement) {
+    if (updatedElement) {
+        angular.element(updatedElement).injector().invoke(function($compile) {
+            var scope = angular.element(updatedElement).scope();
+            $compile(updatedElement)(scope);
+        });
+    }
+});
index 1990b8b0f55c8d2497db67cae6d6da21f714663b..6b98fd93cc44cfd67023e12800294c501f74d5c0 100644 (file)
 //= require bootstrap3-editable/bootstrap-editable
 //= require bootstrap-tab-history
 //= require wiselinks
+//= require angular
 //= require_tree .
 
 jQuery(function($){
-    $.ajaxSetup({
-        headers: {
-            'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
-        }
-    });
-
     $(document).ajaxStart(function(){
       $('.modal-with-loading-spinner .spinner').show();
     }).ajaxStop(function(){
diff --git a/apps/workbench/app/assets/javascripts/arvados_client.js b/apps/workbench/app/assets/javascripts/arvados_client.js
new file mode 100644 (file)
index 0000000..584928f
--- /dev/null
@@ -0,0 +1,94 @@
+angular.
+    module('Arvados', []).
+    service('ArvadosClient', ArvadosClient);
+
+ArvadosClient.$inject = ['arvadosApiToken', 'arvadosDiscoveryUri']
+function ArvadosClient(arvadosApiToken, arvadosDiscoveryUri) {
+    $.extend(this, {
+        apiPromise: apiPromise,
+        uniqueNameForManifest: uniqueNameForManifest
+    });
+    return this;
+    ////////////////////////////////
+
+    var that = this;
+    var promiseDiscovery;
+    var discoveryDoc;
+
+    function apiPromise(controller, action, params) {
+        // Start an API call. Return a promise that will resolve with
+        // the API response.
+        return getDiscoveryDoc().then(function() {
+            var meth = discoveryDoc.resources[controller].methods[action];
+            var data = $.extend({}, params, {_method: meth.httpMethod});
+            $.each(data, function(k, v) {
+                if (typeof(v) == 'object') {
+                    data[k] = JSON.stringify(v);
+                }
+            });
+            var path = meth.path.replace(/{(.*?)}/, function(_, key) {
+                var val = data[key];
+                delete data[key];
+                return encodeURIComponent(val);
+            });
+            return $.ajax({
+                url: discoveryDoc.baseUrl + path,
+                type: 'POST',
+                crossDomain: true,
+                dataType: 'json',
+                data: data,
+                headers: {
+                    Authorization: 'OAuth2 ' + arvadosApiToken
+                }
+            });
+        });
+    }
+
+    function uniqueNameForManifest(manifest, streamName, origName) {
+        // Return an (escaped) filename starting with (unescaped)
+        // origName that won't conflict with any existing names in
+        // the manifest if saved under streamName. streamName must
+        // be exactly as given in the manifest, e.g., "." or
+        // "./foo" or "./foo/bar".
+        //
+        // Example:
+        //
+        // unique('./foo [...] 0:0:bar\040baz\n', '.', 'foo/bar baz')
+        // =>
+        // 'foo/bar\\040baz\\040(1)'
+        var newName;
+        var nameStub = origName;
+        var suffixInt = null;
+        var ok = false;
+        while (!ok) {
+            ok = true;
+            // Add ' (N)' before the filename extension, if any.
+            newName = (!suffixInt ? nameStub :
+                       nameStub.replace(/(\.[^.]*)?$/, ' ('+suffixInt+')$1')).
+                replace(/ /g, '\\040');
+            $.each(manifest.split('\n'), function(_, line) {
+                var i, match, foundName;
+                var toks = line.split(' ');
+                for (var i=1; i<toks.length && ok; i++)
+                    if (match = toks[i].match(/^\d+:\d+:(\S+)/))
+                        if (toks[0] + '/' + match[1] === streamName + '/' + newName) {
+                            suffixInt = (suffixInt || 0) + 1;
+                            ok = false;
+                        }
+            });
+        }
+        return newName;
+    }
+
+    function getDiscoveryDoc() {
+        if (!promiseDiscovery) {
+            promiseDiscovery = $.ajax({
+                url: arvadosDiscoveryUri,
+                crossDomain: true
+            }).then(function(data, status, xhr) {
+                discoveryDoc = data;
+            });
+        }
+        return promiseDiscovery;
+    }
+}
index 07e46fe65fc845328eb21c0c0bc7dd6042ba5d21..f603440ecea97ff88bb7b049942da4a297753a67 100644 (file)
@@ -124,7 +124,7 @@ $(document).on('arv:pane:reload', '[data-pane-content-url]', function(e) {
             $pane.removeClass('pane-loading');
             $pane.addClass('pane-loaded');
             $pane.attr('data-loaded-at', (new Date()).getTime());
-            $pane.trigger('arv:pane:loaded');
+            $pane.trigger('arv:pane:loaded', $pane);
 
             if ($pane.hasClass('pane-stale')) {
                 $pane.trigger('arv:pane:reload');
diff --git a/apps/workbench/app/assets/javascripts/upload_to_collection.js b/apps/workbench/app/assets/javascripts/upload_to_collection.js
new file mode 100644 (file)
index 0000000..267543c
--- /dev/null
@@ -0,0 +1,419 @@
+var app = angular.module('Workbench', ['Arvados']);
+app.controller('UploadToCollection', UploadToCollection);
+app.directive('arvUuid', arvUuid);
+
+function arvUuid() {
+    // Copy the given uuid into the current $scope.
+    return {
+        restrict: 'A',
+        link: function(scope, element, attributes) {
+            scope.uuid = attributes.arvUuid;
+        }
+    };
+}
+
+UploadToCollection.$inject = ['$scope', '$filter', '$q', '$timeout',
+                              'ArvadosClient', 'arvadosApiToken'];
+function UploadToCollection($scope, $filter, $q, $timeout,
+                            ArvadosClient, arvadosApiToken) {
+    $.extend($scope, {
+        uploadQueue: [],
+        uploader: new QueueUploader(),
+        addFilesToQueue: function(files) {
+            // Angular binding doesn't work its usual magic for file
+            // inputs, so we need to $scope.$apply() this update.
+            $scope.$apply(function(){
+                var i;
+                var insertAt;
+                for (insertAt=0; (insertAt<$scope.uploadQueue.length &&
+                                  $scope.uploadQueue[insertAt].state != 'Done');
+                     insertAt++);
+                for (i=0; i<files.length; i++) {
+                    $scope.uploadQueue.splice(insertAt+i, 0,
+                        new FileUploader(files[i]));
+                }
+            });
+        },
+        go: function() {
+            $scope.uploader.go();
+        },
+        stop: function() {
+            $scope.uploader.stop();
+        },
+        removeFileFromQueue: function(index) {
+            var wasRunning = $scope.uploader.running;
+            $scope.uploadQueue[index].stop();
+            $scope.uploadQueue.splice(index, 1);
+            if (wasRunning)
+                $scope.go();
+        },
+        countDone: function() {
+            var done=0;
+            for (var i=0; i<$scope.uploadQueue.length; i++) {
+                if ($scope.uploadQueue[i].state == 'Done') {
+                    ++done;
+                }
+            }
+            return done;
+        }
+    });
+    // TODO: watch uploadQueue, abort uploads if entries disappear
+
+    var keepProxy;
+
+    function SliceReader(_slice) {
+        var that = this;
+        $.extend(this, {
+            go: go
+        });
+        ////////////////////////////////
+        var _deferred;
+        var _reader;
+        function go() {
+            // Return a promise, which will be resolved with the
+            // requested slice data.
+            _deferred = $.Deferred();
+            _reader = new FileReader();
+            _reader.onload = resolve;
+            _reader.onerror = _deferred.reject;
+            _reader.onprogress = _deferred.notify;
+            _reader.readAsArrayBuffer(_slice.blob);
+            return _deferred.promise();
+        }
+        function resolve() {
+            if (that._reader.result.length != that._slice.size) {
+                // Sometimes we get an onload event even if the read
+                // did not return the desired number of bytes. We
+                // treat that as a fail.
+                _deferred.reject(
+                    null, "Read error",
+                    "Short read: wanted " + _slice.size +
+                        ", received " + _reader.result.length);
+                return;
+            }
+            return _deferred.resolve(_reader.result);
+        }
+    }
+
+    function SliceUploader(_label, _data, _dataSize) {
+        $.extend(this, {
+            go: go,
+            stop: stop
+        });
+        ////////////////////////////////
+        var that = this;
+        var _deferred;
+        var _failCount = 0;
+        var _failMax = 3;
+        var _jqxhr;
+        function go() {
+            // Send data to the Keep proxy. Retry a few times on
+            // fail. Return a promise that will get resolved with
+            // resolve(locator) when the block is accepted by the
+            // proxy.
+            _deferred = $.Deferred();
+            goSend();
+            return _deferred.promise();
+        }
+        function stop() {
+            _failMax = 0;
+            _jqxhr.abort();
+            _deferred.reject({
+                textStatus: 'stopped',
+                err: 'interrupted at slice '+_label
+            });
+        }
+        function goSend() {
+            _jqxhr = $.ajax({
+                url: proxyUriBase(),
+                type: 'POST',
+                crossDomain: true,
+                headers: {
+                    'Authorization': 'OAuth2 '+arvadosApiToken,
+                    'Content-Type': 'application/octet-stream',
+                    'X-Keep-Desired-Replicas': '2'
+                },
+                xhr: function() {
+                    // Make an xhr that reports upload progress
+                    var xhr = $.ajaxSettings.xhr();
+                    if (xhr.upload) {
+                        xhr.upload.onprogress = onSendProgress;
+                    }
+                    return xhr;
+                },
+                processData: false,
+                data: _data
+            });
+            _jqxhr.then(onSendResolve, onSendReject);
+        }
+        function onSendProgress(xhrProgressEvent) {
+            _deferred.notify(xhrProgressEvent.loaded, _dataSize);
+        }
+        function onSendResolve(data, textStatus, jqxhr) {
+            _deferred.resolve(data, _dataSize);
+        }
+        function onSendReject(xhr, textStatus, err) {
+            if (++_failCount < _failMax) {
+                // TODO: nice to tell the user that retry is happening.
+                console.log('slice ' + _label + ': ' +
+                            textStatus + ', retry ' + _failCount);
+                goSend();
+            } else {
+                _deferred.reject(
+                    {xhr: xhr, textStatus: textStatus, err: err});
+            }
+        }
+        function proxyUriBase() {
+            return ((keepProxy.service_ssl_flag ? 'https' : 'http') +
+                    '://' + keepProxy.service_host + ':' +
+                    keepProxy.service_port + '/');
+        }
+    }
+
+    function FileUploader(file) {
+        $.extend(this, {
+            committed: false,
+            file: file,
+            locators: [],
+            progress: 0.0,
+            state: 'Queued',    // Queued, Uploading, Paused, Done
+            statistics: null,
+            go: go,
+            stop: stop          // User wants to stop.
+        });
+        ////////////////////////////////
+        var that = this;
+        var _currentUploader;
+        var _currentSlice;
+        var _deferred;
+        var _maxBlobSize = Math.pow(2,26);
+        var _bytesDone = 0;
+        var _queueTime = Date.now();
+        var _startTime;
+        var _startByte;
+        var _finishTime;
+        var _readPos = 0;       // number of bytes confirmed uploaded
+        function go() {
+            if (_deferred)
+                _deferred.reject({textStatus: 'restarted'});
+            _deferred = $q.defer();
+            that.state = 'Uploading';
+            _startTime = Date.now();
+            _startByte = _readPos;
+            setProgress();
+            goSlice();
+            return _deferred.promise;
+        }
+        function stop() {
+            if (_deferred) {
+                that.state = 'Paused';
+                _deferred.reject({textStatus: 'stopped', err: 'interrupted'});
+            }
+            if (_currentUploader) {
+                _currentUploader.stop();
+                _currentUploader = null;
+            }
+        }
+        function goSlice() {
+            // Ensure this._deferred gets resolved or rejected --
+            // either right here, or when a new promise arranged right
+            // here is fulfilled.
+            _currentSlice = nextSlice();
+            if (!_currentSlice) {
+                that.state = 'Done';
+                setProgress(_readPos);
+                _currentUploader = null;
+                _deferred.resolve([that]);
+                return;
+            }
+            _currentUploader = new SliceUploader(
+                _readPos.toString(),
+                _currentSlice.blob,
+                _currentSlice.size);
+            _currentUploader.go().then(
+                onUploaderResolve,
+                onUploaderReject,
+                onUploaderProgress);
+        }
+        function onUploaderResolve(locator, dataSize) {
+            if (!locator || _currentSlice.size != dataSize) {
+                console.log("onUploaderResolve but locator=" + locator +
+                            " and " + _currentSlice.size + " != " + dataSize);
+                return onUploaderReject({
+                    textStatus: "error",
+                    err: "Bad response from slice upload"
+                });
+            }
+            that.locators.push(locator);
+            _readPos += dataSize;
+            _currentUploader = null;
+            goSlice();
+        }
+        function onUploaderReject(reason) {
+            that.state = 'Paused';
+            setProgress(_readPos);
+            _currentUploader = null;
+            _deferred.reject(reason);
+        }
+        function onUploaderProgress(sliceDone, sliceSize) {
+            setProgress(_readPos + sliceDone);
+        }
+        function nextSlice() {
+            var size = Math.min(
+                _maxBlobSize,
+                that.file.size - _readPos);
+            setProgress(_readPos);
+            if (size == 0) {
+                return false;
+            }
+            var blob = that.file.slice(
+                _readPos, _readPos+size,
+                'application/octet-stream; charset=x-user-defined');
+            return {blob: blob, size: size};
+        }
+        function setProgress(bytesDone) {
+            var kBps;
+            that.progress = Math.min(100, 100 * bytesDone / that.file.size)
+            if (bytesDone > _startByte) {
+                kBps = (bytesDone - _startByte) /
+                    (Date.now() - _startTime);
+                that.statistics = (
+                    '' + $filter('number')(bytesDone/1024, '0') + 'K ' +
+                        'at ~' + $filter('number')(kBps, '0') + 'K/s')
+                if (that.state == 'Paused') {
+                    that.statistics += ', paused';
+                } else if (that.state == 'Uploading') {
+                    that.statistics += ', ETA ' +
+                        $filter('date')(
+                            new Date(
+                                Date.now() + (that.file.size - bytesDone) / kBps),
+                            'shortTime')
+                } else {
+                    that.statistics += ', finished ' +
+                        $filter('date')(Date.now(), 'shortTime');
+                    _finishTime = Date.now();
+                }
+            } else {
+                that.statistics = that.state;
+            }
+            _deferred.notify();
+        }
+    }
+
+    function QueueUploader() {
+        $.extend(this, {
+            state: 'Idle',
+            stateReason: null,
+            statusSuccess: null,
+            go: go,
+            stop: stop
+        });
+        ////////////////////////////////
+        var that = this;
+        var _deferred;
+        function go() {
+            if (that.state == 'Running') return _deferred.promise;
+            _deferred = $.Deferred();
+            that.state = 'Running';
+            ArvadosClient.apiPromise(
+                'keep_services', 'list',
+                {filters: [['service_type','=','proxy']]}).
+                then(doQueueWithProxy);
+            onQueueProgress();
+            return _deferred.promise();
+        }
+        function stop() {
+            for (var i=0; i<$scope.uploadQueue.length; i++)
+                $scope.uploadQueue[i].stop();
+        }
+        function doQueueWithProxy(data) {
+            keepProxy = data.items[0];
+            if (!keepProxy) {
+                that.state = 'Failed';
+                that.stateReason =
+                    'There seems to be no Keep proxy service available.';
+                _deferred.reject(null, 'error', that.stateReason);
+                return;
+            }
+            return doQueueWork();
+        }
+        function doQueueWork() {
+            var i;
+            that.state = 'Running';
+            that.stateReason = null;
+            // Push the done things to the bottom of the queue.
+            for (i=0; (i<$scope.uploadQueue.length &&
+                       $scope.uploadQueue[i].state == 'Done'); i++);
+            if (i>0)
+                $scope.uploadQueue.push.apply($scope.uploadQueue, $scope.uploadQueue.splice(0, i));
+            // If anything is not-done, do it.
+            if ($scope.uploadQueue.length > 0 &&
+                $scope.uploadQueue[0].state != 'Done') {
+                return $scope.uploadQueue[0].go().
+                    then(appendToCollection, null, onQueueProgress).
+                    then(doQueueWork, onQueueReject);
+            }
+            // If everything is done, resolve the promise and clean up.
+            return onQueueResolve();
+        }
+        function onQueueReject(reason) {
+            that.state = 'Failed';
+            that.stateReason = (
+                (reason.textStatus || 'Error') +
+                    (reason.xhr && reason.xhr.options
+                     ? (' (from ' + reason.xhr.options.url + ')')
+                     : '') +
+                    ': ' +
+                    (reason.err || ''));
+            if (reason.xhr && reason.xhr.responseText)
+                that.stateReason += ' -- ' + reason.xhr.responseText;
+            _deferred.reject(reason);
+            onQueueProgress();
+        }
+        function onQueueResolve() {
+            that.state = 'Idle';
+            that.stateReason = 'Done!';
+            _deferred.resolve();
+            onQueueProgress();
+        }
+        function onQueueProgress() {
+            // Ensure updates happen after FileUpload promise callbacks.
+            $timeout(function(){$scope.$apply();});
+        }
+        function appendToCollection(uploads) {
+            var deferred = $q.defer();
+            return ArvadosClient.apiPromise(
+                'collections', 'get',
+                { uuid: $scope.uuid }).
+                then(function(collection) {
+                    var manifestText = '';
+                    var upload, i;
+                    for (i=0; i<uploads.length; i++) {
+                        upload = uploads[i];
+                        filename = ArvadosClient.uniqueNameForManifest(
+                            collection.manifest_text,
+                            '.', upload.file.name);
+                        collection.manifest_text += '. ' +
+                            upload.locators.join(' ') +
+                            ' 0:' + upload.file.size.toString() + ':' +
+                            filename +
+                            '\n';
+                    }
+                    return ArvadosClient.apiPromise(
+                        'collections', 'update',
+                        { uuid: $scope.uuid,
+                          collection:
+                          { manifest_text:
+                            collection.manifest_text }
+                        }).
+                        then(deferred.resolve);
+                }, onQueueReject).then(function() {
+                    var i;
+                    for(i=0; i<uploads.length; i++) {
+                        uploads[i].committed = true;
+                    }
+                });
+            return deferred.promise.then(doQueueWork);
+        }
+    }
+}
index 7dbeac9d4ee6b59773ad842d60c572090196f898..8b5580fc38eb7a2a3cc9deb0afcdc67b399ecee6 100644 (file)
@@ -47,6 +47,9 @@ table.table-justforlayout {
     font-size: .8em;
     color: #888;
 }
+.lighten {
+    color: #888;
+}
 .arvados-filename,
 .arvados-uuid {
     font-size: .8em;
index 3270cfb376a1f30be9c214e42fe283a5a81cdc9b..6fea62563d2828e66278b31af5632ca3a8bf9811 100644 (file)
@@ -256,7 +256,9 @@ class ApplicationController < ActionController::Base
         elsif request.method.in? ['GET', 'HEAD']
           render
         else
-          redirect_to params[:return_to] || @object
+          redirect_to (params[:return_to] ||
+                       polymorphic_url(@object,
+                                       anchor: params[:redirect_to_anchor]))
         end
       }
       f.js { render }
@@ -321,15 +323,9 @@ class ApplicationController < ActionController::Base
     @new_resource_attrs.reject! { |k,v| k.to_s == 'uuid' }
     @object ||= model_class.new @new_resource_attrs, params["options"]
     if @object.save
-      respond_to do |f|
-        f.json { render json: @object.attributes.merge(href: url_for(action: :show, id: @object)) }
-        f.html {
-          redirect_to @object
-        }
-        f.js { render }
-      end
+      show
     else
-      self.render_error status: 422
+      render_error status: 422
     end
   end
 
index 39f637e2274d5ac12037363c8051fd17b63d5d9e..eacf8b144e22fb9eb79ef05f47445763c6d1c3e9 100644 (file)
@@ -14,7 +14,9 @@ class CollectionsController < ApplicationController
   RELATION_LIMIT = 5
 
   def show_pane_list
-    %w(Files Provenance_graph Used_by Advanced)
+    panes = %w(Files Upload Provenance_graph Used_by Advanced)
+    panes = panes - %w(Upload) unless (@object.editable? rescue false)
+    panes
   end
 
   def set_persistent
diff --git a/apps/workbench/app/views/collections/_show_upload.html.erb b/apps/workbench/app/views/collections/_show_upload.html.erb
new file mode 100644 (file)
index 0000000..f85f628
--- /dev/null
@@ -0,0 +1,62 @@
+<div class="arv-log-refresh-control"
+     data-load-throttle="86486400000" <%# 1001 nights %>
+     ></div>
+<div ng-cloak ng-controller="UploadToCollection" arv-uuid="<%= @object.uuid %>">
+  <div class="panel panel-primary">
+    <div class="panel-body">
+      <div class="row">
+        <div class="col-sm-4">
+          <input type="file" multiple ng-model="incoming" onchange="angular.element(this).scope().addFilesToQueue(this.files); $(this).val('');">
+          <div class="btn-group btn-group-sm" role="group" style="margin-top: 1.5em">
+            <button type="button" class="btn btn-default" ng-click="stop()" ng-disabled="uploader.state != 'Running'"><i class="fa fa-fw fa-pause"></i> Pause</button>
+            <button type="button" class="btn btn-primary" ng-click="go()" ng-disabled="uploader.state == 'Running' || uploadQueue.length == 0"><i class="fa fa-fw fa-play"></i> Start</button>
+          </div>
+        </div>
+        <div class="col-sm-8">
+          <div ng-show="uploader.state == 'Running'"
+               class="alert alert-info"
+               ><i class="fa fa-gear"></i>
+            Upload in progress.
+            <span ng-show="countDone() > 0">
+              {{countDone()}} file{{countDone()>1?'s':''}} finished.
+            </span>
+          </div>
+          <div ng-show="uploader.state == 'Idle' && uploader.stateReason"
+               class="alert alert-success"
+               ><i class="fa fa-flag-checkered"></i> {{uploader.stateReason}}
+          </div>
+          <div ng-show="uploader.state == 'Failed'"
+               class="alert alert-danger"
+               ><i class="fa fa-warning"></i> {{uploader.stateReason}}
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div ng-repeat="upload in uploadQueue" class="row" ng-class="{lighten: upload.committed}">
+    <div class="col-sm-1">
+      <button class="btn btn-xs btn-default"
+              ng-show="!upload.committed"
+              ng-click="removeFileFromQueue($index)"
+              title="cancel"><i class="fa fa-fw fa-times"></i></button>
+      <span class="label label-success label-info"
+            ng-show="upload.committed">finished</span>
+    </div>
+    <div class="col-sm-4 nowrap" style="overflow-x:hidden;text-overflow:ellipsis">
+      <span title="{{upload.file.name}}">
+        {{upload.file.name}}
+      </span>
+    </div>
+    <div class="col-sm-1" style="text-align: right">
+      {{upload.file.size/1024 | number:0}}K
+    </div>
+    <div class="col-sm-2">
+      <div class="progress">
+        <span class="progress-bar" style="width: {{upload.progress}}%"></span>
+      </div>
+    </div>
+    <div class="col-sm-4" ng-class="{lighten: upload.state != 'Uploading'}">
+      {{upload.statistics}}
+    </div>
+  </div>
+</div>
index 324714e5346efe574fa6e74465c7fdeb2827b9ec..cdc47c17169401995a24a77c9e2a8cdcbc46d72f 100644 (file)
@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html>
+<html ng-app="Workbench">
 <head>
   <meta charset="utf-8">
   <title>
@@ -23,6 +23,8 @@
   <%= csrf_meta_tags %>
   <%= yield :head %>
   <%= javascript_tag do %>
+    angular.module('Arvados').value('arvadosApiToken', '<%=Thread.current[:arvados_api_token]%>');
+    angular.module('Arvados').value('arvadosDiscoveryUri', '<%= Rails.configuration.arvados_v1_base.sub '/arvados/v1', '/discovery/v1/apis/arvados/v1/rest' %>');
   <%= yield :js %>
   <% end %>
   <style>
index 0429f33b4102920ab424f13a887321af9dd4f7f7..58d8f4e95482d1d44adae8128692a5546db8292e 100644 (file)
@@ -6,17 +6,32 @@
 
 <% content_for :tab_line_buttons do %>
   <% if @object.editable? %>
-    <%= link_to(
-         choose_collections_path(
-           title: 'Add data to project:',
-           multiple: true,
-           action_name: 'Add',
-           action_href: actions_path(id: @object.uuid),
-           action_method: 'post',
-           action_data: {selection_param: 'selection[]', copy_selections_into_project: @object.uuid, success: 'page-refresh'}.to_json),
-         { class: "btn btn-primary btn-sm", remote: true, method: 'get', title: "Add data to this project", data: {'event-after-select' => 'page-refresh'} }) do %>
-      <i class="fa fa-fw fa-plus"></i> Add data...
-    <% end %>
+    <div class="btn-group btn-group-sm">
+      <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">Add data <span class="caret"></span></button>
+      <ul class="dropdown-menu" role="menu">
+        <li>
+          <%= link_to(
+                choose_collections_path(
+                  title: 'Choose a collection to copy into this project:',
+                  multiple: true,
+                  action_name: 'Copy',
+                  action_href: actions_path(id: @object.uuid),
+                  action_method: 'post',
+                  action_data: {selection_param: 'selection[]', copy_selections_into_project: @object.uuid, success: 'page-refresh'}.to_json),
+                { remote: true, method: 'get', title: "Copy a collection from another project into this one", data: {'event-after-select' => 'page-refresh', 'toggle' => 'dropdown'} }) do %>
+            <i class="fa fa-fw fa-clipboard"></i> ...from a different project
+          <% end %>
+        </li>
+        <li>
+          <%= link_to(collections_path(options: {ensure_unique_name: true}, collection: {manifest_text: "", name: "New collection", owner_uuid: @object.uuid}, redirect_to_anchor: 'Upload'), {
+              method: 'post',
+              title: "Upload files into a new collection",
+              data: {toggle: 'dropdown'}}) do %>
+            <i class="fa fa-fw fa-upload"></i> ...from your computer
+          <% end %>
+        </li>
+      </ul>
+    </div>
     <%= link_to(
          choose_pipeline_templates_path(
            title: 'Choose a pipeline to run:',
index 7e3696c3896e85a2f54a534bca901d926cdc5b1c..1669376b9113a1aae0d18157da9d91692396bc32 100644 (file)
@@ -34,10 +34,11 @@ class PipelineInstancesTest < ActionDispatch::IntegrationTest
     find("#projects-menu").click
     find('.dropdown-menu a,button', text: 'A Project').click
     find('.btn', text: 'Add data').click
+    find('.dropdown-menu a,button', text: '...from a different project').click
     within('.modal-dialog') do
       wait_for_ajax
       first('span', text: 'foo_tag').click
-      find('.btn', text: 'Add').click
+      find('.btn', text: 'Copy').click
     end
     using_wait_time(Capybara.default_wait_time * 3) do
       wait_for_ajax
@@ -124,10 +125,11 @@ class PipelineInstancesTest < ActionDispatch::IntegrationTest
     find("#projects-menu").click
     find('.dropdown-menu a,button', text: 'A Project').click
     find('.btn', text: 'Add data').click
+    find('.dropdown-menu a,button', text: '...from a different project').click
     within('.modal-dialog') do
       wait_for_ajax
       first('span', text: 'foo_tag').click
-      find('.btn', text: 'Add').click
+      find('.btn', text: 'Copy').click
     end
     using_wait_time(Capybara.default_wait_time * 3) do
       wait_for_ajax