From 556503e1f98b8e262fcc1227ac4afdc78a2c05ca Mon Sep 17 00:00:00 2001 From: Tom Clegg Date: Sat, 22 Nov 2014 14:40:34 -0500 Subject: [PATCH] 3781: Add browser->api/keepproxy angular app as Upload tab on collections#show --- apps/workbench/Gemfile | 2 + apps/workbench/Gemfile.lock | 2 + .../app/assets/javascripts/angular_shim.js | 12 + .../app/assets/javascripts/application.js | 7 +- .../app/assets/javascripts/arvados_client.js | 94 ++++ .../app/assets/javascripts/tab_panes.js | 2 +- .../javascripts/upload_to_collection.js | 419 ++++++++++++++++++ .../assets/stylesheets/application.css.scss | 3 + .../app/controllers/application_controller.rb | 14 +- .../app/controllers/collections_controller.rb | 4 +- .../views/collections/_show_upload.html.erb | 62 +++ .../app/views/layouts/application.html.erb | 4 +- .../app/views/projects/show.html.erb | 37 +- .../integration/pipeline_instances_test.rb | 6 +- 14 files changed, 637 insertions(+), 31 deletions(-) create mode 100644 apps/workbench/app/assets/javascripts/angular_shim.js create mode 100644 apps/workbench/app/assets/javascripts/arvados_client.js create mode 100644 apps/workbench/app/assets/javascripts/upload_to_collection.js create mode 100644 apps/workbench/app/views/collections/_show_upload.html.erb diff --git a/apps/workbench/Gemfile b/apps/workbench/Gemfile index 5ab6eace2c..365cefc458 100644 --- a/apps/workbench/Gemfile +++ b/apps/workbench/Gemfile @@ -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' diff --git a/apps/workbench/Gemfile.lock b/apps/workbench/Gemfile.lock index 8b9ea947bb..8c188476c8 100644 --- a/apps/workbench/Gemfile.lock +++ b/apps/workbench/Gemfile.lock @@ -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 index 0000000000..a480eaf8ff --- /dev/null +++ b/apps/workbench/app/assets/javascripts/angular_shim.js @@ -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); + }); + } +}); diff --git a/apps/workbench/app/assets/javascripts/application.js b/apps/workbench/app/assets/javascripts/application.js index 1990b8b0f5..6b98fd93cc 100644 --- a/apps/workbench/app/assets/javascripts/application.js +++ b/apps/workbench/app/assets/javascripts/application.js @@ -23,15 +23,10 @@ //= 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 index 0000000000..584928f6b4 --- /dev/null +++ b/apps/workbench/app/assets/javascripts/arvados_client.js @@ -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 _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 + > +
+
+
+
+
+ +
+ + +
+
+
+
+ Upload in progress. + + {{countDone()}} file{{countDone()>1?'s':''}} finished. + +
+
{{uploader.stateReason}} +
+
{{uploader.stateReason}} +
+
+
+
+
+
+
+ + finished +
+
+ + {{upload.file.name}} + +
+
+ {{upload.file.size/1024 | number:0}}K +
+
+
+ +
+
+
+ {{upload.statistics}} +
+
+
diff --git a/apps/workbench/app/views/layouts/application.html.erb b/apps/workbench/app/views/layouts/application.html.erb index 324714e534..cdc47c1716 100644 --- a/apps/workbench/app/views/layouts/application.html.erb +++ b/apps/workbench/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - + @@ -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> diff --git a/apps/workbench/app/views/projects/show.html.erb b/apps/workbench/app/views/projects/show.html.erb index 0429f33b41..58d8f4e954 100644 --- a/apps/workbench/app/views/projects/show.html.erb +++ b/apps/workbench/app/views/projects/show.html.erb @@ -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:', diff --git a/apps/workbench/test/integration/pipeline_instances_test.rb b/apps/workbench/test/integration/pipeline_instances_test.rb index 7e3696c389..1669376b91 100644 --- a/apps/workbench/test/integration/pipeline_instances_test.rb +++ b/apps/workbench/test/integration/pipeline_instances_test.rb @@ -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 -- 2.39.5