From 3865e3b1b75a1d094268a2e441523e3bd77d57df Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 18 Jul 2014 13:45:08 -0400 Subject: [PATCH] Refactor the entity-search directive/control to make its interface much cleaner and to add support for ng-model validity checking --- static/css/quay.css | 16 +- .../create-external-notification-dialog.html | 6 + static/directives/entity-search.html | 6 +- static/directives/prototype-manager.html | 14 +- static/directives/setup-trigger-dialog.html | 8 +- static/js/app.js | 319 +++++++++++------- static/partials/repo-admin.html | 9 +- static/partials/team-view.html | 11 +- 8 files changed, 234 insertions(+), 155 deletions(-) diff --git a/static/css/quay.css b/static/css/quay.css index feb71edc6..7ab21941b 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -219,11 +219,12 @@ nav.navbar-default .navbar-nav>li>a { .entity-search-element { position: relative; + display: block; } .entity-search-element .entity-reference { position: absolute !important; - top: 0px; + top: 7px; left: 8px; right: 36px; z-index: 0; @@ -244,6 +245,7 @@ nav.navbar-default .navbar-nav>li>a { .entity-search-element input { vertical-align: middle; + width: 100%; } .entity-search-element.persistent input { @@ -253,12 +255,15 @@ nav.navbar-default .navbar-nav>li>a { .entity-search-element .twitter-typeahead { vertical-align: middle; + display: block !important; + margin-right: 36px; } .entity-search-element .dropdown { vertical-align: middle; - display: inline-block; - margin-top: 0px; + position: absolute; + top: 0px; + right: 0px; } .dropdown-menu i.fa { @@ -2508,10 +2513,6 @@ p.editable:hover i { text-align: right; } -.repo-admin .entity-search input { - width: 300px; -} - .repo-admin .panel { display: inline-block; width: 720px; @@ -3023,7 +3024,6 @@ p.editable:hover i { .team-view .entity-search { margin-top: 10px; - display: inline-block; } .team-view .delete-ui { diff --git a/static/directives/create-external-notification-dialog.html b/static/directives/create-external-notification-dialog.html index 065138207..da86ee939 100644 --- a/static/directives/create-external-notification-dialog.html +++ b/static/directives/create-external-notification-dialog.html @@ -62,6 +62,12 @@ + diff --git a/static/directives/entity-search.html b/static/directives/entity-search.html index bc1c0a94e..81722ece2 100644 --- a/static/directives/entity-search.html +++ b/static/directives/entity-search.html @@ -1,5 +1,5 @@ - - + +
Note: We've automatically selected robot account , since it has access to the Quay.io repository. diff --git a/static/js/app.js b/static/js/app.js index ff3275b2d..6ef24b750 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -999,7 +999,14 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading { 'id': 'quay_notification', 'title': 'Quay.io notification', - 'icon': 'quay-icon' + 'icon': 'quay-icon', + 'fields': [ + { + 'name': 'target', + 'type': 'entity', + 'title': 'Recipient' + } + ] }, { 'id': 'email', @@ -3232,48 +3239,72 @@ quayApp.directive('entitySearch', function () { replace: false, transclude: false, restrict: 'C', + require: '?ngModel', + link: function(scope, element, attr, ctrl) { + scope.ngModel = ctrl; + }, scope: { 'namespace': '=namespace', - 'inputTitle': '=inputTitle', - 'entitySelected': '=entitySelected', - 'includeTeams': '=includeTeams', - 'isOrganization': '=isOrganization', - 'isPersistent': '=isPersistent', + 'placeholder': '=placeholder', + + // Default: ['user', 'team', 'robot'] + 'allowedEntities': '=allowedEntities', + 'currentEntity': '=currentEntity', - 'clearNow': '=clearNow', - 'filter': '=filter', + 'entitySelected': '&entitySelected', + + // When set to true, the contents of the control will be cleared as soon + // as an entity is selected. + 'autoClear': '=autoClear', + + // Set this property to immediately clear the contents of the control. + 'clearValue': '=clearValue', }, - controller: function($scope, $element, Restangular, UserService, ApiService) { + controller: function($rootScope, $scope, $element, Restangular, UserService, ApiService) { $scope.lazyLoading = true; + + $scope.teams = null; + $scope.robots = null; + $scope.isAdmin = false; + $scope.isOrganization = false; + + $scope.includeTeams = true; + $scope.includeRobots = true; + $scope.currentEntityInternal = $scope.currentEntity; + var isSupported = function(kind, opt_array) { + return $.inArray(kind, opt_array || $scope.allowedEntities || ['user', 'team', 'robot']) >= 0; + }; + $scope.lazyLoad = function() { if (!$scope.namespace || !$scope.lazyLoading) { return; } - // Determine whether we can admin this namespace. - $scope.isAdmin = UserService.isNamespaceAdmin($scope.namespace); - - // If the scope is an organization and we are not part of it, then nothing more we can do. - if (!$scope.isAdmin && $scope.isOrganization && !UserService.getOrganization($scope.namespace)) { - $scope.teams = null; - $scope.robots = null; - $scope.lazyLoading = false; - return; - } - - if ($scope.isOrganization && $scope.includeTeams) { + // Reset the cached teams and robots. + $scope.teams = null; + $scope.robots = null; + + // Load the organization's teams (if applicable). + if ($scope.isOrganization && isSupported('team')) { + // Note: We load the org here again so that we always have the fully up-to-date + // teams list. ApiService.getOrganization(null, {'orgname': $scope.namespace}).then(function(resp) { $scope.teams = resp.teams; }); } - ApiService.getRobots($scope.isOrganization ? $scope.namespace : null).then(function(resp) { - $scope.robots = resp.robots; + // Load the user/organization's robots (if applicable). + if ($scope.isAdmin && isSupported('robot')) { + ApiService.getRobots($scope.isOrganization ? $scope.namespace : null).then(function(resp) { + $scope.robots = resp.robots; + $scope.lazyLoading = false; + }, function() { + $scope.lazyLoading = false; + }); + } else { $scope.lazyLoading = false; - }, function() { - $scope.lazyLoading = false; - }); + } }; $scope.createTeam = function() { @@ -3321,7 +3352,7 @@ quayApp.directive('entitySearch', function () { 'is_robot': is_robot }; - if ($scope.is_organization) { + if ($scope.isOrganization) { entity['is_org_member'] = true; } @@ -3331,146 +3362,184 @@ quayApp.directive('entitySearch', function () { $scope.clearEntityInternal = function() { $scope.currentEntityInternal = null; $scope.currentEntity = null; - - if ($scope.entitySelected) { - $scope.entitySelected(null); + $scope.entitySelected({'entity': null}); + if ($scope.ngModel) { + $scope.ngModel.$setValidity('entity', false); } }; $scope.setEntityInternal = function(entity, updateTypeahead) { if (updateTypeahead) { - $(input).typeahead('val', $scope.isPersistent ? entity.name : ''); + $(input).typeahead('val', $scope.autoClear ? '' : entity.name); } else { - $(input).val($scope.isPersistent ? entity.name : ''); + $(input).val($scope.autoClear ? '' : entity.name); } - if ($scope.isPersistent) { + if (!$scope.autoClear) { $scope.currentEntityInternal = entity; $scope.currentEntity = entity; } - if ($scope.entitySelected) { - $scope.entitySelected(entity); + $scope.entitySelected({'entity': entity}); + if ($scope.ngModel) { + $scope.ngModel.$setValidity('entity', !!entity); } }; - number++; + // Setup the typeahead. + var input = $element[0].firstChild.firstChild; + + (function() { + // Create the bloodhound search query system. + $rootScope.__entity_search_counter = (($rootScope.__entity_search_counter || 0) + 1); + var entitySearchB = new Bloodhound({ + name: 'entities' + $rootScope.__entity_search_counter, + remote: { + url: '/api/v1/entities/%QUERY', + replace: function (url, uriEncodedQuery) { + var namespace = $scope.namespace || ''; + url = url.replace('%QUERY', uriEncodedQuery); + url += '?namespace=' + encodeURIComponent(namespace); + if ($scope.isOrganization && isSupported('team')) { + url += '&includeTeams=true' + } + return url; + }, + filter: function(data) { + var datums = []; + for (var i = 0; i < data.results.length; ++i) { + var entity = data.results[i]; - var entitySearchB = new Bloodhound({ - name: 'entities' + number, - remote: { - url: '/api/v1/entities/%QUERY', - replace: function (url, uriEncodedQuery) { - var namespace = $scope.namespace || ''; - url = url.replace('%QUERY', uriEncodedQuery); - url += '?namespace=' + encodeURIComponent(namespace); - if ($scope.includeTeams) { - url += '&includeTeams=true' - } - return url; - }, - filter: function(data) { - var datums = []; - for (var i = 0; i < data.results.length; ++i) { - var entity = data.results[i]; - if ($scope.filter) { - var allowed = $scope.filter; var found = 'user'; if (entity.kind == 'user') { found = entity.is_robot ? 'robot' : 'user'; } else if (entity.kind == 'team') { found = 'team'; } - if (allowed.indexOf(found)) { + + if (!isSupported(found)) { continue; } + + datums.push({ + 'value': entity.name, + 'tokens': [entity.name], + 'entity': entity + }); + } + return datums; + } + }, + datumTokenizer: function(d) { + return Bloodhound.tokenizers.whitespace(d.val); + }, + queryTokenizer: Bloodhound.tokenizers.whitespace + }); + entitySearchB.initialize(); + + // Setup the typeahead. + $(input).typeahead({ + 'highlight': true + }, { + source: entitySearchB.ttAdapter(), + templates: { + 'empty': function(info) { + // Only display the empty dialog if the server load has finished. + if (info.resultKind == 'remote') { + var val = $(input).val(); + if (!val) { + return null; + } + + if (val.indexOf('@') > 0) { + return '
A Quay.io username (not an e-mail address) must be specified
'; + } + + var classes = []; + + if (isSupported('user')) { classes.push('users'); } + if ($scope.isAdmin && isSupported('robot')) { classes.push('robot accounts'); } + if ($scope.isOrganization && isSupported('team')) { classes.push('teams'); } + + if (classes.length > 1) { + classes[classes.length - 1] = 'or ' + classes[classes.length - 1]; + } + + var class_string = ''; + for (var i = 0; i < classes.length; ++i) { + if (i > 0) { + if (i == classes.length - 1) { + class_string += ' or '; + } else { + class_string += ', '; + } + } + + class_string += classes[i]; + } + + return '
No matching Quay.io ' + class_string + ' found
'; } - datums.push({ - 'value': entity.name, - 'tokens': [entity.name], - 'entity': entity - }); - } - return datums; - } - }, - datumTokenizer: function(d) { - return Bloodhound.tokenizers.whitespace(d.val); - }, - queryTokenizer: Bloodhound.tokenizers.whitespace - }); - entitySearchB.initialize(); + return null; + }, + 'suggestion': function (datum) { + template = '
'; + if (datum.entity.kind == 'user' && !datum.entity.is_robot) { + template += ''; + } else if (datum.entity.kind == 'user' && datum.entity.is_robot) { + template += ''; + } else if (datum.entity.kind == 'team') { + template += ''; + } + template += '' + datum.value + ''; - var counter = 0; - var input = $element[0].firstChild.firstChild; - $(input).typeahead({ - 'highlight': true - }, { - source: entitySearchB.ttAdapter(), - templates: { - 'empty': function(info) { - // Only display the empty dialog if the server load has finished. - if (info.resultKind == 'remote') { - var val = $(input).val(); - if (!val) { - return null; + if (datum.entity.is_org_member === false && datum.entity.kind == 'user') { + template += ''; } - if (val.indexOf('@') > 0) { - return '
A Quay.io username (not an e-mail address) must be specified
'; - } + template += '
'; + return template; + }} + }); - var robots = $scope.isOrganization ? ', robot accounts' : ''; - var teams = ($scope.includeTeams && $scope.isOrganization) ? ' or teams' : ''; - return '
No matching Quay.io users' + robots + teams + ' found
'; - } - - return null; - }, - 'suggestion': function (datum) { - template = '
'; - if (datum.entity.kind == 'user' && !datum.entity.is_robot) { - template += ''; - } else if (datum.entity.kind == 'user' && datum.entity.is_robot) { - template += ''; - } else if (datum.entity.kind == 'team') { - template += ''; - } - template += '' + datum.value + ''; - - if (datum.entity.is_org_member === false && datum.entity.kind == 'user') { - template += ''; - } - - template += '
'; - return template; - }} - }); - - $(input).on('input', function(e) { - $scope.$apply(function() { - if ($scope.isPersistent) { + $(input).on('input', function(e) { + $scope.$apply(function() { $scope.clearEntityInternal(); - } + }); }); - }); - $(input).on('typeahead:selected', function(e, datum) { - $scope.$apply(function() { - $scope.setEntityInternal(datum.entity, true); + $(input).on('typeahead:selected', function(e, datum) { + $scope.$apply(function() { + $scope.setEntityInternal(datum.entity, true); + }); }); - }); + })(); + + $scope.$watch('clearValue', function() { + if (!input) { return; } - $scope.$watch('clearNow', function() { $(input).typeahead('val', ''); $scope.clearEntityInternal(); }); - $scope.$watch('inputTitle', function(title) { + $scope.$watch('placeholder', function(title) { input.setAttribute('placeholder', title); }); + $scope.$watch('allowedEntities', function(allowed) { + if (!allowed) { return; } + $scope.includeTeams = isSupported('team', allowed); + $scope.includeRobots = isSupported('robot', allowed); + }); + + $scope.$watch('namespace', function(namespace) { + if (!namespace) { return; } + + $scope.isAdmin = UserService.isNamespaceAdmin(namespace); + $scope.isOrganization = !!UserService.getOrganization(namespace); + }); + $scope.$watch('currentEntity', function(entity) { if ($scope.currentEntityInternal != entity) { if (entity) { diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index ef88e687f..0ce1738b1 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -135,10 +135,11 @@ - + diff --git a/static/partials/team-view.html b/static/partials/team-view.html index 969cb00e9..3737c6427 100644 --- a/static/partials/team-view.html +++ b/static/partials/team-view.html @@ -23,10 +23,13 @@ - - + +