From: Tom Clegg Date: Sat, 16 Aug 2014 06:06:20 +0000 (-0400) Subject: Merge branch '2800-python-global-state' into 2800-pgs X-Git-Tag: 1.1.0~2316^2~5 X-Git-Url: https://git.arvados.org/arvados.git/commitdiff_plain/0bd1c28bed9a0756c61037947d5a9dccd5066f00?hp=c6db15d0529212126920002665c9c99a4f864529 Merge branch '2800-python-global-state' into 2800-pgs Conflicts: sdk/python/arvados/api.py --- diff --git a/.gitignore b/.gitignore index 0cddee596c..8cc6b89324 100644 --- a/.gitignore +++ b/.gitignore @@ -11,11 +11,9 @@ sdk/perl/Makefile sdk/perl/blib sdk/perl/pm_to_blib */vendor/bundle -services/keep/bin -services/keep/pkg -services/keep/src/github.com sdk/java/target *.class apps/workbench/vendor/bundle services/api/vendor/bundle sdk/java/log +sdk/cli/vendor diff --git a/apps/workbench/Gemfile b/apps/workbench/Gemfile index 754d5c6043..a289303e4d 100644 --- a/apps/workbench/Gemfile +++ b/apps/workbench/Gemfile @@ -49,6 +49,8 @@ gem 'bootstrap-x-editable-rails' gem 'less' gem 'less-rails' +gem 'wiselinks' +gem 'sshkey' # To use ActiveModel has_secure_password # gem 'bcrypt-ruby', '~> 3.0.0' diff --git a/apps/workbench/Gemfile.lock b/apps/workbench/Gemfile.lock index 173be13cc3..3daa501ca5 100644 --- a/apps/workbench/Gemfile.lock +++ b/apps/workbench/Gemfile.lock @@ -167,6 +167,7 @@ GEM activesupport (>= 3.0) sprockets (~> 2.8) sqlite3 (1.3.8) + sshkey (1.6.1) therubyracer (0.12.0) libv8 (~> 3.16.14.0) ref @@ -183,6 +184,7 @@ GEM json (>= 1.8.0) websocket (1.0.7) websocket-driver (0.3.2) + wiselinks (1.2.1) xpath (2.0.0) nokogiri (~> 1.3) @@ -216,6 +218,8 @@ DEPENDENCIES simplecov (~> 0.7.1) simplecov-rcov sqlite3 + sshkey themes_for_rails! therubyracer uglifier (>= 1.0.3) + wiselinks diff --git a/apps/workbench/app/assets/javascripts/application.js b/apps/workbench/app/assets/javascripts/application.js index d21c4b5625..c4cb68322f 100644 --- a/apps/workbench/app/assets/javascripts/application.js +++ b/apps/workbench/app/assets/javascripts/application.js @@ -21,6 +21,7 @@ //= require bootstrap/modal //= require bootstrap/button //= require bootstrap3-editable/bootstrap-editable +//= require wiselinks //= require_tree . jQuery(function($){ @@ -135,11 +136,13 @@ jQuery(function($){ on('ajax:complete ready', function() { // See http://getbootstrap.com/javascript/#buttons $('.btn').button(); - }); - - $(document). + }). on('ready ajax:complete', function() { $('[data-toggle~=tooltip]').tooltip({container:'body'}); + }). + on('ready ajax:complete', function() { + // This makes the dialog close on Esc key, obviously. + $('.modal').attr('tabindex', '-1') }); HeaderRowFixer = function(selector) { @@ -177,4 +180,29 @@ jQuery(function($){ fixer.duplicateTheadTr(); fixer.fixThead(); }); + + $(document).ready(function() { + /* When wiselinks is initialized, selection.js is not working. Since we want to stop + using selection.js in the near future, let's not initialize wiselinks for now. */ + + // window.wiselinks = new Wiselinks(); + + $(document).off('page:loading').on('page:loading', function(event, $target, render, url){ + $("#page-wrapper").fadeOut(200); + }); + + $(document).off('page:redirected').on('page:redirected', function(event, $target, render, url){ + }); + + $(document).off('page:always').on('page:always', function(event, xhr, settings){ + $("#page-wrapper").fadeIn(200); + }); + + $(document).off('page:done').on('page:done', function(event, $target, status, url, data){ + }); + + $(document).off('page:fail').on('page:fail', function(event, $target, status, url, error, code){ + }); + }); + }); diff --git a/apps/workbench/app/assets/javascripts/filterable.js b/apps/workbench/app/assets/javascripts/filterable.js index 76c5ac3ca1..7da461ec44 100644 --- a/apps/workbench/app/assets/javascripts/filterable.js +++ b/apps/workbench/app/assets/javascripts/filterable.js @@ -1,11 +1,12 @@ $(document). - on('paste keyup change', 'input[type=text].filterable-control', function() { + on('paste keyup input', 'input[type=text].filterable-control', function() { var q = new RegExp($(this).val(), 'i'); $($(this).attr('data-filterable-target')). addClass('filterable-container'). data('q', q). trigger('refresh'); }).on('refresh', '.filterable-container', function() { + var $container = $(this); var q = $(this).data('q'); var filters = $(this).data('filters'); $('.filterable', this).hide().filter(function() { @@ -26,6 +27,16 @@ $(document). } return pass; }).show(); + + // Show/hide each section heading depending on whether any + // content rows are visible in that section. + $('.row[data-section-heading]', this).each(function(){ + $(this).toggle($('.row.filterable[data-section-name="' + + $(this).attr('data-section-name') + + '"]:visible').length > 0); + }); + + // Load more content if the last result is showing. $('.infinite-scroller').add(window).trigger('scroll'); }).on('change', 'select.filterable-control', function() { var val = $(this).val(); @@ -38,5 +49,5 @@ $(document). data('filters', filters). trigger('refresh'); }).on('ajax:complete', function() { - $('.filterable-control').trigger('change'); + $('.filterable-control').trigger('input'); }); diff --git a/apps/workbench/app/assets/javascripts/infinite_scroll.js b/apps/workbench/app/assets/javascripts/infinite_scroll.js index 1f9997efa0..e70ca259e4 100644 --- a/apps/workbench/app/assets/javascripts/infinite_scroll.js +++ b/apps/workbench/app/assets/javascripts/infinite_scroll.js @@ -1,25 +1,31 @@ -function maybe_load_more_content() { +function maybe_load_more_content(event) { var scroller = this; // element with scroll bars - var container; // element that receives new content + var $container; // element that receives new content var src; // url for retrieving content var scrollHeight; var spinner, colspan; + var serial = Date.now(); scrollHeight = scroller.scrollHeight || $('body')[0].scrollHeight; - var num_scrollers = $(window).data("arv-num-scrollers"); if ($(scroller).scrollTop() + $(scroller).height() > scrollHeight - 50) { - for (var i = 0; i < num_scrollers; i++) { - $container = $($(this).data('infinite-container'+i)); + $container = $(event.data.container); + if (!$container.attr('data-infinite-content-href0')) { + // Remember the first page source url, so we can refresh + // from page 1 later. + $container.attr('data-infinite-content-href0', + $container.attr('data-infinite-content-href')); + } src = $container.attr('data-infinite-content-href'); if (!src || !$container.is(':visible')) - continue; + // Finished + return; // Don't start another request until this one finishes $container.attr('data-infinite-content-href', null); spinner = '
'; - if ($(container).is('table,tbody,thead,tfoot')) { + if ($container.is('table,tbody,thead,tfoot')) { // Hack to determine how many columns a new tr should have // in order to reach full width. colspan = $container.closest('table'). @@ -30,16 +36,21 @@ function maybe_load_more_content() { spinner + ''); } + $container.find(".spinner").detach(); $container.append(spinner); + $container.attr('data-infinite-serial', serial); $.ajax(src, {dataType: 'json', type: 'GET', - data: {}, - context: {container: $container, src: src}}). - always(function() { - $(this.container).find(".spinner").detach(); - }). + data: ($container.data('infinite-content-params') || {}), + context: {container: $container, src: src, serial: serial}}). fail(function(jqxhr, status, error) { + var $faildiv; + var $container = this.container; + if ($container.attr('data-infinite-serial') != this.serial) { + // A newer request is already in progress. + return; + } if (jqxhr.readyState == 0 || jqxhr.status == 0) { message = "Cancelled." } else if (jqxhr.responseJSON && jqxhr.responseJSON.errors) { @@ -47,32 +58,60 @@ function maybe_load_more_content() { } else { message = "Request failed."; } - // TODO: report this to the user. + // TODO: report the message to the user. console.log(message); - $(this.container).attr('data-infinite-content-href', this.src); + $faildiv = $('
'). + attr('data-infinite-content-href', this.src). + addClass('infinite-retry'). + append(' Oops, request failed. '); + $container.find('div.spinner').replaceWith($faildiv); }). done(function(data, status, jqxhr) { - $(this.container).append(data.content); - $(this.container).attr('data-infinite-content-href', data.next_page_href); + if ($container.attr('data-infinite-serial') != this.serial) { + // A newer request is already in progress. + return; + } + $container.find(".spinner").detach(); + $container.append(data.content); + $container.attr('data-infinite-content-href', data.next_page_href); }); - break; } - } } $(document). + on('click', 'div.infinite-retry button', function() { + var $retry_div = $(this).closest('.infinite-retry'); + var $scroller = $(this).closest('.infinite-scroller') + $scroller.attr('data-infinite-content-href', + $retry_div.attr('data-infinite-content-href')); + $retry_div.replaceWith('
'); + $scroller.trigger('scroll'); + }). + on('refresh-content', '[data-infinite-scroller]', function() { + // Clear all rows, reset source href to initial state, and + // (if the container is visible) start loading content. + var first_page_href = $(this).attr('data-infinite-content-href0'); + if (!first_page_href) + first_page_href = $(this).attr('data-infinite-content-href'); + $(this). + html(''). + attr('data-infinite-content-href', first_page_href); + $('.infinite-scroller'). + trigger('scroll'); + }). on('ready ajax:complete', function() { - var num_scrollers = 0; $('[data-infinite-scroller]').each(function() { + if ($(this).hasClass('infinite-scroller-ready')) + return; + $(this).addClass('infinite-scroller-ready'); + var $scroller = $($(this).attr('data-infinite-scroller')); if (!$scroller.hasClass('smart-scroll') && 'scroll' != $scroller.css('overflow-y')) $scroller = $(window); $scroller. addClass('infinite-scroller'). - data('infinite-container'+num_scrollers, this). - on('scroll', maybe_load_more_content); - num_scrollers++; + on('scroll resize', { container: this }, maybe_load_more_content). + trigger('scroll'); }); - $(window).data("arv-num-scrollers", num_scrollers); }); diff --git a/apps/workbench/app/assets/javascripts/select_modal.js b/apps/workbench/app/assets/javascripts/select_modal.js index cd23556a4a..1fec3dcb95 100644 --- a/apps/workbench/app/assets/javascripts/select_modal.js +++ b/apps/workbench/app/assets/javascripts/select_modal.js @@ -1,14 +1,14 @@ $(document).on('click', '.selectable', function() { var any; var $this = $(this); - if (!$this.hasClass('multiple')) { - $this.closest('.selectable-container'). + var $container = $(this).closest('.selectable-container'); + if (!$container.hasClass('multiple')) { + $container. find('.selectable'). removeClass('active'); } $this.toggleClass('active'); - any = ($this. - closest('.selectable-container'). + any = ($container. find('.selectable.active').length > 0) $this. closest('.modal'). @@ -16,14 +16,19 @@ $(document).on('click', '.selectable', function() { prop('disabled', !any); if ($this.hasClass('active')) { + var no_preview_available = '
(No preview available)
'; + if (!$this.attr('data-preview-href')) { + $(".modal-dialog-preview-pane").html(no_preview_available); + return; + } $(".modal-dialog-preview-pane").html('
'); $.ajax($this.attr('data-preview-href'), {dataType: "html"}). - done(function(data, status, jqxhr) { + done(function(data, status, jqxhr) { $(".modal-dialog-preview-pane").html(data); }). fail(function(data, status, jqxhr) { - $(".modal-dialog-preview-pane").text('Preview load failed.'); + $(".modal-dialog-preview-pane").html(no_preview_available); }); } @@ -65,12 +70,72 @@ $(document).on('click', '.selectable', function() { $(document).trigger(event_name!=null ? event_name : 'page-refresh', [data, status, jqxhr, this.action_data]); }); -}); -$(document).on('page-refresh', function(event, data, status, jqxhr, action_data) { +}).on('click', '.chooser-show-project', function() { + var params = {}; + var project_uuid = $(this).attr('data-project-uuid'); + $(this).attr('href', '#'); // Skip normal click handler + if (project_uuid) { + params = {'filters[]': JSON.stringify(['owner_uuid', + '=', + project_uuid]), + project_uuid: project_uuid + }; + } + // Use current selection as dropdown button label + $(this). + closest('.dropdown-menu'). + prev('button'). + html($(this).text() + ' '); + // Set (or unset) filter params and refresh filterable rows + $($(this).closest('[data-filterable-target]').attr('data-filterable-target')). + data('infinite-content-params', params). + trigger('refresh-content'); +}).on('ready', function() { + $('form[data-search-modal] a').on('click', function() { + $(this).closest('form').submit(); + return false; + }); + $('form[data-search-modal]').on('submit', function() { + // Ask the server for a Search modal. When it arrives, copy + // the search string from the top nav input into the modal's + // search query field. + var $form = $(this); + var searchq = $form.find('input').val(); + var is_a_uuid = /^([0-9a-f]{32}(\+\S+)?|[0-9a-z]{5}-[0-9a-z]{5}-[0-9a-z]{15})$/; + if (searchq.trim().match(is_a_uuid)) { + window.location = '/actions?uuid=' + encodeURIComponent(searchq.trim()); + // Show the "loading" indicator. TODO: better page transition hook + $(document).trigger('ajax:send'); + return false; + } + if ($form.find('a[data-remote]').length > 0) { + // A search dialog is already loading. + return false; + } + $(''). + attr('href', $form.attr('data-search-modal')). + attr('data-remote', 'true'). + attr('data-method', 'GET'). + hide(). + appendTo($form). + on('ajax:success', function(data, status, xhr) { + $('body > .modal-container input[type=text]'). + val($form.find('input').val()). + focus(); + $form.find('input').val(''); + }).on('ajax:complete', function() { + $(this).detach(); + }). + click(); + return false; + }); +}).on('page-refresh', function(event, data, status, jqxhr, action_data) { window.location.reload(); }).on('tab-refresh', function(event, data, status, jqxhr, action_data) { $(document).trigger('arv:pane:reload:all'); $('body > .modal-container .modal').modal('hide'); }).on('redirect-to-created-object', function(event, data, status, jqxhr, action_data) { window.location.href = data.href.replace(/^[^\/]*\/\/[^\/]*/, ''); +}).on('shown.bs.modal', 'body > .modal-container .modal', function() { + $('.focus-on-display', this).focus(); }); diff --git a/apps/workbench/app/assets/stylesheets/application.css.scss b/apps/workbench/app/assets/stylesheets/application.css.scss index 75d58ed517..6b91783e60 100644 --- a/apps/workbench/app/assets/stylesheets/application.css.scss +++ b/apps/workbench/app/assets/stylesheets/application.css.scss @@ -146,6 +146,10 @@ nav.navbar-fixed-top .navbar-nav.navbar-right > li > a:hover { margin-bottom: -15px; } +.infinite-scroller .fa-warning { + color: #800; +} + .inline-progress-container div.progress { margin-bottom: 0; } @@ -240,3 +244,7 @@ div.pane-content iframe { width: 100%; border: none; } + +div.rounded { + border-radius: 3px; +} diff --git a/apps/workbench/app/assets/stylesheets/select_modal.css.scss b/apps/workbench/app/assets/stylesheets/select_modal.css.scss index 09017697b6..4295f30ece 100644 --- a/apps/workbench/app/assets/stylesheets/select_modal.css.scss +++ b/apps/workbench/app/assets/stylesheets/select_modal.css.scss @@ -14,3 +14,6 @@ background: #428bca; color: #fff; } +.selectable-container > .row.class-separator { + background: #ddd; +} diff --git a/apps/workbench/app/controllers/actions_controller.rb b/apps/workbench/app/controllers/actions_controller.rb index 9a76e9aed4..d1dc0fca35 100644 --- a/apps/workbench/app/controllers/actions_controller.rb +++ b/apps/workbench/app/controllers/actions_controller.rb @@ -10,6 +10,19 @@ class ActionsController < ApplicationController ArvadosBase::resource_class_for_uuid(params[:uuid]) end + def show + @object = model_class.andand.find(params[:uuid]) + if @object.is_a? Link and + @object.link_class == 'name' and + ArvadosBase::resource_class_for_uuid(@object.head_uuid) == Collection + redirect_to collection_path(id: @object.uuid) + elsif @object + redirect_to @object + else + raise ActiveRecord::RecordNotFound + end + end + def post params.keys.collect(&:to_sym).each do |param| if @@exposed_actions[param] diff --git a/apps/workbench/app/controllers/application_controller.rb b/apps/workbench/app/controllers/application_controller.rb index f739ee1046..222888085d 100644 --- a/apps/workbench/app/controllers/application_controller.rb +++ b/apps/workbench/app/controllers/application_controller.rb @@ -12,8 +12,11 @@ class ApplicationController < ActionController::Base # Methods that don't require login should # skip_around_filter :require_thread_api_token around_filter :require_thread_api_token, except: ERROR_ACTIONS + before_filter :accept_uuid_as_id_param, except: ERROR_ACTIONS before_filter :check_user_agreements, except: ERROR_ACTIONS + before_filter :check_user_profile, except: [:update_profile] + ERROR_ACTIONS before_filter :check_user_notifications, except: ERROR_ACTIONS + before_filter :load_filters_and_paging_params, except: ERROR_ACTIONS before_filter :find_object_by_uuid, except: [:index, :choose] + ERROR_ACTIONS theme :select_theme @@ -86,7 +89,10 @@ class ApplicationController < ActionController::Base end end - def find_objects_for_index + def load_filters_and_paging_params + @order = params[:order] || 'created_at desc' + @order = [@order] unless @order.is_a? Array + @limit ||= 200 if params[:limit] @limit = params[:limit].to_i @@ -102,10 +108,22 @@ class ApplicationController < ActionController::Base filters = params[:filters] if filters.is_a? String filters = Oj.load filters + elsif filters.is_a? Array + filters = filters.collect do |filter| + if filter.is_a? String + # Accept filters[]=["foo","=","bar"] + Oj.load filter + else + # Accept filters=[["foo","=","bar"]] + filter + end + end end @filters += filters end + end + def find_objects_for_index @objects ||= model_class @objects = @objects.filter(@filters).limit(@limit).offset(@offset) end @@ -114,10 +132,8 @@ class ApplicationController < ActionController::Base respond_to do |f| f.json { render json: @objects } f.html { - if params['tab_pane'] - comparable = self.respond_to? :compare - render(partial: 'show_' + params['tab_pane'].downcase, - locals: { comparable: comparable, objects: @objects }) + if params[:tab_pane] + render_pane params[:tab_pane] else render end @@ -126,6 +142,23 @@ class ApplicationController < ActionController::Base end end + helper_method :render_pane + def render_pane tab_pane, opts={} + render_opts = { + partial: 'show_' + tab_pane.downcase, + locals: { + comparable: self.respond_to?(:compare), + objects: @objects, + tab_pane: tab_pane + }.merge(opts[:locals] || {}) + } + if opts[:to_string] + render_to_string render_opts + else + render render_opts + end + end + def index find_objects_for_index if !@objects render_index @@ -148,6 +181,13 @@ class ApplicationController < ActionController::Base end end + helper_method :next_page_href + def next_page_href with_params={} + if next_page_offset + url_for with_params.merge(offset: next_page_offset) + end + end + def show if !@object return render_not_found("object not found") @@ -156,9 +196,7 @@ class ApplicationController < ActionController::Base f.json { render json: @object.attributes.merge(href: url_for(@object)) } f.html { if params['tab_pane'] - comparable = self.respond_to? :compare - render(partial: 'show_' + params['tab_pane'].downcase, - locals: { comparable: comparable, objects: @objects }) + render_pane params['tab_pane'] elsif request.method.in? ['GET', 'HEAD'] render else @@ -171,26 +209,14 @@ class ApplicationController < ActionController::Base def choose params[:limit] ||= 40 - if !@objects - if params[:project_uuid] and !params[:project_uuid].empty? - # We want the chooser to show objects of the controllers's model_class - # type within a specific project specified by project_uuid, so fetch the - # project and request the contents of the project filtered on the - # controllers's model_class kind. - @objects = Group.find(params[:project_uuid]).contents({:filters => [['uuid', 'is_a', "arvados\##{ArvadosApiClient.class_kind(model_class)}"]]}) - end - find_objects_for_index if !@objects - end + find_objects_for_index if !@objects respond_to do |f| if params[:partial] f.json { render json: { content: render_to_string(partial: "choose_rows.html", - formats: [:html], - locals: { - multiple: params[:multiple] - }), - next_page_href: @next_page_href + formats: [:html]), + next_page_href: next_page_href(partial: params[:partial]) } } end @@ -341,10 +367,14 @@ class ApplicationController < ActionController::Base end end - def find_object_by_uuid + + def accept_uuid_as_id_param if params[:id] and params[:id].match /\D/ params[:uuid] = params.delete :id end + end + + def find_object_by_uuid begin if not model_class @object = nil @@ -380,9 +410,6 @@ class ApplicationController < ActionController::Base Thread.current[:arvados_api_token] = new_token if new_token.nil? Thread.current[:user] = nil - elsif (new_token == session[:arvados_api_token]) and - session[:user].andand[:is_active] - Thread.current[:user] = User.new(session[:user]) else Thread.current[:user] = User.current end @@ -400,15 +427,7 @@ class ApplicationController < ActionController::Base false # We may redirect to login, or not, based on the current action. else session[:arvados_api_token] = params[:api_token] - session[:user] = { - uuid: user.uuid, - email: user.email, - first_name: user.first_name, - last_name: user.last_name, - is_active: user.is_active, - is_admin: user.is_admin, - prefs: user.prefs - } + if !request.format.json? and request.method.in? ['GET', 'HEAD'] # Repeat this request with api_token in the (new) session # cookie instead of the query string. This prevents API @@ -502,6 +521,41 @@ class ApplicationController < ActionController::Base true end + def check_user_profile + if request.method.downcase != 'get' || params[:partial] || + params[:tab_pane] || params[:action_method] || + params[:action] == 'setup_popup' + return true + end + + if missing_required_profile? + render 'users/profile' + end + true + end + + helper_method :missing_required_profile? + def missing_required_profile? + missing_required = false + + profile_config = Rails.configuration.user_profile_form_fields + if current_user && profile_config + current_user_profile = current_user.prefs[:profile] + profile_config.kind_of?(Array) && profile_config.andand.each do |entry| + if entry['required'] + if !current_user_profile || + !current_user_profile[entry['key'].to_sym] || + current_user_profile[entry['key'].to_sym].empty? + missing_required = true + break + end + end + end + end + + missing_required + end + def select_theme return Rails.configuration.arvados_theme end @@ -569,7 +623,7 @@ class ApplicationController < ActionController::Base helper_method :all_projects def all_projects @all_projects ||= Group. - filter([['group_class','in',['project','folder']]]).order('name') + filter([['group_class','=','project']]).order('name') end helper_method :my_projects @@ -609,8 +663,8 @@ class ApplicationController < ActionController::Base (Job.limit(10) | PipelineInstance.limit(10)). sort_by do |x| - x.finished_at || x.started_at || x.created_at rescue x.created_at - end + (x.finished_at || x.started_at rescue nil) || x.modified_at || x.created_at + end.reverse end helper_method :my_project_tree @@ -680,7 +734,7 @@ class ApplicationController < ActionController::Base crumbs = [] current = @name_link || @object while current - if current.is_a?(Group) and current.group_class.in?(['project','folder']) + if current.is_a?(Group) and current.group_class == 'project' crumbs.prepend current end if current.is_a? Link @@ -694,7 +748,7 @@ class ApplicationController < ActionController::Base helper_method :current_project_uuid def current_project_uuid - if @object.is_a? Group and @object.group_class.in?(['project','folder']) + if @object.is_a? Group and @object.group_class == 'project' @object.uuid elsif @name_link.andand.tail_uuid @name_link.tail_uuid @@ -872,4 +926,7 @@ class ApplicationController < ActionController::Base @objects_for end + def wiselinks_layout + 'body' + end end diff --git a/apps/workbench/app/controllers/collections_controller.rb b/apps/workbench/app/controllers/collections_controller.rb index 5a7a52207b..bea72a4274 100644 --- a/apps/workbench/app/controllers/collections_controller.rb +++ b/apps/workbench/app/controllers/collections_controller.rb @@ -45,22 +45,17 @@ class CollectionsController < ApplicationController def choose params[:limit] ||= 40 - filter = [['link_class','=','name'], - ['head_uuid','is_a','arvados#collection']] - - if params[:project_uuid] and !params[:project_uuid].empty? - filter << ['tail_uuid', '=', params[:project_uuid]] - end - - @objects = Link.filter(filter) + @filters += [['link_class','=','name'], + ['head_uuid','is_a','arvados#collection']] + @objects = Link find_objects_for_index - @next_page_href = (next_page_offset and - url_for(offset: next_page_offset, partial: true)) + @name_links = @objects @objects = Collection. filter([['uuid','in',@name_links.collect(&:head_uuid)]]) + preload_links_for_objects @objects.to_a super end diff --git a/apps/workbench/app/controllers/groups_controller.rb b/apps/workbench/app/controllers/groups_controller.rb index 7698fdba93..080386ea5c 100644 --- a/apps/workbench/app/controllers/groups_controller.rb +++ b/apps/workbench/app/controllers/groups_controller.rb @@ -1,6 +1,6 @@ class GroupsController < ApplicationController def index - @groups = Group.filter [['group_class', 'not in', ['folder', 'project']]] + @groups = Group.filter [['group_class', '!=', 'project']] @group_uuids = @groups.collect &:uuid @links_from = Link.where link_class: 'permission', tail_uuid: @group_uuids @links_to = Link.where link_class: 'permission', head_uuid: @group_uuids @@ -8,7 +8,7 @@ class GroupsController < ApplicationController end def show - if @object.group_class.in?(['project','folder']) + if @object.group_class == 'project' redirect_to(project_path(@object)) else super diff --git a/apps/workbench/app/controllers/projects_controller.rb b/apps/workbench/app/controllers/projects_controller.rb index f32f356d0c..6287793f76 100644 --- a/apps/workbench/app/controllers/projects_controller.rb +++ b/apps/workbench/app/controllers/projects_controller.rb @@ -3,6 +3,31 @@ class ProjectsController < ApplicationController Group end + def find_object_by_uuid + if current_user and params[:uuid] == current_user.uuid + @object = current_user.dup + @object.uuid = current_user.uuid + class << @object + def name + 'Home' + end + def description + '' + end + def attribute_editable? attr, *args + case attr + when 'description', 'name' + false + else + super + end + end + end + else + super + end + end + def index_pane_list %w(Projects) end @@ -52,6 +77,32 @@ class ProjectsController < ApplicationController end end + def move_items + target_uuid = params['target'] + uuids_to_add = session[:selected_move_items] + + uuids_to_add. + collect { |x| ArvadosBase::resource_class_for_uuid(x) }. + uniq. + each do |resource_class| + resource_class.filter([['uuid','in',uuids_to_add]]).each do |dst| + if resource_class == Collection + dst = Link.new(owner_uuid: target_uuid, + tail_uuid: target_uuid, + head_uuid: dst.uuid, + link_class: 'name', + name: target_uuid) + else + dst.owner_uuid = target_uuid + dst.tail_uuid = target_uuid if dst.class == Link + end + dst.save! + end + end + session[:selected_move_items] = nil + redirect_to @object + end + def destroy while (objects = Link.filter([['owner_uuid','=',@object.uuid], ['tail_uuid','=',@object.uuid]])).any? @@ -77,48 +128,86 @@ class ProjectsController < ApplicationController super end + def load_contents_objects kinds=[] + kind_filters = @filters.select do |attr,op,val| + op == 'is_a' and val.is_a? Array and val.count > 1 + end + if /^created_at\b/ =~ @order[0] and kind_filters.count == 1 + # If filtering on multiple types and sorting by date: Get the + # first page of each type, sort the entire set, truncate to one + # page, and use the last item on this page as a filter for + # retrieving the next page. Ideally the API would do this for + # us, but it doesn't (yet). + nextpage_operator = /\bdesc$/i =~ @order[0] ? '<' : '>' + @objects = [] + @name_link_for = {} + kind_filters.each do |attr,op,val| + (val.is_a?(Array) ? val : [val]).each do |type| + objects = @object.contents(order: @order, + limit: @limit, + include_linked: true, + filters: (@filters - kind_filters + [['uuid', 'is_a', type]]), + offset: @offset) + objects.each do |object| + @name_link_for[object.andand.uuid] = objects.links_for(object, 'name').first + end + @objects += objects + end + end + @objects = @objects.to_a.sort_by(&:created_at) + @objects.reverse! if nextpage_operator == '<' + @objects = @objects[0..@limit-1] + @next_page_filters = @filters.reject do |attr,op,val| + attr == 'created_at' and op == nextpage_operator + end + if @objects.any? + @next_page_filters += [['created_at', + nextpage_operator, + @objects.last.created_at]] + @next_page_href = url_for(partial: :contents_rows, + filters: @next_page_filters.to_json) + else + @next_page_href = nil + end + else + @objects = @object.contents(order: @order, + limit: @limit, + include_linked: true, + filters: @filters, + offset: @offset) + @next_page_href = next_page_href(partial: :contents_rows) + end + end + def show if !@object return render_not_found("object not found") end - @objects = @object.contents(limit: 50, - include_linked: true, - filters: params[:filters], - offset: params[:offset] || 0) - @logs = Log.limit(10).filter([['object_uuid', '=', @object.uuid]]) - @users = User.limit(10000). - select(["uuid", "is_active", "first_name", "last_name"]). - filter([['is_active', '=', 'true']]) - @groups = Group.limit(10000). - select(["uuid", "name", "description"]) - begin - @share_links = Link.permissions_for(@object) - @user_is_manager = true - rescue ArvadosApiClient::AccessForbiddenException, - ArvadosApiClient::NotFoundException - @share_links = [] - @user_is_manager = false + @user_is_manager = false + @share_links = [] + if @object.uuid != current_user.uuid + begin + @share_links = Link.permissions_for(@object) + @user_is_manager = true + rescue ArvadosApiClient::AccessForbiddenException, + ArvadosApiClient::NotFoundException + end end - @objects_and_names = get_objects_and_names @objects - if params[:partial] + load_contents_objects respond_to do |f| f.json { render json: { content: render_to_string(partial: 'show_contents_rows.html', - formats: [:html], - locals: { - objects_and_names: @objects_and_names, - project: @object - }), - next_page_href: (next_page_offset and - url_for(offset: next_page_offset, filters: params[:filters], partial: true)) + formats: [:html]), + next_page_href: @next_page_href } } end else + @objects = [] super end end @@ -135,13 +224,17 @@ class ProjectsController < ApplicationController end helper_method :get_objects_and_names - def get_objects_and_names(objects) + def get_objects_and_names(objects=nil) + objects = @objects if objects.nil? objects_and_names = [] objects.each do |object| - if !(name_links = objects.links_for(object, 'name')).empty? + if objects.respond_to? :links_for and + !(name_links = objects.links_for(object, 'name')).empty? name_links.each do |name_link| objects_and_names << [object, name_link] end + elsif @name_link_for.andand[object.uuid] + objects_and_names << [object, @name_link_for[object.uuid]] elsif object.respond_to? :name objects_and_names << [object, object] else diff --git a/apps/workbench/app/controllers/search_controller.rb b/apps/workbench/app/controllers/search_controller.rb new file mode 100644 index 0000000000..6f209a5a9e --- /dev/null +++ b/apps/workbench/app/controllers/search_controller.rb @@ -0,0 +1,26 @@ +class SearchController < ApplicationController + def find_objects_for_index + search_what = Group + if params[:project_uuid] + # Special case for "search all things in project": + @filters = @filters.select do |attr, operator, operand| + not (attr == 'owner_uuid' and operator == '=') + end + # Special case for project_uuid is a user uuid: + if ArvadosBase::resource_class_for_uuid(params[:project_uuid]) == User + search_what = User.find params[:project_uuid] + else + search_what = Group.find params[:project_uuid] + end + end + @objects = search_what.contents(limit: @limit, + offset: @offset, + filters: @filters, + include_linked: true) + super + end + + def next_page_href with_params={} + super with_params.merge(last_object_class: @objects.last.class.to_s) + end +end diff --git a/apps/workbench/app/controllers/user_agreements_controller.rb b/apps/workbench/app/controllers/user_agreements_controller.rb index 6ab8ae215f..924bf44bae 100644 --- a/apps/workbench/app/controllers/user_agreements_controller.rb +++ b/apps/workbench/app/controllers/user_agreements_controller.rb @@ -1,6 +1,7 @@ class UserAgreementsController < ApplicationController skip_before_filter :check_user_agreements skip_before_filter :find_object_by_uuid + skip_before_filter :check_user_profile def model_class Collection diff --git a/apps/workbench/app/controllers/users_controller.rb b/apps/workbench/app/controllers/users_controller.rb index 0313de5aa2..67b51a9bc9 100644 --- a/apps/workbench/app/controllers/users_controller.rb +++ b/apps/workbench/app/controllers/users_controller.rb @@ -210,6 +210,73 @@ class UsersController < ApplicationController end end + def manage_account + # repositories current user can read / write + repo_links = [] + Link.filter([['head_uuid', 'is_a', 'arvados#repository'], + ['tail_uuid', '=', current_user.uuid], + ['link_class', '=', 'permission'], + ['name', 'in', ['can_write', 'can_read']], + ]). + each do |perm_link| + repo_links << perm_link[:head_uuid] + end + @my_repositories = Repository.where(uuid: repo_links) + + # virtual machines the current user can login into + @my_vm_logins = {} + Link.where(tail_uuid: current_user.uuid, + link_class: 'permission', + name: 'can_login'). + each do |perm_link| + if perm_link.properties.andand[:username] + @my_vm_logins[perm_link.head_uuid] ||= [] + @my_vm_logins[perm_link.head_uuid] << perm_link.properties[:username] + end + end + @my_virtual_machines = VirtualMachine.where(uuid: @my_vm_logins.keys) + + # current user's ssh keys + @my_ssh_keys = AuthorizedKey.where(key_type: 'SSH', owner_uuid: current_user.uuid) + + respond_to do |f| + f.html { render template: 'users/manage_account' } + end + end + + def add_ssh_key_popup + respond_to do |format| + format.html + format.js + end + end + + def add_ssh_key + respond_to do |format| + key_params = {'key_type' => 'SSH'} + key_params['authorized_user_uuid'] = current_user.uuid + + if params['name'] && params['name'].size>0 + key_params['name'] = params['name'].strip + end + if params['public_key'] && params['public_key'].size>0 + key_params['public_key'] = params['public_key'].strip + end + + if !key_params['name'] && params['public_key'].andand.size>0 + split_key = key_params['public_key'].split + key_params['name'] = split_key[-1] if (split_key.size == 3) + end + + new_key = AuthorizedKey.create! key_params + if new_key + format.js + else + self.render_error status: 422 + end + end + end + protected def find_current_links user diff --git a/apps/workbench/app/helpers/application_helper.rb b/apps/workbench/app/helpers/application_helper.rb index c3856c2f29..428c14f828 100644 --- a/apps/workbench/app/helpers/application_helper.rb +++ b/apps/workbench/app/helpers/application_helper.rb @@ -260,12 +260,18 @@ module ApplicationHelper display_value = value_info[:link_name] end end + if (attr == :components) and (subattr.size > 2) + chooser_title = "Choose a dataset for #{object.component_input_title(subattr[0], subattr[2])}:" + else + chooser_title = "Choose a dataset:" + end modal_path = choose_collections_path \ - ({ title: 'Choose a dataset:', - filters: [['tail_uuid', '=', object.owner_uuid]].to_json, + ({ title: chooser_title, + filters: [['owner_uuid', '=', object.owner_uuid]].to_json, action_name: 'OK', action_href: pipeline_instance_path(id: object.uuid), action_method: 'patch', + preconfigured_search_str: "#{value_info[:search_for]}", action_data: { merge: true, selection_param: selection_param, @@ -428,4 +434,13 @@ module ApplicationHelper RESOURCE_CLASS_ICONS.fetch(class_name, default) end end + + def chooser_preview_url_for object + case object.class.to_s + when 'Collection' + polymorphic_path(object, tab_pane: 'chooser_preview') + else + nil + end + end end diff --git a/apps/workbench/app/models/group.rb b/apps/workbench/app/models/group.rb index 9e627bf66e..558c587a1c 100644 --- a/apps/workbench/app/models/group.rb +++ b/apps/workbench/app/models/group.rb @@ -3,6 +3,15 @@ class Group < ArvadosBase true end + def self.contents params={} + res = arvados_api_client.api self, "/contents", { + _method: 'GET' + }.merge(params) + ret = ArvadosResourceList.new + ret.results = arvados_api_client.unpack_api_response(res) + ret + end + def contents params={} res = arvados_api_client.api self.class, "/#{self.uuid}/contents", { _method: 'GET' @@ -13,7 +22,7 @@ class Group < ArvadosBase end def class_for_display - group_class.in?(['folder', 'project']) ? 'Project' : super + group_class == 'project' ? 'Project' : super end def editable? diff --git a/apps/workbench/app/models/pipeline_instance.rb b/apps/workbench/app/models/pipeline_instance.rb index c3b14755f3..fa9fab68eb 100644 --- a/apps/workbench/app/models/pipeline_instance.rb +++ b/apps/workbench/app/models/pipeline_instance.rb @@ -28,7 +28,7 @@ class PipelineInstance < ArvadosBase end end end - + def attribute_editable? attr, *args super && (attr.to_sym == :name || (attr.to_sym == :components and @@ -42,4 +42,11 @@ class PipelineInstance < ArvadosBase def self.creatable? false end + + def component_input_title(component_name, input_name) + component = components[component_name] + return nil if component.nil? + component[:script_parameters].andand[input_name.to_sym].andand[:title] || + "\"#{input_name.to_s}\" parameter for #{component[:script]} script in #{component_name} component" + end end diff --git a/apps/workbench/app/models/user.rb b/apps/workbench/app/models/user.rb index 9c914776a2..87ea5faefa 100644 --- a/apps/workbench/app/models/user.rb +++ b/apps/workbench/app/models/user.rb @@ -27,11 +27,15 @@ class User < ArvadosBase {})) end + def contents params={} + Group.contents params.merge(uuid: self.uuid) + end + def attributes_for_display super.reject { |k,v| %w(owner_uuid default_owner_uuid identity_url prefs).index k } end - def attribute_editable? attr, *args + def attribute_editable? attr, *args (not (self.uuid.andand.match(/000000000000000$/) and self.is_admin)) and super end @@ -49,4 +53,10 @@ class User < ArvadosBase arvados_api_client.api(self, "/setup", params) end + def update_profile params + self.private_reload(arvados_api_client.api(self.class, + "/#{self.uuid}/profile", + params)) + end + end diff --git a/apps/workbench/app/views/application/404.html.erb b/apps/workbench/app/views/application/404.html.erb index 40d73b9a73..fd97295956 100644 --- a/apps/workbench/app/views/application/404.html.erb +++ b/apps/workbench/app/views/application/404.html.erb @@ -1,7 +1,8 @@ <% if (controller.andand.action_name == 'show') and params[:uuid] - class_name = controller.model_class.to_s.underscore.humanize(capitalize: false) - req_item = safe_join([class_name, " with UUID ", + class_name = controller.model_class.to_s.underscore + class_name_h = class_name.humanize(capitalize: false) + req_item = safe_join([class_name_h, " with UUID ", raw(""), params[:uuid], raw("")], "") else req_item = "page you requested" @@ -14,7 +15,7 @@ <% if class_name %> Perhaps you'd like to -<%= link_to("browse all #{class_name.pluralize}", action: :index) %>? +<%= link_to("browse all #{class_name_h.pluralize}", action: :index, controller: class_name.tableize) %>? <% end %>

diff --git a/apps/workbench/app/views/application/_choose.html.erb b/apps/workbench/app/views/application/_choose.html.erb index 3c1aaadf7d..68351a9499 100644 --- a/apps/workbench/app/views/application/_choose.html.erb +++ b/apps/workbench/app/views/application/_choose.html.erb @@ -7,48 +7,59 @@
+ <% end %> + +
+
- <% preview_pane = (params[:preview_pane] != "false") - pane_col_class = preview_pane ? "col-sm-6" : "" %> + <% preview_pane = (params[:preview_pane].to_s != "false") + pane_col_class = preview_pane ? "col-sm-6" : "col-sm-12" %>
-
- <%= render partial: 'choose_rows', locals: {multiple: multiple} %> -
+
+ <%= render partial: 'choose_rows' %> +
<% if preview_pane %> - diff --git a/apps/workbench/app/views/application/_choose.js.erb b/apps/workbench/app/views/application/_choose.js.erb index b033c9bf2f..c482b4597e 100644 --- a/apps/workbench/app/views/application/_choose.js.erb +++ b/apps/workbench/app/views/application/_choose.js.erb @@ -1,3 +1,4 @@ +<% session[:selected_move_items] = params['move_items'] if params['move_items'] %> $('body > .modal-container').html("<%= escape_javascript(render partial: 'choose.html', locals: {multiple: multiple}) %>"); $('body > .modal-container .modal').modal('show'); $('body > .modal-container .modal .modal-footer .btn-primary'). @@ -5,24 +6,3 @@ $('body > .modal-container .modal .modal-footer .btn-primary'). attr('data-action-href', '<%= j params[:action_href] %>'). attr('data-method', '<%= j params[:action_method] %>'). data('action-data', <%= raw params[:action_data] %>); -$(".chooser-show-project").on("click", function() { - $("#choose-scroll").html("
"); - $(".modal-dialog-preview-pane").html(''); - var t = $(this); - var d = { - partial: true, - multiple: <%= multiple || "false" %> - }; - if (t.attr("data-project-uuid") != null) { - d.project_uuid = t.attr("data-project-uuid"); - } - $.ajax('<%=j url_for %>', { - dataType: "json", - type: "GET", - data: d - }).done(function(data, status, jqxhr) { - $("#chooser-breadcrumb").text(t.text()); - $("#choose-scroll").html(data.content); - $("#choose-scroll").prop("data-infinite-content-href", "next_page_href"); - }); -}); diff --git a/apps/workbench/app/views/application/_content.html.erb b/apps/workbench/app/views/application/_content.html.erb index 1609a66b09..f7ae90912f 100644 --- a/apps/workbench/app/views/application/_content.html.erb +++ b/apps/workbench/app/views/application/_content.html.erb @@ -1,24 +1,3 @@ -<% content_for :content_top do %> - <% if @object and not @object.is_a?(Group) and @object.class.goes_in_projects? and @object.owner_uuid == current_user.uuid and not @name_link %> -
-
- - Hey. This <%= @object.class_for_display.downcase %> belongs to your account, but it's not in any of your projects. If you want it to be easy to find in the future, you should move it to a project.
- <%= button_to(choose_projects_path( - title: 'Move to...', - editable: true, - action_name: 'Move', - action_href: url_for(action: :update), - action_method: 'patch', - action_data: {selection_param: @object.resource_param_name+'[owner_uuid]', success: 'page-refresh'}.to_json), - { class: "btn btn-primary btn-sm", remote: true, method: 'get' }) do %> - Choose a project... - <% end %> -
-
- <% end %> -<% end %> - <% content_for :tab_panes do %> <% comparable = controller.respond_to? :compare %> @@ -42,8 +21,7 @@
<% if i == 0 %> - <%= render(partial: 'show_' + pane.downcase, - locals: { comparable: comparable, objects: @objects }) %> + <%= render_pane pane, to_string: true %> <% else %>
<% end %> diff --git a/apps/workbench/app/views/application/_content_layout.html.erb b/apps/workbench/app/views/application/_content_layout.html.erb index c8e3827720..3e50b6eb8c 100644 --- a/apps/workbench/app/views/application/_content_layout.html.erb +++ b/apps/workbench/app/views/application/_content_layout.html.erb @@ -1,13 +1,6 @@ <%= content_for :content_top %> - <% if @object and @object.is_a?(Group) and @object.group_class.in?(['project','folder']) %> -
- <%= content_for :tab_line_buttons %> -
-
-<% else %> -
-
- <%= content_for :tab_line_buttons %> -
-<% end %> +
+ <%= content_for :tab_line_buttons %> +
+
<%= content_for :tab_panes %> diff --git a/apps/workbench/app/views/application/_projects_tree_menu.html.erb b/apps/workbench/app/views/application/_projects_tree_menu.html.erb index 876b0be65c..600c6ab96f 100644 --- a/apps/workbench/app/views/application/_projects_tree_menu.html.erb +++ b/apps/workbench/app/views/application/_projects_tree_menu.html.erb @@ -1,12 +1,13 @@ -
  • + <%= project_link_to.call({object: current_user, depth: 0}) do %> + Home + <% end %>
  • <% my_project_tree.each do |pnode| %> <% next if pnode[:object].class != Group %> -
  • +
  • <%= project_link_to.call pnode do %> - <%= pnode[:object].name %> + <%= pnode[:object].name %> <% end %>
  • <% end %> @@ -16,9 +17,9 @@ <% shared_project_tree.each do |pnode| %> <% next if pnode[:object].class != Group %> -
  • +
  • <%= project_link_to.call pnode do %> - <%= pnode[:object].name %> + <%= pnode[:object].name %> <% end %>
  • <% end %> diff --git a/apps/workbench/app/views/application/_tab_line_buttons.html.erb b/apps/workbench/app/views/application/_tab_line_buttons.html.erb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/workbench/app/views/application/index.html.erb b/apps/workbench/app/views/application/index.html.erb index 0e72f7a2dd..3e2a608ed7 100644 --- a/apps/workbench/app/views/application/index.html.erb +++ b/apps/workbench/app/views/application/index.html.erb @@ -13,6 +13,8 @@ Add a new user <% end %> + <% elsif controller.controller_name == 'manage_account' %> + <%# No add button is needed %> <% else %> <%= button_to({action: 'create'}, {class: 'btn btn-sm btn-primary'}) do %> diff --git a/apps/workbench/app/views/application/show.html.erb b/apps/workbench/app/views/application/show.html.erb index d6eca3a215..105e1c356d 100644 --- a/apps/workbench/app/views/application/show.html.erb +++ b/apps/workbench/app/views/application/show.html.erb @@ -27,4 +27,3 @@ <% end %> <%= render partial: 'content', layout: 'content_layout', locals: {pane_list: controller.show_pane_list }%> - diff --git a/apps/workbench/app/views/collections/_choose_rows.html.erb b/apps/workbench/app/views/collections/_choose_rows.html.erb index d87f56f9cd..8079eb44a6 100644 --- a/apps/workbench/app/views/collections/_choose_rows.html.erb +++ b/apps/workbench/app/views/collections/_choose_rows.html.erb @@ -1,13 +1,15 @@ <% @name_links.each do |name_link| %> <% if (object = get_object(name_link.head_uuid)) %> -
    - - <%= name_link.name %> - <% Link.filter([['link_class','=','tag'],['head_uuid','=',object.uuid]]).collect(&:name).each do |tagname| %> - <%= tagname %> - <% end %> + + <%= name_link.name %> + <% links_for_object(object).each do |tag| %> + <% if tag.link_class == 'tag' %> + <%= tag.name %> + <% end %> + <% end %>
    <% end %> <% end %> diff --git a/apps/workbench/app/views/collections/_show_files.html.erb b/apps/workbench/app/views/collections/_show_files.html.erb index 8576d40440..ea55577e70 100644 --- a/apps/workbench/app/views/collections/_show_files.html.erb +++ b/apps/workbench/app/views/collections/_show_files.html.erb @@ -1,8 +1,3 @@ -<% content_for :tab_line_buttons do %> -Collection storage status: -<%= render partial: 'toggle_persist', locals: { uuid: @object.uuid, current_state: (@is_persistent ? 'persistent' : 'cache') } %> -<% end %> - <% file_tree = @object.andand.files_tree %> <% if file_tree.nil? or file_tree.empty? %>

    This collection is empty.

    diff --git a/apps/workbench/app/views/collections/_show_recent.html.erb b/apps/workbench/app/views/collections/_show_recent.html.erb index 2f81073305..d624749a4a 100644 --- a/apps/workbench/app/views/collections/_show_recent.html.erb +++ b/apps/workbench/app/views/collections/_show_recent.html.erb @@ -1,16 +1,3 @@ -<% content_for :tab_line_buttons do %> - <%= form_tag collections_path, method: 'get', remote: true, class: 'form-search' do %> -
    - <%= text_field_tag :search, params[:search], class: 'form-control', placeholder: 'Search collections' %> - - <%= button_tag(class: 'btn btn-info') do %> - - <% end %> - -
    - <% end %> -<% end %> - <%= render partial: "paging", locals: {results: @collections, object: @object} %>
    diff --git a/apps/workbench/app/views/collections/index.html.erb b/apps/workbench/app/views/collections/index.html.erb new file mode 100644 index 0000000000..061b05b474 --- /dev/null +++ b/apps/workbench/app/views/collections/index.html.erb @@ -0,0 +1,14 @@ +<% content_for :tab_line_buttons do %> + <%= form_tag collections_path, method: 'get', remote: true, class: 'form-search' do %> +
    + <%= text_field_tag :search, params[:search], class: 'form-control', placeholder: 'Search collections' %> + + <%= button_tag(class: 'btn btn-info') do %> + + <% end %> + +
    + <% end %> +<% end %> + +<%= render file: 'application/index.html.erb', locals: local_assigns %> diff --git a/apps/workbench/app/views/collections/show.html.erb b/apps/workbench/app/views/collections/show.html.erb index a51c450ea0..83dcb4511b 100644 --- a/apps/workbench/app/views/collections/show.html.erb +++ b/apps/workbench/app/views/collections/show.html.erb @@ -90,4 +90,9 @@
    -<%= render file: 'application/show.html.erb' %> +<% content_for :tab_line_buttons do %> + Collection storage status: + <%= render partial: 'toggle_persist', locals: { uuid: @object.uuid, current_state: (@is_persistent ? 'persistent' : 'cache') } %> +<% end %> + +<%= render file: 'application/show.html.erb', locals: local_assigns %> diff --git a/apps/workbench/app/views/groups/_choose_rows.html.erb b/apps/workbench/app/views/groups/_choose_rows.html.erb index 772ef197d0..fca0415e29 100644 --- a/apps/workbench/app/views/groups/_choose_rows.html.erb +++ b/apps/workbench/app/views/groups/_choose_rows.html.erb @@ -1,6 +1,6 @@ <% icon_class = fa_icon_class_for_class(Group) %> <% @objects.each do |object| %> -
    +
    <%= object.name %> diff --git a/apps/workbench/app/views/keep_disks/_content_layout.html.erb b/apps/workbench/app/views/keep_disks/_content_layout.html.erb index 0f5cd7aece..56177ae6e5 100644 --- a/apps/workbench/app/views/keep_disks/_content_layout.html.erb +++ b/apps/workbench/app/views/keep_disks/_content_layout.html.erb @@ -17,5 +17,7 @@ <% end %> <% end %> <%= content_for :content_top %> -<%= content_for :tab_line_buttons %> +
    + <%= content_for :tab_line_buttons %> +
    <%= content_for :tab_panes %> diff --git a/apps/workbench/app/views/layouts/application.html.erb b/apps/workbench/app/views/layouts/application.html.erb index d0b27c3758..0a309db5b5 100644 --- a/apps/workbench/app/views/layouts/application.html.erb +++ b/apps/workbench/app/views/layouts/application.html.erb @@ -41,184 +41,12 @@ } +<%= piwik_tracking_tag %> -
    - - - <% if current_user.andand.is_active %> - - <% end %> - -
    - <%= yield %> -
    -
    - - <%= yield :footer_html %> - <%= piwik_tracking_tag %> - <%= javascript_tag do %> - <%= yield :footer_js %> - <% end %> - - +<%= render template: 'layouts/body' %> +<%= javascript_tag do %> +<%= yield :footer_js %> +<% end %> diff --git a/apps/workbench/app/views/layouts/body.html.erb b/apps/workbench/app/views/layouts/body.html.erb new file mode 100644 index 0000000000..4c58daba70 --- /dev/null +++ b/apps/workbench/app/views/layouts/body.html.erb @@ -0,0 +1,204 @@ +
    + + + <% if current_user.andand.is_active %> + + <% end %> + +
    + <%= yield %> +
    +
    + + <%= yield :footer_html %> + + diff --git a/apps/workbench/app/views/pipeline_instances/_show_inputs.html.erb b/apps/workbench/app/views/pipeline_instances/_show_inputs.html.erb index 5106710b0e..4573919286 100644 --- a/apps/workbench/app/views/pipeline_instances/_show_inputs.html.erb +++ b/apps/workbench/app/views/pipeline_instances/_show_inputs.html.erb @@ -12,8 +12,7 @@ not pvalue_spec[:value])) %> <% n_inputs += 1 %>

    diff --git a/apps/workbench/app/views/pipeline_instances/_show_recent.html.erb b/apps/workbench/app/views/pipeline_instances/_show_recent.html.erb index 0b78e07d65..08b24f13cb 100644 --- a/apps/workbench/app/views/pipeline_instances/_show_recent.html.erb +++ b/apps/workbench/app/views/pipeline_instances/_show_recent.html.erb @@ -1,10 +1,3 @@ -<%= content_for :tab_line_buttons do %> -<%= form_tag({action: 'compare', controller: params[:controller], method: 'get'}, {method: 'get', id: 'compare', class: 'pull-right small-form-margin'}) do |f| %> - <%= submit_tag 'Compare 2 or 3 selected', {class: 'btn btn-primary', disabled: true, style: 'display: none'} %> -   -<% end rescue nil %> -<% end %> - <%= render partial: "paging", locals: {results: @objects, object: @object} %> <%= form_tag do |f| %> diff --git a/apps/workbench/app/views/pipeline_instances/index.html.erb b/apps/workbench/app/views/pipeline_instances/index.html.erb new file mode 100644 index 0000000000..4b73bd499a --- /dev/null +++ b/apps/workbench/app/views/pipeline_instances/index.html.erb @@ -0,0 +1,8 @@ +<% content_for :tab_line_buttons do %> +<%= form_tag({action: 'compare', controller: params[:controller], method: 'get'}, {method: 'get', id: 'compare', class: 'pull-right small-form-margin'}) do |f| %> + <%= submit_tag 'Compare 2 or 3 selected', {class: 'btn btn-primary', disabled: true, style: 'display: none'} %> +   +<% end rescue nil %> +<% end %> + +<%= render file: 'application/index.html.erb', locals: local_assigns %> diff --git a/apps/workbench/app/views/pipeline_templates/_choose_rows.html.erb b/apps/workbench/app/views/pipeline_templates/_choose_rows.html.erb index 9aebd695bf..9b96b47d19 100644 --- a/apps/workbench/app/views/pipeline_templates/_choose_rows.html.erb +++ b/apps/workbench/app/views/pipeline_templates/_choose_rows.html.erb @@ -1,5 +1,5 @@ <% @objects.each do |object| %> -

    +
    <%= object.name %> diff --git a/apps/workbench/app/views/pipeline_templates/_show_components.html.erb b/apps/workbench/app/views/pipeline_templates/_show_components.html.erb index 1f2c1baaf4..cd03a5c790 100644 --- a/apps/workbench/app/views/pipeline_templates/_show_components.html.erb +++ b/apps/workbench/app/views/pipeline_templates/_show_components.html.erb @@ -1,18 +1 @@ -<% content_for :tab_line_buttons do %> - <%= button_to(choose_projects_path(id: "run-pipeline-button", - title: 'Choose project', - editable: true, - action_name: 'Choose', - action_href: pipeline_instances_path, - action_method: 'post', - action_data: {selection_param: 'pipeline_instance[owner_uuid]', - 'pipeline_instance[pipeline_template_uuid]' => @object.uuid, - 'success' => 'redirect-to-created-object' - }.to_json), - { class: "btn btn-primary btn-sm", remote: true, method: 'get' } - ) do %> - Run this pipeline - <% end %> -<% end %> - <%= render_pipeline_components("editable", :json, editable: false) %> diff --git a/apps/workbench/app/views/pipeline_templates/show.html.erb b/apps/workbench/app/views/pipeline_templates/show.html.erb new file mode 100644 index 0000000000..725d63db73 --- /dev/null +++ b/apps/workbench/app/views/pipeline_templates/show.html.erb @@ -0,0 +1,18 @@ +<% content_for :tab_line_buttons do %> + <%= button_to(choose_projects_path(id: "run-pipeline-button", + title: 'Choose project', + editable: true, + action_name: 'Choose', + action_href: pipeline_instances_path, + action_method: 'post', + action_data: {selection_param: 'pipeline_instance[owner_uuid]', + 'pipeline_instance[pipeline_template_uuid]' => @object.uuid, + 'success' => 'redirect-to-created-object' + }.to_json), + { class: "btn btn-primary btn-sm", remote: true, method: 'get' } + ) do %> + Run this pipeline + <% end %> +<% end %> + +<%= render file: 'application/show.html.erb', locals: local_assigns %> diff --git a/apps/workbench/app/views/projects/_index_projects.html.erb b/apps/workbench/app/views/projects/_index_projects.html.erb index b05a87dd49..027dc29b8e 100644 --- a/apps/workbench/app/views/projects/_index_projects.html.erb +++ b/apps/workbench/app/views/projects/_index_projects.html.erb @@ -9,8 +9,10 @@ <%= projectnode[:object] %> <% elsif show_root_node and rowtype == User %> <% if projectnode[:object].uuid == current_user.andand.uuid %> - - My Projects + + <%= link_to project_path(id: projectnode[:object].uuid) do %> + Home + <% end %> <% else %> <%= projectnode[:object].friendly_link_name %> @@ -22,7 +24,7 @@ <% end %> <% end %>
    - <% if not projectnode[:object].description.blank? %> + <% if projectnode[:object].respond_to?(:description) and not projectnode[:object].description.blank? %>
    <%= projectnode[:object].description %>
    <% end %>
    diff --git a/apps/workbench/app/views/projects/_show_contents_rows.html.erb b/apps/workbench/app/views/projects/_show_contents_rows.html.erb index b690a1bf8f..e1996a7f40 100644 --- a/apps/workbench/app/views/projects/_show_contents_rows.html.erb +++ b/apps/workbench/app/views/projects/_show_contents_rows.html.erb @@ -1,24 +1,34 @@ -<% objects_and_names.each do |object, name_link| %> +<% get_objects_and_names.each do |object, name_link| %> <% name_object = (object.respond_to?(:name) || !name_link) ? object : name_link %> - <%= render partial: 'selection_checkbox', locals: {object: name_object, friendly_name: ((name_object.name rescue '') || '')} %> +
    + <%= render partial: 'selection_checkbox', locals: {object: name_object, friendly_name: ((name_object.name rescue '') || '')} %> +
    + - <% if project.editable? %> - <%= link_to({action: 'remove_item', id: project.uuid, item_uuid: ((name_link && name_link.uuid) || object.uuid)}, method: :delete, remote: true, data: {confirm: "Remove #{object.class_for_display.downcase} #{name_object.name rescue object.uuid} from this project?", toggle: 'tooltip', placement: 'top'}, class: 'btn btn-sm btn-default btn-nodecorate', title: 'remove') do %> + + <% if @object.editable? %> + <%= link_to({action: 'remove_item', id: @object.uuid, item_uuid: ((name_link && name_link.uuid) || object.uuid)}, method: :delete, remote: true, data: {confirm: "Remove #{object.class_for_display.downcase} #{name_object.name rescue object.uuid} from this project?", toggle: 'tooltip', placement: 'top'}, class: 'btn btn-sm btn-default btn-nodecorate', title: 'remove') do %> <% end %> <% else %> <%# placeholder %> <% end %> + + <%= render :partial => "show_object_button", :locals => {object: object, size: 'sm', name_link: name_link} %> + + <%= render_editable_attribute (name_link || object), 'name', nil, {tiptitle: 'rename'} %> + <%= render_controller_partial( 'show_object_description_cell.html', diff --git a/apps/workbench/app/views/projects/_show_data_collections.html.erb b/apps/workbench/app/views/projects/_show_data_collections.html.erb index c44db5f4bd..e56321dde8 100644 --- a/apps/workbench/app/views/projects/_show_data_collections.html.erb +++ b/apps/workbench/app/views/projects/_show_data_collections.html.erb @@ -1,65 +1,3 @@ -<% content_for :content_top do %> - -

    - <%= render_editable_attribute @object, 'name', nil, { 'data-emptytext' => "New project" } %> -

    - -
    - <%= render_editable_attribute @object, 'description', nil, { 'data-emptytext' => "(No description provided)", 'data-toggle' => 'manual' } %> -
    - -<% end %> - -<% 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', data: {'event-after-select' => 'page-refresh'} }) do %> - Add data... - <% end %> - <%= link_to( - choose_pipeline_templates_path( - title: 'Choose a pipeline to run:', - action_name: 'Next: choose inputs ', - action_href: pipeline_instances_path, - action_method: 'post', - action_data: {'selection_param' => 'pipeline_instance[pipeline_template_uuid]', 'pipeline_instance[owner_uuid]' => @object.uuid, 'success' => 'redirect-to-created-object'}.to_json), - { class: "btn btn-primary btn-sm", remote: true, method: 'get' }) do %> - Run a pipeline... - <% end %> - <%= link_to projects_path('project[owner_uuid]' => @object.uuid), method: 'post', class: 'btn btn-sm btn-primary' do %> - - Add a subproject - <% end %> - <%= link_to( - choose_projects_path( - title: 'Move this project to...', - editable: true, - my_root_selectable: true, - action_name: 'Move', - action_href: project_path(@object.uuid), - action_method: 'put', - action_data: {selection_param: 'project[owner_uuid]', success: 'page-refresh'}.to_json), - { class: "btn btn-sm btn-primary arv-move-to-project", remote: true, method: 'get' }) do %> - Move project... - <% end %> - <%= link_to(project_path(id: @object.uuid), method: 'delete', class: 'btn btn-sm btn-primary', data: {confirm: "Really delete project '#{@object.name}'?"}) do %> - Delete project - <% end %> - <% end %> -<% end %> - -<% - filters = [['uuid', 'is_a', "arvados#collection"]] - @objects = @object.contents({limit: 50, include_linked: true, :filters => filters}) - objects_and_names = get_objects_and_names @objects - page_offset = next_page_offset @objects -%> - -<%= render partial: 'show_tab_contents', locals: {project: @object, objects_and_names: objects_and_names, filters: filters, page_offset: page_offset, tab_name: 'Data_collections'} %> +<%= render_pane 'tab_contents', to_string: true, locals: { + filters: [['uuid', 'is_a', "arvados#collection"]] + }.merge(local_assigns) %> diff --git a/apps/workbench/app/views/projects/_show_jobs_and_pipelines.html.erb b/apps/workbench/app/views/projects/_show_jobs_and_pipelines.html.erb index f9e6306a9d..df31fec8ee 100644 --- a/apps/workbench/app/views/projects/_show_jobs_and_pipelines.html.erb +++ b/apps/workbench/app/views/projects/_show_jobs_and_pipelines.html.erb @@ -1,8 +1,3 @@ -<% - filters = [['uuid', 'is_a', ["arvados#pipelineInstance","arvados#job"]]] - @objects = @object.contents({limit: 50, include_linked: true, :filters => filters}) - objects_and_names = get_objects_and_names @objects - page_offset = next_page_offset @objects -%> - -<%= render partial: 'show_tab_contents', locals: {project: @object, objects_and_names: objects_and_names, filters: filters, page_offset: page_offset, tab_name: 'Jobs_and_pipelines'} %> +<%= render_pane 'tab_contents', to_string: true, locals: { + filters: [['uuid', 'is_a', ["arvados#job", "arvados#pipelineInstance"]]] + }.merge(local_assigns) %> diff --git a/apps/workbench/app/views/projects/_show_other_objects.html.erb b/apps/workbench/app/views/projects/_show_other_objects.html.erb index 308034ed54..af6fbd1a92 100644 --- a/apps/workbench/app/views/projects/_show_other_objects.html.erb +++ b/apps/workbench/app/views/projects/_show_other_objects.html.erb @@ -1,8 +1,3 @@ -<% - filters = [['uuid', 'is_a', ["arvados#human","arvados#specimen","arvados#trait"]]] - @objects = @object.contents({limit: 50, include_linked: true, :filters => filters}) - objects_and_names = get_objects_and_names @objects - page_offset = next_page_offset @objects -%> - -<%= render partial: 'show_tab_contents', locals: {project: @object, objects_and_names: objects_and_names, filters: filters, page_offset: page_offset, tab_name: 'Other_objects'} %> +<%= render_pane 'tab_contents', to_string: true, locals: { + filters: [['uuid', 'is_a', ["arvados#human", "arvados#specimen", "arvados#trait"]]] + }.merge(local_assigns) %> diff --git a/apps/workbench/app/views/projects/_show_pipeline_templates.html.erb b/apps/workbench/app/views/projects/_show_pipeline_templates.html.erb index c54e28dfeb..b875b086ec 100644 --- a/apps/workbench/app/views/projects/_show_pipeline_templates.html.erb +++ b/apps/workbench/app/views/projects/_show_pipeline_templates.html.erb @@ -1,8 +1,3 @@ -<% - filters = [['uuid', 'is_a', "arvados#pipelineTemplate"]] - @objects = @object.contents({limit: 50, include_linked: true, :filters => filters}) - objects_and_names = get_objects_and_names @objects - page_offset = next_page_offset @objects -%> - -<%= render partial: 'show_tab_contents', locals: {project: @object, objects_and_names: objects_and_names, filters: filters, page_offset: page_offset, tab_name: 'Pipeline_templates'} %> +<%= render_pane 'tab_contents', to_string: true, locals: { + filters: [['uuid', 'is_a', ["arvados#pipelineTemplate"]]] + }.merge(local_assigns) %> diff --git a/apps/workbench/app/views/projects/_show_sharing.html.erb b/apps/workbench/app/views/projects/_show_sharing.html.erb index a5482efc1d..ff0062c24b 100644 --- a/apps/workbench/app/views/projects/_show_sharing.html.erb +++ b/apps/workbench/app/views/projects/_show_sharing.html.erb @@ -1,7 +1,13 @@ <% uuid_map = {} - [@users, @groups].each do |obj_list| - obj_list.each { |o| uuid_map[o.uuid] = o } + if @share_links + [User, Group].each do |type| + type.limit(10000) + .filter([['uuid','in',@share_links.collect(&:tail_uuid)]]) + .each do |o| + uuid_map[o.uuid] = o + end + end end perm_name_desc_map = {} perm_desc_name_map = {} @@ -13,6 +19,10 @@ perms_json << {value: link_name, text: link_desc} end perms_json = perms_json.to_json + choose_filters = { + "groups" => [["group_class", "=", nil]], + } + choose_filters.default = [] owner_icon = fa_icon_class_for_uuid(@object.owner_uuid) if owner_icon == "fa-users" owner_icon = "fa-folder" @@ -27,9 +37,11 @@ <%= link_to(send("choose_#{share_class}_path", title: "Share with #{share_class}", + by_project: false, preview_pane: false, multiple: true, limit: 10000, + filters: choose_filters[share_class].to_json, action_method: 'post', action_href: share_with_project_path, action_name: 'Add', diff --git a/apps/workbench/app/views/projects/_show_subprojects.html.erb b/apps/workbench/app/views/projects/_show_subprojects.html.erb index 4497ca4ea7..2c0ba60178 100644 --- a/apps/workbench/app/views/projects/_show_subprojects.html.erb +++ b/apps/workbench/app/views/projects/_show_subprojects.html.erb @@ -1,8 +1,3 @@ -<% - filters = [['uuid', 'is_a', "arvados#group"]] - @objects = @object.contents({limit: 50, include_linked: true, :filters => filters}) - objects_and_names = get_objects_and_names @objects - page_offset = next_page_offset @objects -%> - -<%= render partial: 'show_tab_contents', locals: {project: @object, objects_and_names: objects_and_names, filters: filters, page_offset: page_offset, tab_name: 'Subprojects'} %> +<%= render_pane 'tab_contents', to_string: true, locals: { + filters: [['uuid', 'is_a', ["arvados#group"]]] + }.merge(local_assigns) %> diff --git a/apps/workbench/app/views/projects/_show_tab_contents.html.erb b/apps/workbench/app/views/projects/_show_tab_contents.html.erb index 36f1907a3d..e3884b6292 100644 --- a/apps/workbench/app/views/projects/_show_tab_contents.html.erb +++ b/apps/workbench/app/views/projects/_show_tab_contents.html.erb @@ -2,7 +2,7 @@
    - +
    - +
    - +
    - - + + + + + - "> - <%= render partial: 'show_contents_rows', locals: {project: @object, objects_and_names: objects_and_names} %> + - - + + + + +
    - - description - namedescription
    -
    diff --git a/apps/workbench/app/views/projects/index.html.erb b/apps/workbench/app/views/projects/index.html.erb index 2c764335f6..219bad262b 100644 --- a/apps/workbench/app/views/projects/index.html.erb +++ b/apps/workbench/app/views/projects/index.html.erb @@ -1,8 +1,3 @@ -<% content_for :breadcrumbs do %> - -
  • Home
  • -<% end %> -
    @@ -37,7 +32,7 @@
    - <%= render partial: 'index_projects', locals: {tree: my_project_tree, show_root_node: false} %> + <%= render partial: 'index_projects', locals: {tree: my_project_tree, show_root_node: true} %>
    diff --git a/apps/workbench/app/views/projects/show.html.erb b/apps/workbench/app/views/projects/show.html.erb new file mode 100644 index 0000000000..c221ca1d16 --- /dev/null +++ b/apps/workbench/app/views/projects/show.html.erb @@ -0,0 +1,62 @@ +<% if @object.uuid != current_user.uuid # Not the "Home" project %> +<% content_for :content_top do %> + +

    + <%= render_editable_attribute @object, 'name', nil, { 'data-emptytext' => "New project" } %> +

    + +
    + <%= render_editable_attribute @object, 'description', nil, { 'data-emptytext' => "(No description provided)", 'data-toggle' => 'manual' } %> +
    + +<% end %> +<% end %> + +<% 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', data: {'event-after-select' => 'page-refresh'} }) do %> + Add data... + <% end %> + <%= link_to( + choose_pipeline_templates_path( + title: 'Choose a pipeline to run:', + action_name: 'Next: choose inputs ', + action_href: pipeline_instances_path, + action_method: 'post', + action_data: {'selection_param' => 'pipeline_instance[pipeline_template_uuid]', 'pipeline_instance[owner_uuid]' => @object.uuid, 'success' => 'redirect-to-created-object'}.to_json), + { class: "btn btn-primary btn-sm", remote: true, method: 'get' }) do %> + Run a pipeline... + <% end %> + <%= link_to projects_path('project[owner_uuid]' => @object.uuid), method: 'post', class: 'btn btn-sm btn-primary' do %> + + Add a subproject + <% end %> + <% if @object.uuid != current_user.uuid # Not the "Home" project %> + <%= link_to( + choose_projects_path( + title: 'Move this project to...', + editable: true, + my_root_selectable: true, + action_name: 'Move', + action_href: project_path(@object.uuid), + action_method: 'put', + action_data: {selection_param: 'project[owner_uuid]', success: 'page-refresh'}.to_json), + { class: "btn btn-sm btn-primary arv-move-to-project", remote: true, method: 'get' }) do %> + Move project... + <% end %> + <%= link_to(project_path(id: @object.uuid), method: 'delete', class: 'btn btn-sm btn-primary', data: {confirm: "Really delete project '#{@object.name}'?"}) do %> + Delete project + <% end %> + <% end %> + <% end %> +<% end %> + +<%= render file: 'application/show.html.erb', locals: local_assigns %> diff --git a/apps/workbench/app/views/search/_choose_rows.html.erb b/apps/workbench/app/views/search/_choose_rows.html.erb new file mode 100644 index 0000000000..61f9300356 --- /dev/null +++ b/apps/workbench/app/views/search/_choose_rows.html.erb @@ -0,0 +1,26 @@ +<% current_class = params[:last_object_class] %> +<% @objects.each do |object| %> + <% icon_class = fa_icon_class_for_class(object.class) %> + <% if object.class.to_s != current_class %> + <% current_class = object.class.to_s %> +
    +
    + <%= object.class_for_display.pluralize.downcase %> +
    +
    + <% end %> +
    +
    + + <% if (name_link = @objects.links_for(object, 'name').first) %> + <%= name_link.name %> + <%= object.uuid %> + <% elsif object.respond_to?(:name) and object.name and object.name.length > 0 %> + <%= object.name %> + <%= object.uuid %> + <% else %> + <%= object.uuid %> + <% end %> +
    +
    +<% end %> diff --git a/apps/workbench/app/views/users/_add_ssh_key_popup.html.erb b/apps/workbench/app/views/users/_add_ssh_key_popup.html.erb new file mode 100644 index 0000000000..efa8cae5c9 --- /dev/null +++ b/apps/workbench/app/views/users/_add_ssh_key_popup.html.erb @@ -0,0 +1,38 @@ +