initial import for Open Source 🎉
This commit is contained in:
parent
1898c361f3
commit
9c0dd3b722
2048 changed files with 218743 additions and 0 deletions
14
static/js/directives/fallback-src.js
Normal file
14
static/js/directives/fallback-src.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Adds a fallback-src attribute, which is used as the source for an <img> tag if the main
|
||||
* image fails to load.
|
||||
*/
|
||||
angular.module('quay').directive('fallbackSrc', function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function postLink(scope, element, attributes) {
|
||||
element.bind('error', function() {
|
||||
angular.element(this).attr("src", attributes.fallbackSrc);
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
37
static/js/directives/file-present.js
Normal file
37
static/js/directives/file-present.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Sets the 'filePresent' value on the scope if a file on the marked <input type="file"> exists.
|
||||
*/
|
||||
angular.module('quay').directive("filePresent", [function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
'filePresent': "="
|
||||
},
|
||||
link: function (scope, element, attributes) {
|
||||
element.bind("change", function (changeEvent) {
|
||||
scope.$apply(function() {
|
||||
scope.filePresent = changeEvent.target.files.length > 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
/**
|
||||
* Raises the 'filesChanged' event on the scope if a file on the marked <input type="file"> exists.
|
||||
*/
|
||||
angular.module('quay').directive("filesChanged", [function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
'filesChanged': "&"
|
||||
},
|
||||
link: function (scope, element, attributes) {
|
||||
element.bind("change", function (changeEvent) {
|
||||
scope.$apply(function() {
|
||||
scope.filesChanged({'files': changeEvent.target.files});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}]);
|
26
static/js/directives/filters/abbreviated.js
Normal file
26
static/js/directives/filters/abbreviated.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Filter which displays numbers with suffixes.
|
||||
*
|
||||
* Based on: https://gist.github.com/pedrorocha-net/9aa21d5f34d9cc15d18f
|
||||
*/
|
||||
angular.module('quay').filter('abbreviated', function() {
|
||||
return function(number) {
|
||||
if (number >= 10000000) {
|
||||
return (number / 1000000).toFixed(0) + 'M'
|
||||
}
|
||||
|
||||
if (number >= 1000000) {
|
||||
return (number / 1000000).toFixed(1) + 'M'
|
||||
}
|
||||
|
||||
if (number >= 10000) {
|
||||
return (number / 1000).toFixed(0) + 'K'
|
||||
}
|
||||
|
||||
if (number >= 1000) {
|
||||
return (number / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
});
|
12
static/js/directives/filters/bytes.js
Normal file
12
static/js/directives/filters/bytes.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Filter which displays bytes with suffixes.
|
||||
*/
|
||||
angular.module('quay').filter('bytes', function() {
|
||||
return function(bytes, precision) {
|
||||
if (!bytes || isNaN(parseFloat(bytes)) || !isFinite(bytes)) return 'Unknown';
|
||||
if (typeof precision === 'undefined') precision = 1;
|
||||
var units = ['bytes', 'kB', 'MB', 'GB', 'TB', 'PB'],
|
||||
number = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, Math.floor(number))).toFixed(precision) + ' ' + units[number];
|
||||
}
|
||||
});
|
15
static/js/directives/filters/humanize-date.js
Normal file
15
static/js/directives/filters/humanize-date.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { isNumber } from "util"
|
||||
import moment from "moment"
|
||||
|
||||
/**
|
||||
* Filter which displays a date in a human-readable format.
|
||||
*/
|
||||
angular.module('quay').filter('humanizeDate', function() {
|
||||
return function(input) {
|
||||
if (isNumber(input)) {
|
||||
return moment.unix(input).format('lll'); // Unix Timestamp
|
||||
} else {
|
||||
return moment(input).format('lll');
|
||||
}
|
||||
}
|
||||
});
|
17
static/js/directives/filters/humanize-time-interval.js
Normal file
17
static/js/directives/filters/humanize-time-interval.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Filter which converts an interval of time to a human-readable format.
|
||||
*/
|
||||
angular.module('quay').filter('humanizeInterval', function() {
|
||||
return function(seconds) {
|
||||
let minute = 60;
|
||||
let hour = minute * 60;
|
||||
let day = hour * 24;
|
||||
let week = day * 7;
|
||||
|
||||
if (seconds % week == 0) return (seconds / week) + ' weeks';
|
||||
if (seconds % day == 0) return (seconds / day) + ' days';
|
||||
if (seconds % hour == 0) return (seconds / hour) + ' hours';
|
||||
if (seconds % minute == 0) return (seconds / minute) + ' minutes';
|
||||
return seconds + ' seconds';
|
||||
}
|
||||
});
|
23
static/js/directives/filters/regex.js
Normal file
23
static/js/directives/filters/regex.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Regular expression filter.
|
||||
*/
|
||||
angular.module('quay').filter('regex', function() {
|
||||
return function(input, regex) {
|
||||
if (!regex) { return []; }
|
||||
|
||||
try {
|
||||
var patt = new RegExp(regex);
|
||||
} catch (ex) {
|
||||
return [];
|
||||
}
|
||||
|
||||
var out = [];
|
||||
for (var i = 0; i < input.length; ++i){
|
||||
var m = input[i].match(patt);
|
||||
if (m && m[0].length == input[i].length) {
|
||||
out.push(input[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
});
|
8
static/js/directives/filters/reverse.js
Normal file
8
static/js/directives/filters/reverse.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Reversing filter.
|
||||
*/
|
||||
angular.module('quay').filter('reverse', function() {
|
||||
return function(items) {
|
||||
return items.slice().reverse();
|
||||
};
|
||||
});
|
8
static/js/directives/filters/slice.js
Normal file
8
static/js/directives/filters/slice.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Slice filter.
|
||||
*/
|
||||
angular.module('quay').filter('slice', function() {
|
||||
return function(arr, start, end) {
|
||||
return (arr || []).slice(start, end);
|
||||
};
|
||||
});
|
19
static/js/directives/filters/visible-log-filter.js
Normal file
19
static/js/directives/filters/visible-log-filter.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Filter for hiding logs that don't meet the allowed predicate.
|
||||
*/
|
||||
angular.module('quay').filter('visibleLogFilter', function () {
|
||||
return function (logs, allowed) {
|
||||
if (!allowed) {
|
||||
return logs;
|
||||
}
|
||||
|
||||
var filtered = [];
|
||||
angular.forEach(logs, function (log) {
|
||||
if (allowed[log.kind]) {
|
||||
filtered.push(log);
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
};
|
||||
});
|
37
static/js/directives/focusable-popover-content.js
Normal file
37
static/js/directives/focusable-popover-content.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* An element which, when used to display content inside a popover, hide the popover once
|
||||
* the content loses focus.
|
||||
*/
|
||||
angular.module('quay').directive('focusablePopoverContent', ['$timeout', '$popover', function ($timeout, $popover) {
|
||||
return {
|
||||
restrict: "A",
|
||||
link: function (scope, element, attrs) {
|
||||
$body = $('body');
|
||||
var hide = function() {
|
||||
$body.off('click');
|
||||
|
||||
if (!scope) { return; }
|
||||
scope.$apply(function() {
|
||||
if (!scope || !scope.$hide) { return; }
|
||||
scope.$hide();
|
||||
});
|
||||
};
|
||||
|
||||
scope.$on('$destroy', function() {
|
||||
$body.off('click');
|
||||
});
|
||||
|
||||
$timeout(function() {
|
||||
$body.on('click', function(evt) {
|
||||
var target = evt.target;
|
||||
var isPanelMember = $(element).has(target).length > 0 || target == element;
|
||||
if (!isPanelMember) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
$(element).find('input').focus();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
}]);
|
16
static/js/directives/match.js
Normal file
16
static/js/directives/match.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Adds a 'match' attribute that ensures that a form field's value matches another field's
|
||||
* value.
|
||||
*/
|
||||
angular.module('quay').directive('match', function($parse) {
|
||||
return {
|
||||
require: 'ngModel',
|
||||
link: function(scope, elem, attrs, ctrl) {
|
||||
scope.$watch(function() {
|
||||
return $parse(attrs.match)(scope) === ctrl.$modelValue;
|
||||
}, function(currentValue) {
|
||||
ctrl.$setValidity('mismatch', currentValue);
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
7
static/js/directives/ng-blur.js
Normal file
7
static/js/directives/ng-blur.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
angular.module('quay').directive('ngBlur', function() {
|
||||
return function( scope, elem, attrs ) {
|
||||
elem.bind('blur', function() {
|
||||
scope.$apply(attrs.ngBlur);
|
||||
});
|
||||
};
|
||||
});
|
15
static/js/directives/ng-if-media.js
Normal file
15
static/js/directives/ng-if-media.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Adds an ng-if-media attribute that evaluates a media query and, if false, removes the element.
|
||||
*/
|
||||
angular.module('quay').directive('ngIfMedia', function ($animate, AngularHelper) {
|
||||
return {
|
||||
transclude: 'element',
|
||||
priority: 600,
|
||||
terminal: true,
|
||||
restrict: 'A',
|
||||
link: AngularHelper.buildConditionalLinker($animate, 'ngIfMedia', function(value) {
|
||||
return window.matchMedia(value).matches;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
24
static/js/directives/ng-image-watch.js
Normal file
24
static/js/directives/ng-image-watch.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Adds a ng-image-watch attribute, which is a callback invoked when the image is loaded or fails.
|
||||
*/
|
||||
angular.module('quay').directive('ngImageWatch', function ($parse) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
compile: function($element, attr) {
|
||||
var fn = $parse(attr['ngImageWatch']);
|
||||
return function(scope, element) {
|
||||
element.bind('error', function() {
|
||||
scope.$apply(function() {
|
||||
fn(scope, {result: false});
|
||||
})
|
||||
});
|
||||
|
||||
element.bind('load', function() {
|
||||
scope.$apply(function() {
|
||||
fn(scope, {result: true});
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
11
static/js/directives/ng-name.js
Normal file
11
static/js/directives/ng-name.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Adds an ng-name attribute which sets the name of a form field. Using the normal name field
|
||||
* in Angular 1.3 works, but we're still on 1.2.
|
||||
*/
|
||||
angular.module('quay').directive('ngName', function () {
|
||||
return function (scope, element, attr) {
|
||||
scope.$watch(attr.ngName, function (name) {
|
||||
element.attr('name', name);
|
||||
});
|
||||
};
|
||||
});
|
25
static/js/directives/ng-transcope.js
Normal file
25
static/js/directives/ng-transcope.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Directive to transclude a template under an ng-repeat. From: http://stackoverflow.com/a/24512435
|
||||
*/
|
||||
angular.module('quay').directive('ngTranscope', function() {
|
||||
return {
|
||||
link: function( $scope, $element, $attrs, controller, $transclude ) {
|
||||
if ( !$transclude ) {
|
||||
throw minErr( 'ngTranscope' )( 'orphan',
|
||||
'Illegal use of ngTransclude directive in the template! ' +
|
||||
'No parent directive that requires a transclusion found. ' +
|
||||
'Element: {0}',
|
||||
startingTag( $element ));
|
||||
}
|
||||
var innerScope = $scope.$new();
|
||||
|
||||
$transclude( innerScope, function( clone ) {
|
||||
$element.empty();
|
||||
$element.append( clone );
|
||||
$element.on( '$destroy', function() {
|
||||
innerScope.$destroy();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
10
static/js/directives/ng-visible.js
Normal file
10
static/js/directives/ng-visible.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Adds an ng-visible attribute that hides an element if the expression evaluates to false.
|
||||
*/
|
||||
angular.module('quay').directive('ngVisible', function () {
|
||||
return function (scope, element, attr) {
|
||||
scope.$watch(attr.ngVisible, function (visible) {
|
||||
element.css('visibility', visible ? 'visible' : 'hidden');
|
||||
});
|
||||
};
|
||||
});
|
19
static/js/directives/object-order-by.js
Normal file
19
static/js/directives/object-order-by.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
// From: http://justinklemm.com/angularjs-filter-ordering-objects-ngrepeat/ under MIT License
|
||||
angular
|
||||
.module('quay')
|
||||
.filter('orderObjectBy', function() {
|
||||
return function(items, field, reverse) {
|
||||
var filtered = [];
|
||||
angular.forEach(items, function(item) {
|
||||
filtered.push(item);
|
||||
});
|
||||
|
||||
filtered.sort(function (a, b) {
|
||||
return (a[field] > b[field] ? 1 : -1);
|
||||
});
|
||||
|
||||
if(reverse) filtered.reverse();
|
||||
|
||||
return filtered;
|
||||
};
|
||||
});
|
24
static/js/directives/onresize.js
Normal file
24
static/js/directives/onresize.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Adds an onresize event attribute that gets invokved when the size of the window changes.
|
||||
*/
|
||||
angular.module('quay').directive('onresize', function ($window, $parse, $timeout) {
|
||||
return function (scope, element, attr) {
|
||||
var fn = $parse(attr.onresize);
|
||||
|
||||
var notifyResized = function() {
|
||||
// Angular.js enforces only one call to $apply can run at a time.
|
||||
// Use $timeout to make the scope update safe, even when called within another $apply block,
|
||||
// by scheduling it on the call stack.
|
||||
// See docs: https://docs.angularjs.org/error/$rootScope/inprog
|
||||
$timeout(function () {
|
||||
fn(scope);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
angular.element($window).on('resize', null, notifyResized);
|
||||
|
||||
scope.$on('$destroy', function() {
|
||||
angular.element($window).off('resize', null, notifyResized);
|
||||
});
|
||||
};
|
||||
});
|
145
static/js/directives/quay-layout.js
Normal file
145
static/js/directives/quay-layout.js
Normal file
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Directives which show, hide, include or otherwise mutate the DOM based on Features and Config.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Adds a quay-show attribute that shows the element only if the attribute evaluates to true.
|
||||
* The Features and Config services are added into the scope's context automatically.
|
||||
*/
|
||||
angular.module('quay').directive('quayShow', function($animate, Features, Config) {
|
||||
return {
|
||||
priority: 590,
|
||||
restrict: 'A',
|
||||
link: function($scope, $element, $attr, ctrl, $transclude) {
|
||||
$scope.Features = Features;
|
||||
$scope.Config = Config;
|
||||
$scope.$watch($attr.quayShow, function(result) {
|
||||
$animate[!!result ? 'removeClass' : 'addClass']($element, 'ng-hide');
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Adds a quay-section attribute that adds an 'active' class to the element if the current URL
|
||||
* matches the given section.
|
||||
*/
|
||||
angular.module('quay').directive('quaySection', function($animate, $location, $rootScope) {
|
||||
return {
|
||||
priority: 590,
|
||||
restrict: 'A',
|
||||
link: function($scope, $element, $attr, ctrl, $transclude) {
|
||||
var update = function() {
|
||||
var result = $location.path().indexOf('/' + $attr.quaySection) == 0;
|
||||
$animate[!result ? 'removeClass' : 'addClass']($element, 'active');
|
||||
};
|
||||
|
||||
$scope.$watch(function(){
|
||||
return $location.path();
|
||||
}, update);
|
||||
|
||||
$scope.$watch($attr.quaySection, update);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds a quay-classes attribute that performs like ng-class, but with Features and Config also
|
||||
* available in the scope automatically.
|
||||
*/
|
||||
angular.module('quay').directive('quayClasses', function(Features, Config) {
|
||||
return {
|
||||
priority: 580,
|
||||
restrict: 'A',
|
||||
link: function($scope, $element, $attr, ctrl, $transclude) {
|
||||
|
||||
// Borrowed from ngClass.
|
||||
function flattenClasses(classVal) {
|
||||
if(angular.isArray(classVal)) {
|
||||
return classVal.join(' ');
|
||||
} else if (angular.isObject(classVal)) {
|
||||
var classes = [], i = 0;
|
||||
angular.forEach(classVal, function(v, k) {
|
||||
if (v) {
|
||||
classes.push(k);
|
||||
}
|
||||
});
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
return classVal;
|
||||
}
|
||||
|
||||
function removeClass(classVal) {
|
||||
$attr.$removeClass(flattenClasses(classVal));
|
||||
}
|
||||
|
||||
|
||||
function addClass(classVal) {
|
||||
$attr.$addClass(flattenClasses(classVal));
|
||||
}
|
||||
|
||||
$scope.$watch($attr.quayClasses, function(result) {
|
||||
var scopeVals = {
|
||||
'Features': Features,
|
||||
'Config': Config
|
||||
};
|
||||
|
||||
for (var expr in result) {
|
||||
if (!result.hasOwnProperty(expr)) { continue; }
|
||||
|
||||
// Evaluate the expression with the entire features list added.
|
||||
var value = $scope.$eval(expr, scopeVals);
|
||||
if (value) {
|
||||
addClass(result[expr]);
|
||||
} else {
|
||||
removeClass(result[expr]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds a quay-static-include attribute handles adding static marketing content from a defined
|
||||
* S3 bucket. If running under QE, the local template is used.
|
||||
*
|
||||
* Usage: quay-static-include="{'hosted': 'index.html', 'otherwise': 'partials/landing-login.html'}"
|
||||
*/
|
||||
angular.module('quay').directive('quayStaticInclude', function($compile, $templateCache, $http, Features, Config) {
|
||||
return {
|
||||
priority: 595,
|
||||
restrict: 'A',
|
||||
link: function($scope, $element, $attr, ctrl) {
|
||||
var getTemplate = function(hostedTemplateName, staticTemplateName) {
|
||||
var staticTemplateUrl = '/static/' + staticTemplateName;
|
||||
var templateUrl = staticTemplateUrl;
|
||||
if (Features.BILLING && Config['STATIC_SITE_BUCKET']) {
|
||||
templateUrl = Config['STATIC_SITE_BUCKET'] + hostedTemplateName;
|
||||
}
|
||||
|
||||
return $http.get(templateUrl, {cache: $templateCache}).catch(function(resolve, reject) {
|
||||
// Fallback to the static local URL if the hosted URL doesn't work.
|
||||
return $http.get(staticTemplateUrl, {cache: $templateCache});
|
||||
});
|
||||
};
|
||||
|
||||
var result = $scope.$eval($attr.quayStaticInclude);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
var promise = getTemplate(result['hosted'], result['otherwise']).then(function (response) {
|
||||
$element.replaceWith($compile(response['data'])($scope));
|
||||
if ($attr.onload) {
|
||||
$scope.$eval($attr.onload);
|
||||
}
|
||||
}).catch(function(err) {
|
||||
console.log(err)
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
28
static/js/directives/quay-message-bar.js
Normal file
28
static/js/directives/quay-message-bar.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* An element which displays a message for users to read.
|
||||
*/
|
||||
angular.module('quay').directive('quayMessageBar', function () {
|
||||
return {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/quay-message-bar.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {},
|
||||
controller: function ($scope, $element, $rootScope, ApiService, NotificationService,
|
||||
StateService) {
|
||||
$scope.messages = [];
|
||||
$scope.NotificationService = NotificationService;
|
||||
|
||||
StateService.updateStateIn($scope, function(state) {
|
||||
$scope.inReadOnlyMode = state.inReadOnlyMode;
|
||||
});
|
||||
|
||||
ApiService.getGlobalMessages().then(function (data) {
|
||||
$scope.messages = data['messages'] || [];
|
||||
}, function (resp) {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
104
static/js/directives/range-slider.js
Normal file
104
static/js/directives/range-slider.js
Normal file
|
@ -0,0 +1,104 @@
|
|||
// From: https://github.com/angular/angular.js/issues/6726#issuecomment-116251130
|
||||
angular.module('quay').directive('rangeSlider', [function () {
|
||||
return {
|
||||
replace: true,
|
||||
restrict: 'E',
|
||||
require: 'ngModel',
|
||||
template: '<input type="range"/>',
|
||||
link: function (scope, element, attrs, ngModel) {
|
||||
var ngRangeMin;
|
||||
var ngRangeMax;
|
||||
var ngRangeStep;
|
||||
var value;
|
||||
|
||||
function init() {
|
||||
if (!angular.isDefined(attrs.ngRangeMin)) {
|
||||
ngRangeMin = 0;
|
||||
} else {
|
||||
scope.$watch(attrs.ngRangeMin, function (newValue, oldValue, scope) {
|
||||
if (angular.isDefined(newValue)) {
|
||||
ngRangeMin = newValue;
|
||||
setValue();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!angular.isDefined(attrs.ngRangeMax)) {
|
||||
ngRangeMax = 100;
|
||||
} else {
|
||||
scope.$watch(attrs.ngRangeMax, function (newValue, oldValue, scope) {
|
||||
if (angular.isDefined(newValue)) {
|
||||
ngRangeMax = newValue;
|
||||
setValue();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!angular.isDefined(attrs.ngRangeStep)) {
|
||||
ngRangeStep = 1;
|
||||
} else {
|
||||
scope.$watch(attrs.ngRangeStep, function (newValue, oldValue, scope) {
|
||||
if (angular.isDefined(newValue)) {
|
||||
ngRangeStep = newValue;
|
||||
setValue();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!angular.isDefined(ngModel)) {
|
||||
value = 50;
|
||||
} else {
|
||||
scope.$watch(
|
||||
function () {
|
||||
return ngModel.$modelValue;
|
||||
},
|
||||
function (newValue, oldValue, scope) {
|
||||
if (angular.isDefined(newValue)) {
|
||||
value = newValue;
|
||||
setValue();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!ngModel) {
|
||||
return;
|
||||
}
|
||||
ngModel.$parsers.push(function (value) {
|
||||
var val = Number(value);
|
||||
if (val !== val) {
|
||||
val = undefined;
|
||||
}
|
||||
return val;
|
||||
});
|
||||
}
|
||||
|
||||
function setValue() {
|
||||
if (
|
||||
angular.isDefined(ngRangeMin) &&
|
||||
angular.isDefined(ngRangeMax) &&
|
||||
angular.isDefined(ngRangeStep) &&
|
||||
angular.isDefined(value)
|
||||
) {
|
||||
element.attr("min", ngRangeMin);
|
||||
element.attr("max", ngRangeMax);
|
||||
element.attr("step", ngRangeStep);
|
||||
element.val(value);
|
||||
}
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (angular.isDefined(ngModel)) {
|
||||
ngModel.$setViewValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
element.on('change', function () {
|
||||
if (angular.isDefined(value) && (value != element.val())) {
|
||||
value = element.val();
|
||||
scope.$apply(read);
|
||||
}
|
||||
});
|
||||
|
||||
init();
|
||||
}
|
||||
};
|
||||
}
|
||||
]);
|
281
static/js/directives/repo-view/repo-panel-builds.js
Normal file
281
static/js/directives/repo-view/repo-panel-builds.js
Normal file
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* An element which displays the builds panel for a repository view.
|
||||
*/
|
||||
angular.module('quay').directive('repoPanelBuilds', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/repo-view/repo-panel-builds.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'builds': '=builds',
|
||||
'isEnabled': '=isEnabled'
|
||||
},
|
||||
controller: function($scope, $element, $filter, $routeParams, ApiService, TriggerService, UserService, StateService) {
|
||||
StateService.updateStateIn($scope, function(state) {
|
||||
$scope.inReadOnlyMode = state.inReadOnlyMode;
|
||||
});
|
||||
|
||||
var orderBy = $filter('orderBy');
|
||||
|
||||
$scope.TriggerService = TriggerService;
|
||||
|
||||
UserService.updateUserIn($scope);
|
||||
|
||||
$scope.options = {
|
||||
'filter': 'recent',
|
||||
'reverse': false,
|
||||
'predicate': 'started_datetime'
|
||||
};
|
||||
|
||||
$scope.currentFilter = null;
|
||||
|
||||
$scope.currentStartTrigger = null;
|
||||
|
||||
$scope.showBuildDialogCounter = 0;
|
||||
$scope.showTriggerStartDialogCounter = 0;
|
||||
|
||||
$scope.triggerCredentialsModalTrigger = null;
|
||||
$scope.triggerCredentialsModalCounter = 0;
|
||||
|
||||
$scope.feedback = null;
|
||||
|
||||
var updateBuilds = function() {
|
||||
if (!$scope.allBuilds) { return; }
|
||||
|
||||
var unordered = $scope.allBuilds.map(function(build_info) {
|
||||
var commit_sha = null;
|
||||
|
||||
if (build_info.trigger_metadata) {
|
||||
commit_sha = TriggerService.getCommitSHA(build_info.trigger_metadata);
|
||||
}
|
||||
|
||||
return $.extend(build_info, {
|
||||
'started_datetime': (new Date(build_info.started)).valueOf() * (-1),
|
||||
'building_tags': build_info.tags || [],
|
||||
'commit_sha': commit_sha
|
||||
});
|
||||
});
|
||||
|
||||
$scope.fullBuilds = orderBy(unordered, $scope.options.predicate, $scope.options.reverse);
|
||||
};
|
||||
|
||||
var loadBuilds = function(opt_forcerefresh) {
|
||||
if (!$scope.builds || !$scope.repository || !$scope.options.filter || !$scope.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: We only refresh if the filter has changed.
|
||||
var filter = $scope.options.filter;
|
||||
if ($scope.buildsResource && filter == $scope.currentFilter && !opt_forcerefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
var since = null;
|
||||
var limit = 10;
|
||||
|
||||
if ($scope.options.filter == '48hour') {
|
||||
since = Math.floor(moment().subtract(2, 'days').valueOf() / 1000);
|
||||
limit = 100;
|
||||
} else if ($scope.options.filter == '30day') {
|
||||
since = Math.floor(moment().subtract(30, 'days').valueOf() / 1000);
|
||||
limit = 100;
|
||||
} else {
|
||||
since = null;
|
||||
limit = 10;
|
||||
}
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'limit': limit,
|
||||
'since': since
|
||||
};
|
||||
|
||||
$scope.buildsResource = ApiService.getRepoBuildsAsResource(params).get(function(resp) {
|
||||
$scope.allBuilds = resp.builds;
|
||||
$scope.currentFilter = filter;
|
||||
updateBuilds();
|
||||
});
|
||||
};
|
||||
|
||||
var buildsChanged = function() {
|
||||
if (!$scope.allBuilds) {
|
||||
loadBuilds();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$scope.builds || !$scope.repository || !$scope.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace any build records with updated records from the server.
|
||||
var requireReload = false;
|
||||
$scope.builds.map(function(build) {
|
||||
var found = false;
|
||||
for (var i = 0; i < $scope.allBuilds.length; ++i) {
|
||||
var current = $scope.allBuilds[i];
|
||||
if (current.id == build.id && current.phase != build.phase) {
|
||||
$scope.allBuilds[i] = build;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the build was not found, then a new build has started. Reload
|
||||
// the builds list.
|
||||
if (!found) {
|
||||
requireReload = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (requireReload) {
|
||||
loadBuilds(/* force refresh */true);
|
||||
} else {
|
||||
updateBuilds();
|
||||
}
|
||||
};
|
||||
|
||||
var loadBuildTriggers = function() {
|
||||
if (!$scope.repository || !$scope.repository.can_admin || !$scope.isEnabled) { return; }
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
||||
};
|
||||
|
||||
$scope.triggersResource = ApiService.listBuildTriggersAsResource(params).get(function(resp) {
|
||||
$scope.triggers = resp.triggers;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch('repository', loadBuildTriggers);
|
||||
$scope.$watch('repository', loadBuilds);
|
||||
|
||||
$scope.$watch('isEnabled', loadBuildTriggers);
|
||||
$scope.$watch('isEnabled', loadBuilds);
|
||||
|
||||
$scope.$watch('builds', buildsChanged);
|
||||
|
||||
$scope.$watch('options.filter', loadBuilds);
|
||||
$scope.$watch('options.predicate', updateBuilds);
|
||||
$scope.$watch('options.reverse', updateBuilds);
|
||||
|
||||
$scope.tablePredicateClass = function(name, predicate, reverse) {
|
||||
if (name != predicate) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'current ' + (reverse ? 'reversed' : '');
|
||||
};
|
||||
|
||||
$scope.orderBy = function(predicate) {
|
||||
if (predicate == $scope.options.predicate) {
|
||||
$scope.options.reverse = !$scope.options.reverse;
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.options.reverse = false;
|
||||
$scope.options.predicate = predicate;
|
||||
};
|
||||
|
||||
$scope.showTriggerCredentialsModal = function(trigger) {
|
||||
$scope.triggerCredentialsModalTrigger = trigger;
|
||||
$scope.triggerCredentialsModalCounter++;
|
||||
};
|
||||
|
||||
$scope.askDeleteTrigger = function(trigger) {
|
||||
$scope.deleteTriggerInfo = {
|
||||
'trigger': trigger
|
||||
};
|
||||
};
|
||||
|
||||
$scope.askRunTrigger = function(trigger) {
|
||||
if (!trigger.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trigger.can_invoke) {
|
||||
bootbox.alert('You do not have permission to manually invoke this trigger');
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.currentStartTrigger = trigger;
|
||||
$scope.showTriggerStartDialogCounter++;
|
||||
};
|
||||
|
||||
$scope.askToggleTrigger = function(trigger) {
|
||||
if (!trigger.can_invoke) {
|
||||
bootbox.alert('You do not have permission to edit this trigger');
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.toggleTriggerInfo = {
|
||||
'trigger': trigger
|
||||
};
|
||||
};
|
||||
|
||||
$scope.toggleTrigger = function(trigger, opt_callback) {
|
||||
if (!trigger) { return; }
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'trigger_uuid': trigger.id
|
||||
};
|
||||
|
||||
var data = {
|
||||
'enabled': !trigger.enabled
|
||||
};
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Could not toggle build trigger', function() {
|
||||
opt_callback && opt_callback(false);
|
||||
});
|
||||
|
||||
ApiService.updateBuildTrigger(data, params).then(function(resp) {
|
||||
trigger.enabled = !trigger.enabled;
|
||||
trigger.disabled_reason = 'user_toggled';
|
||||
opt_callback && opt_callback(true);
|
||||
}, errorHandler);
|
||||
};
|
||||
|
||||
$scope.deleteTrigger = function(trigger, opt_callback) {
|
||||
if (!trigger) { return; }
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'trigger_uuid': trigger.id
|
||||
};
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Could not delete build trigger', function() {
|
||||
opt_callback && opt_callback(false);
|
||||
});
|
||||
|
||||
ApiService.deleteBuildTrigger(null, params).then(function(resp) {
|
||||
$scope.triggers.splice($scope.triggers.indexOf(trigger), 1);
|
||||
opt_callback && opt_callback(true);
|
||||
}, errorHandler);
|
||||
};
|
||||
|
||||
$scope.showNewBuildDialog = function() {
|
||||
$scope.showBuildDialogCounter++;
|
||||
};
|
||||
|
||||
$scope.handleBuildStarted = function(build) {
|
||||
if ($scope.allBuilds) {
|
||||
$scope.allBuilds.push(build);
|
||||
}
|
||||
updateBuilds();
|
||||
|
||||
$scope.feedback = {
|
||||
'kind': 'info',
|
||||
'message': 'Build {buildid} started',
|
||||
'data': {
|
||||
'buildid': build.id
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
58
static/js/directives/repo-view/repo-panel-info.js
Normal file
58
static/js/directives/repo-view/repo-panel-info.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* An element which displays the information panel for a repository view.
|
||||
*/
|
||||
angular.module('quay').directive('repoPanelInfo', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/repo-view/repo-panel-info.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'builds': '=builds',
|
||||
'isEnabled': '=isEnabled'
|
||||
},
|
||||
controller: function($scope, $element, ApiService, Config, Features, StateService) {
|
||||
StateService.updateStateIn($scope, function(state) {
|
||||
$scope.inReadOnlyMode = state.inReadOnlyMode;
|
||||
});
|
||||
|
||||
$scope.Features = Features;
|
||||
|
||||
$scope.$watch('repository', function(repository) {
|
||||
if (!$scope.repository) { return; }
|
||||
|
||||
var namespace = $scope.repository.namespace;
|
||||
var name = $scope.repository.name;
|
||||
|
||||
$scope.pullCommand = 'docker pull ' + Config.getDomain() + '/' + namespace + '/' + name;
|
||||
});
|
||||
|
||||
$scope.updateDescription = function(content) {
|
||||
$scope.repository.description = content;
|
||||
$scope.repository.put();
|
||||
};
|
||||
|
||||
$scope.getAggregatedUsage = function(stats, days) {
|
||||
if (!stats || !stats.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
var startDate = moment().subtract(days + 1, 'days');
|
||||
for (var i = 0; i < stats.length; ++i) {
|
||||
var stat = stats[i];
|
||||
var statDate = moment(stat['date']);
|
||||
if (statDate.isBefore(startDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
count += stat['count'];
|
||||
}
|
||||
return count;
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
468
static/js/directives/repo-view/repo-panel-mirroring.js
Normal file
468
static/js/directives/repo-view/repo-panel-mirroring.js
Normal file
|
@ -0,0 +1,468 @@
|
|||
import { vendor } from "postcss";
|
||||
|
||||
/**
|
||||
* An element which displays the mirroring panel for a repository view.
|
||||
*/
|
||||
angular.module('quay').directive('repoPanelMirror', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/repo-view/repo-panel-mirroring.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'isEnabled': '=isEnabled'
|
||||
},
|
||||
controllerAs: 'vm',
|
||||
controller: function ($scope, ApiService, Features) {
|
||||
|
||||
let vm = this;
|
||||
|
||||
// Feature Flagged
|
||||
if (!Features.REPO_MIRROR) { return; }
|
||||
|
||||
// Shared by API Calls
|
||||
let params = { 'repository': $scope.repository.namespace + '/' + $scope.repository.name };
|
||||
|
||||
/**
|
||||
* Mirror Configuration
|
||||
*/
|
||||
vm.isSetup = false;
|
||||
vm.expirationDate = null;
|
||||
vm.isEnabled = null;
|
||||
vm.httpProxy = null;
|
||||
vm.httpsProxy = null;
|
||||
vm.externalReference = null;
|
||||
vm.noProxy = null;
|
||||
vm.retriesRemaining = null;
|
||||
vm.robot = null;
|
||||
vm.status = null;
|
||||
vm.syncInterval = null;
|
||||
vm.syncStartDate = null;
|
||||
vm.tags = null;
|
||||
vm.username = null;
|
||||
vm.verifyTLS = null;
|
||||
|
||||
/**
|
||||
* Fetch the latest Repository Mirror Configuration
|
||||
*/
|
||||
vm.getMirror = function() {
|
||||
ApiService.getRepoMirrorConfig(null, params)
|
||||
.then(function (resp) {
|
||||
vm.isSetup = true;
|
||||
|
||||
vm.isEnabled = resp.is_enabled;
|
||||
vm.externalReference = resp.external_reference;
|
||||
vm.syncInterval = resp.sync_interval;
|
||||
vm.username = resp.external_registry_username;
|
||||
vm.syncStartDate = resp.sync_start_date;
|
||||
vm.status = resp.sync_status;
|
||||
vm.expirationDate = resp.sync_expiration_date;
|
||||
vm.retriesRemaining = resp.sync_retries_remaining;
|
||||
|
||||
vm.robot = {};
|
||||
if (resp.robot_username) {
|
||||
vm.robot = {
|
||||
'name': resp.robot_username,
|
||||
'kind': 'user',
|
||||
'is_robot': true
|
||||
};
|
||||
}
|
||||
|
||||
vm.tags = resp.root_rule.rule_value || []; // TODO: Use RepoMirrorRule-specific endpoint
|
||||
|
||||
// TODO: These are not consistently provided by the API. Correct that in the API.
|
||||
vm.verifyTLS = resp.external_registry_config.verify_tls;
|
||||
if (resp.external_registry_config.proxy) {
|
||||
vm.httpProxy = resp.external_registry_config.proxy.http_proxy;
|
||||
vm.httpsProxy = resp.external_registry_config.proxy.https_proxy;
|
||||
vm.noProxy = resp.external_registry_config.proxy.no_proxy;
|
||||
}
|
||||
}, function (err) { console.info("No repository mirror configuration.", err); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-friendly status messages
|
||||
*/
|
||||
vm.statusLabels = {
|
||||
"NEVER_RUN": "Scheduled",
|
||||
"SYNC_NOW": "Scheduled Now",
|
||||
"SYNCING": "Sync In Progress",
|
||||
"SUCCESS": "Last Sync Succeeded",
|
||||
"FAIL": "Last Sync Failed"
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert (Unix) Timestamp to ISO Formatted Date String used by the API
|
||||
*/
|
||||
vm.timestampToISO = function(ts) {
|
||||
let dt = moment.unix(ts).toISOString().split('.')[0] + 'Z'; // Remove milliseconds
|
||||
return dt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ISO Date String to (Unix) Timestamp
|
||||
*/
|
||||
vm.timestampFromISO = function(dt) {
|
||||
let ts = moment(dt).unix();
|
||||
return ts;
|
||||
}
|
||||
|
||||
/**
|
||||
* When set to a truthy value, any `cor-confirm-dialog` associated with these variables will
|
||||
* be displayed.
|
||||
*/
|
||||
vm.credentialsChanges = null;
|
||||
vm.httpProxyChanges = null;
|
||||
vm.httpsProxyChanges = null;
|
||||
vm.locationChanges = null;
|
||||
vm.noProxyChanges = null;
|
||||
vm.syncIntervalChanges = null;
|
||||
vm.syncStartDateChanges = null;
|
||||
vm.tagChanges = null;
|
||||
|
||||
/**
|
||||
* The following `show` functions initialize and trigger the display of a modal to modify
|
||||
* configuration attributes.
|
||||
*/
|
||||
|
||||
vm.showChangeSyncInterval = function() {
|
||||
vm.syncIntervalChanges = {
|
||||
'fieldName': 'synchronization interval',
|
||||
'values': {
|
||||
'sync_interval': vm.syncInterval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.showChangeSyncStartDate = function() {
|
||||
let ts = vm.timestampFromISO(vm.syncStartDate);
|
||||
vm.syncStartDateChanges = {
|
||||
'fieldName': 'next synchronization date',
|
||||
'values': {
|
||||
'sync_start_date': ts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.showChangeTags = function() {
|
||||
vm.tagChanges = {
|
||||
'fieldName': 'tag patterns',
|
||||
'values': {
|
||||
'rule_value': vm.tags || []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.showChangeCredentials = function() {
|
||||
vm.credentialsChanges = {
|
||||
'fieldName': 'credentials',
|
||||
'values': {
|
||||
'external_registry_username': vm.username,
|
||||
'external_registry_password': null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.showChangeHTTPProxy = function() {
|
||||
vm.httpProxyChanges = {
|
||||
'fieldName': 'HTTP Proxy',
|
||||
'values': {
|
||||
'external_registry_config': {
|
||||
'proxy': {
|
||||
'http_proxy': vm.httpProxy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.showChangeHTTPsProxy = function() {
|
||||
vm.httpsProxyChanges = {
|
||||
'fieldName': 'HTTPs Proxy',
|
||||
'values': {
|
||||
'external_registry_config': {
|
||||
'proxy': {
|
||||
'https_proxy': vm.httpsProxy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.showChangeNoProxy = function() {
|
||||
vm.noProxyChanges = {
|
||||
'fieldName': 'No Proxy',
|
||||
'values': {
|
||||
'external_registry_config': {
|
||||
'proxy': {
|
||||
'no_proxy': vm.noProxy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vm.showChangeExternalRepository = function() {
|
||||
vm.externalRepositoryChanges = {
|
||||
'fieldName': 'External Repository',
|
||||
'values': {
|
||||
'external_reference': vm.externalReference
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit API request to modify repository mirroring attribute(s)
|
||||
*/
|
||||
vm.changeConfig = function(data, callback) {
|
||||
|
||||
let fieldName = data.fieldName || 'configuration';
|
||||
let requestBody = data.values;
|
||||
let errMsg = "Unable to change " + fieldName + '.';
|
||||
let handleError = ApiService.errorDisplay(errMsg, callback);
|
||||
|
||||
let handleSuccess = function() {
|
||||
vm.getMirror(); // Fetch updated configuration
|
||||
if (callback) callback(true);
|
||||
}
|
||||
|
||||
ApiService.changeRepoMirrorConfig(requestBody, params)
|
||||
.then(handleSuccess, handleError);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the DatePicker's Unix timestamp into a string compatible with the API
|
||||
* before attempting to change it.
|
||||
*/
|
||||
vm.changeSyncStartDate = function(data, callback) {
|
||||
let newSyncStartDate = vm.timestampToISO(data.values.sync_start_date);
|
||||
data.values.sync_start_date = newSyncStartDate;
|
||||
return vm.changeConfig(data, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable `Verify TLS`.
|
||||
*/
|
||||
vm.enableVerifyTLS = function() {
|
||||
let data = {
|
||||
'fieldName': 'TLS verification',
|
||||
'values': {
|
||||
'external_registry_config': {
|
||||
'verify_tls': true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vm.changeConfig(data, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable `Verify TLS`
|
||||
*/
|
||||
vm.disableVerifyTLS = function() {
|
||||
let data = {
|
||||
'fieldName': 'TLS verification',
|
||||
'values': {
|
||||
'external_registry_config': {
|
||||
'verify_tls': false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vm.changeConfig(data, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle `Verify TLS`.
|
||||
*/
|
||||
vm.toggleVerifyTLS = function() {
|
||||
if (vm.verifyTLS) return vm.disableVerifyTLS();
|
||||
else return vm.enableVerifyTLS();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change Robot user.
|
||||
*/
|
||||
vm.changeRobot = function(robot) {
|
||||
if (!vm.robot) return;
|
||||
if (!robot || robot.name == vm.robot.name) return;
|
||||
|
||||
let data = {
|
||||
'fieldName': 'robot',
|
||||
'values': {
|
||||
'robot_username': robot.name
|
||||
}
|
||||
}
|
||||
|
||||
return vm.changeConfig(data, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Credentials
|
||||
*/
|
||||
vm.deleteCredentials = function() {
|
||||
let data = {
|
||||
'fieldName': 'credentials',
|
||||
'values': {
|
||||
'external_registry_username': null,
|
||||
'external_registry_password': null
|
||||
}
|
||||
}
|
||||
|
||||
return vm.changeConfig(data, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable mirroring configuration.
|
||||
*/
|
||||
vm.enableMirroring = function() {
|
||||
let data = {
|
||||
'fieldName': 'enabled state',
|
||||
'values': {
|
||||
'is_enabled': true
|
||||
}
|
||||
}
|
||||
|
||||
return vm.changeConfig(data, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable mirroring configuration.
|
||||
*/
|
||||
vm.disableMirroring = function() {
|
||||
let data = {
|
||||
'fieldName': 'enabled state',
|
||||
'values': {
|
||||
'is_enabled': false
|
||||
}
|
||||
}
|
||||
|
||||
return vm.changeConfig(data, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mirroring on/off.
|
||||
*/
|
||||
vm.toggleMirroring = function() {
|
||||
if (vm.isEnabled) return vm.disableMirroring();
|
||||
else return vm.enableMirroring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Tag-Rules
|
||||
*/
|
||||
vm.changeTagRules = function(data, callback) {
|
||||
let csv = data.values.rule_value;
|
||||
let patterns = csv.split(',');
|
||||
|
||||
patterns.map(s => s.trim()); // Trim excess whitespace
|
||||
patterns = Array.from(new Set(patterns)); // De-duplicate
|
||||
|
||||
if (patterns.length < 1) {
|
||||
bootbox.alert('Rule value required');
|
||||
callback(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
data = {
|
||||
'root_rule': {
|
||||
'rule_type': 'TAG_GLOB_CSV',
|
||||
'rule_value': patterns
|
||||
}
|
||||
}
|
||||
|
||||
let displayError = ApiService.errorDisplay('Could not change Tag Rules', callback);
|
||||
|
||||
ApiService
|
||||
.changeRepoMirrorRule(data, params)
|
||||
.then(function(resp) {
|
||||
vm.getMirror();
|
||||
callback(true);
|
||||
}, displayError);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Immediate Synchronization
|
||||
*/
|
||||
vm.syncNow = function () {
|
||||
let displayError = ApiService.errorDisplay('Unable to sync now', null);
|
||||
|
||||
ApiService
|
||||
.syncNow(null, params)
|
||||
.then(function(resp) {
|
||||
vm.getMirror(); // Reload latest changes
|
||||
return true;
|
||||
}, displayError);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel In-Progress Synchronization
|
||||
*/
|
||||
vm.syncCancel = function() {
|
||||
let displayError = ApiService.errorDisplay('Unable to cancel sync', null);
|
||||
|
||||
ApiService
|
||||
.syncCancel(null, params)
|
||||
.then(function(resp) {
|
||||
vm.getMirror(); // Reload latest changes
|
||||
return true;
|
||||
}, displayError);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Load the current mirror configuration on initialization
|
||||
if ($scope.repository.state == 'MIRROR') {
|
||||
vm.getMirror();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure mirroing.
|
||||
* TODO: Move this, and the associated template/view, to its own component and use the
|
||||
* wizard-flow instead of a single form.
|
||||
*/
|
||||
vm.setupMirror = function() {
|
||||
|
||||
// Apply transformations
|
||||
let now = vm.timestampToISO(moment().unix());
|
||||
let syncStartDate = vm.timestampToISO(vm.syncStartDate) || now;
|
||||
let patterns = Array.from(new Set(vm.tags.split(',').map(s => s.trim()))); // trim + de-dupe
|
||||
|
||||
let requestBody = {
|
||||
'external_reference': vm.externalReference,
|
||||
'external_registry_username': vm.username,
|
||||
'external_registry_password': vm.password,
|
||||
'sync_interval': vm.syncInterval,
|
||||
'sync_start_date': syncStartDate,
|
||||
'robot_username': vm.robot.name,
|
||||
'external_registry_config': {
|
||||
'verify_tls': vm.verifyTLS || false, // `null` not allowed
|
||||
'proxy': {
|
||||
'http_proxy': vm.httpProxy,
|
||||
'https_proxy': vm.httpsProxy,
|
||||
'no_proxy': vm.noProxy
|
||||
}
|
||||
},
|
||||
'root_rule': {
|
||||
'rule_type': 'TAG_GLOB_CSV',
|
||||
'rule_value': patterns
|
||||
}
|
||||
}
|
||||
|
||||
let successHandler = function(resp) { vm.getMirror(); return true; }
|
||||
let errorHandler = ApiService.errorDisplay('Unable to setup mirror.', null);
|
||||
ApiService.createRepoMirrorConfig(requestBody, params).then(successHandler, errorHandler);
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
123
static/js/directives/repo-view/repo-panel-settings.js
Normal file
123
static/js/directives/repo-view/repo-panel-settings.js
Normal file
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* An element which displays the settings panel for a repository view.
|
||||
*/
|
||||
angular.module('quay').directive('repoPanelSettings', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/repo-view/repo-panel-settings.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'isEnabled': '=isEnabled'
|
||||
},
|
||||
controller: function($scope, $element, $route, ApiService, Config, Features, StateService) {
|
||||
StateService.updateStateIn($scope, function(state) {
|
||||
$scope.inReadOnlyMode = state.inReadOnlyMode;
|
||||
});
|
||||
|
||||
$scope.Features = Features;
|
||||
$scope.deleteDialogCounter = 0;
|
||||
|
||||
var getTitle = function(repo) {
|
||||
return repo.kind == 'application' ? 'application' : 'image';
|
||||
};
|
||||
|
||||
$scope.getBadgeFormat = function(format, repository) {
|
||||
if (!repository) { return ''; }
|
||||
|
||||
var imageUrl = Config.getUrl('/repository/' + repository.namespace + '/' + repository.name + '/status');
|
||||
if (!$scope.repository.is_public) {
|
||||
imageUrl += '?token=' + repository.status_token;
|
||||
}
|
||||
|
||||
var linkUrl = Config.getUrl('/repository/' + repository.namespace + '/' + repository.name);
|
||||
|
||||
switch (format) {
|
||||
case 'svg':
|
||||
return imageUrl;
|
||||
|
||||
case 'md':
|
||||
return '[](' + linkUrl + ')';
|
||||
|
||||
case 'asciidoc':
|
||||
return 'image:' + imageUrl + '["Docker Repository on ' + Config.REGISTRY_TITLE_SHORT + '", link="' + linkUrl + '"]';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
$scope.askDelete = function() {
|
||||
$scope.deleteDialogCounter++;
|
||||
$scope.deleteRepoInfo = {
|
||||
'counter': $scope.deleteDialogCounter
|
||||
};
|
||||
};
|
||||
|
||||
$scope.deleteRepo = function(info, callback) {
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
||||
};
|
||||
|
||||
var errorHandler = ApiService.errorDisplay(
|
||||
'Could not delete ' + getTitle($scope.repository), callback);
|
||||
|
||||
ApiService.deleteRepository(null, params).then(function() {
|
||||
callback(true);
|
||||
setTimeout(function() {
|
||||
document.location = '/repository/';
|
||||
}, 100);
|
||||
}, errorHandler);
|
||||
};
|
||||
|
||||
$scope.askChangeAccess = function(newAccess) {
|
||||
var msg = 'Are you sure you want to make this ' + getTitle($scope.repository) + ' ' +
|
||||
newAccess + '?';
|
||||
|
||||
bootbox.confirm(msg, function(r) {
|
||||
if (!r) { return; }
|
||||
$scope.changeAccess(newAccess);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.changeAccess = function(newAccess) {
|
||||
var visibility = {
|
||||
'visibility': newAccess
|
||||
};
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
||||
};
|
||||
|
||||
ApiService.changeRepoVisibility(visibility, params).then(function() {
|
||||
$scope.repository.is_public = newAccess == 'public';
|
||||
}, ApiService.errorDisplay('Could not change visibility'));
|
||||
};
|
||||
|
||||
// Hide State, for now, if mirroring feature-flag is not enabled.
|
||||
if (Features.REPO_MIRROR) {
|
||||
$scope.repoStates = ['NORMAL', 'READ_ONLY'];
|
||||
}
|
||||
|
||||
// Only show MIRROR as an option if the feature-flag is enabled.
|
||||
if (Features.REPO_MIRROR) { $scope.repoStates.push('MIRROR'); }
|
||||
|
||||
// Hide State, for now, if mirroring feature-flag is not enabled.
|
||||
if (Features.REPO_MIRROR) {
|
||||
$scope.selectedState = $scope.repository.state;
|
||||
$scope.changeState = function() {
|
||||
let state = {'state': $scope.selectedState};
|
||||
let params = {'repository': $scope.repository.namespace + '/' + $scope.repository.name};
|
||||
ApiService.changeRepoState(state, params)
|
||||
.then(function() {
|
||||
$scope.repository.state = $scope.selectedState;
|
||||
$route.reload(); // State will eventually affect many UI elements. Reload the view.
|
||||
}, ApiService.errorDisplay('Could not change state'));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
499
static/js/directives/repo-view/repo-panel-tags.js
Normal file
499
static/js/directives/repo-view/repo-panel-tags.js
Normal file
|
@ -0,0 +1,499 @@
|
|||
/**
|
||||
* An element which displays the tags panel for a repository view.
|
||||
*/
|
||||
angular.module('quay').directive('repoPanelTags', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/repo-view/repo-panel-tags.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'repositoryTags': '=repositoryTags',
|
||||
'selectedTags': '=selectedTags',
|
||||
'historyFilter': '=historyFilter',
|
||||
'imagesResource': '=imagesResource',
|
||||
'imageLoader': '=imageLoader',
|
||||
|
||||
'isEnabled': '=isEnabled',
|
||||
|
||||
'getImages': '&getImages'
|
||||
},
|
||||
controller: function($scope, $element, $filter, $location, ApiService, UIService,
|
||||
VulnerabilityService, TableService, Features, StateService) {
|
||||
StateService.updateStateIn($scope, function(state) {
|
||||
$scope.inReadOnlyMode = state.inReadOnlyMode;
|
||||
});
|
||||
|
||||
$scope.Features = Features;
|
||||
|
||||
$scope.maxTrackCount = 5;
|
||||
|
||||
$scope.checkedTags = UIService.createCheckStateController([], 'name');
|
||||
$scope.checkedTags.setPage(0);
|
||||
|
||||
$scope.options = {
|
||||
'predicate': 'last_modified_datetime',
|
||||
'reverse': false,
|
||||
'page': 0
|
||||
};
|
||||
|
||||
$scope.iterationState = {};
|
||||
$scope.tagActionHandler = null;
|
||||
$scope.tagsPerPage = 25;
|
||||
|
||||
$scope.expandedView = false;
|
||||
$scope.labelCache = {};
|
||||
|
||||
$scope.manifestVulnerabilities = {};
|
||||
$scope.repoDelegationsInfo = null;
|
||||
|
||||
$scope.defcon1 = {};
|
||||
$scope.hasDefcon1 = false;
|
||||
|
||||
var loadRepoSignatures = function() {
|
||||
if (!$scope.repository || !$scope.repository.trust_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.repoSignatureError = false;
|
||||
$scope.repoDelegationsInfo = null;
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
||||
};
|
||||
|
||||
ApiService.getRepoSignatures(null, params).then(function(resp) {
|
||||
$scope.repoDelegationsInfo = resp;
|
||||
}, function() {
|
||||
$scope.repoDelegationsInfo = {'error': true};
|
||||
});
|
||||
};
|
||||
|
||||
var setTagState = function() {
|
||||
if (!$scope.repositoryTags || !$scope.selectedTags) { return; }
|
||||
|
||||
// Build a list of all the tags, with extending information.
|
||||
var allTags = [];
|
||||
for (var tag in $scope.repositoryTags) {
|
||||
if (!$scope.repositoryTags.hasOwnProperty(tag)) { continue; }
|
||||
|
||||
var tagData = $scope.repositoryTags[tag];
|
||||
var tagInfo = $.extend(tagData, {
|
||||
'name': tag,
|
||||
'last_modified_datetime': TableService.getReversedTimestamp(tagData.last_modified),
|
||||
'expiration_date': tagData.expiration ? TableService.getReversedTimestamp(tagData.expiration) : null,
|
||||
});
|
||||
|
||||
allTags.push(tagInfo);
|
||||
}
|
||||
|
||||
// Sort the tags by the predicate and the reverse, and map the information.
|
||||
var imageIDs = [];
|
||||
var ordered = TableService.buildOrderedItems(allTags, $scope.options,
|
||||
['name'], ['last_modified_datetime', 'size']).entries;
|
||||
|
||||
var checked = [];
|
||||
var imageMap = {};
|
||||
var imageIndexMap = {};
|
||||
for (var i = 0; i < ordered.length; ++i) {
|
||||
var tagInfo = ordered[i];
|
||||
if (!tagInfo.image_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!imageMap[tagInfo.image_id]) {
|
||||
imageMap[tagInfo.image_id] = [];
|
||||
imageIDs.push(tagInfo.image_id)
|
||||
}
|
||||
|
||||
imageMap[tagInfo.image_id].push(tagInfo);
|
||||
if ($.inArray(tagInfo.name, $scope.selectedTags) >= 0) {
|
||||
checked.push(tagInfo);
|
||||
}
|
||||
|
||||
if (!imageIndexMap[tagInfo.image_id]) {
|
||||
imageIndexMap[tagInfo.image_id] = {'start': i, 'end': i};
|
||||
}
|
||||
|
||||
imageIndexMap[tagInfo.image_id]['end'] = i;
|
||||
};
|
||||
|
||||
// Calculate the image tracks.
|
||||
var colors = d3.scale.category10();
|
||||
if (Object.keys(imageMap).length > 10) {
|
||||
colors = d3.scale.category20();
|
||||
}
|
||||
|
||||
var imageTracks = [];
|
||||
var imageTrackEntries = [];
|
||||
var trackEntryForImage = {};
|
||||
|
||||
var visibleStartIndex = ($scope.options.page * $scope.tagsPerPage);
|
||||
var visibleEndIndex = (($scope.options.page + 1) * $scope.tagsPerPage);
|
||||
|
||||
imageIDs.sort().map(function(image_id) {
|
||||
if (imageMap[image_id].length >= 2){
|
||||
// Create the track entry.
|
||||
var imageIndexRange = imageIndexMap[image_id];
|
||||
var colorIndex = imageTrackEntries.length;
|
||||
var trackEntry = {
|
||||
'image_id': image_id,
|
||||
'color': colors(colorIndex),
|
||||
'count': imageMap[image_id].length,
|
||||
'tags': imageMap[image_id],
|
||||
'index_range': imageIndexRange,
|
||||
'visible': visibleStartIndex <= imageIndexRange.end && imageIndexRange.start <= visibleEndIndex,
|
||||
};
|
||||
|
||||
trackEntryForImage[image_id] = trackEntry;
|
||||
imageMap[image_id]['color'] = colors(colorIndex);
|
||||
|
||||
// Find the track in which we can place the entry, if any.
|
||||
var existingTrack = null;
|
||||
for (var i = 0; i < imageTracks.length; ++i) {
|
||||
// For the current track, ensure that the start and end index
|
||||
// for the current entry is outside of the range of the track's
|
||||
// entries. If so, then we can add the entry to the track.
|
||||
var currentTrack = imageTracks[i];
|
||||
var canAddToCurrentTrack = true;
|
||||
for (var j = 0; j < currentTrack.entries.length; ++j) {
|
||||
var currentTrackEntry = currentTrack.entries[j];
|
||||
var entryInfo = imageIndexMap[currentTrackEntry.image_id];
|
||||
if (Math.max(entryInfo.start, imageIndexRange.start) <= Math.min(entryInfo.end, imageIndexRange.end)) {
|
||||
canAddToCurrentTrack = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (canAddToCurrentTrack) {
|
||||
existingTrack = currentTrack;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the entry to the track or create a new track if necessary.
|
||||
if (existingTrack) {
|
||||
existingTrack.entries.push(trackEntry)
|
||||
existingTrack.entryByImageId[image_id] = trackEntry;
|
||||
existingTrack.endIndex = Math.max(existingTrack.endIndex, imageIndexRange.end);
|
||||
|
||||
for (var j = imageIndexRange.start; j <= imageIndexRange.end; j++) {
|
||||
existingTrack.entryByIndex[j] = trackEntry;
|
||||
}
|
||||
} else {
|
||||
var entryByImageId = {};
|
||||
entryByImageId[image_id] = trackEntry;
|
||||
|
||||
var entryByIndex = {};
|
||||
for (var j = imageIndexRange.start; j <= imageIndexRange.end; j++) {
|
||||
entryByIndex[j] = trackEntry;
|
||||
}
|
||||
|
||||
imageTracks.push({
|
||||
'entries': [trackEntry],
|
||||
'entryByImageId': entryByImageId,
|
||||
'startIndex': imageIndexRange.start,
|
||||
'endIndex': imageIndexRange.end,
|
||||
'entryByIndex': entryByIndex,
|
||||
});
|
||||
}
|
||||
|
||||
imageTrackEntries.push(trackEntry);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.imageMap = imageMap;
|
||||
$scope.imageTracks = imageTracks;
|
||||
$scope.imageTrackEntries = imageTrackEntries;
|
||||
$scope.trackEntryForImage = trackEntryForImage;
|
||||
|
||||
$scope.options.page = 0;
|
||||
|
||||
$scope.tags = ordered;
|
||||
$scope.allTags = allTags;
|
||||
|
||||
$scope.checkedTags = UIService.createCheckStateController(ordered, 'name');
|
||||
$scope.checkedTags.setPage($scope.options.page, $scope.tagsPerPage);
|
||||
|
||||
$scope.checkedTags.listen(function(allChecked, pageChecked) {
|
||||
$scope.selectedTags = allChecked.map(function(tag_info) {
|
||||
return tag_info.name;
|
||||
});
|
||||
|
||||
$scope.fullPageSelected = ((pageChecked.length == $scope.tagsPerPage) &&
|
||||
(allChecked.length != $scope.tags.length));
|
||||
$scope.allTagsSelected = ((allChecked.length > $scope.tagsPerPage) &&
|
||||
(allChecked.length == $scope.tags.length));
|
||||
});
|
||||
|
||||
$scope.checkedTags.setChecked(checked);
|
||||
}
|
||||
|
||||
$scope.$watch('options.predicate', setTagState);
|
||||
$scope.$watch('options.reverse', setTagState);
|
||||
$scope.$watch('options.filter', setTagState);
|
||||
|
||||
$scope.$watch('options.page', function(page) {
|
||||
if (page != null && $scope.checkedTags) {
|
||||
$scope.checkedTags.setPage(page, $scope.tagsPerPage);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('selectedTags', function(selectedTags) {
|
||||
if (!selectedTags || !$scope.repository || !$scope.imageMap) { return; }
|
||||
|
||||
$scope.checkedTags.setChecked(selectedTags.map(function(tag) {
|
||||
return $scope.repositoryTags[tag];
|
||||
}));
|
||||
}, true);
|
||||
|
||||
$scope.$watch('repository', function(updatedRepoObject, previousRepoObject) {
|
||||
// Process each of the tags.
|
||||
setTagState();
|
||||
loadRepoSignatures();
|
||||
});
|
||||
|
||||
$scope.$watch('repositoryTags', function(newTags, oldTags) {
|
||||
if (newTags === oldTags) { return; }
|
||||
// Process each of the tags.
|
||||
setTagState();
|
||||
loadRepoSignatures();
|
||||
}, true);
|
||||
|
||||
$scope.clearSelectedTags = function() {
|
||||
$scope.checkedTags.setChecked([]);
|
||||
};
|
||||
|
||||
$scope.selectAllTags = function() {
|
||||
$scope.checkedTags.setChecked($scope.tags);
|
||||
};
|
||||
|
||||
$scope.constrastingColor = function(backgroundColor) {
|
||||
// From: https://stackoverflow.com/questions/11068240/what-is-the-most-efficient-way-to-parse-a-css-color-in-javascript
|
||||
function parseColor(input) {
|
||||
m = input.match(/^#([0-9a-f]{6})$/i)[1];
|
||||
return [
|
||||
parseInt(m.substr(0,2),16),
|
||||
parseInt(m.substr(2,2),16),
|
||||
parseInt(m.substr(4,2),16)
|
||||
];
|
||||
}
|
||||
|
||||
var rgb = parseColor(backgroundColor);
|
||||
|
||||
// From W3C standard.
|
||||
var o = Math.round(((parseInt(rgb[0]) * 299) + (parseInt(rgb[1]) * 587) + (parseInt(rgb[2]) * 114)) / 1000);
|
||||
return (o > 150) ? 'black' : 'white';
|
||||
};
|
||||
|
||||
$scope.getTrackEntryForIndex = function(it, index) {
|
||||
index += $scope.options.page * $scope.tagsPerPage;
|
||||
return it.entryByIndex[index];
|
||||
};
|
||||
|
||||
$scope.trackLineExpandedClass = function(it, index, track_info) {
|
||||
var entry = $scope.getTrackEntryForIndex(it, index);
|
||||
if (!entry) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var adjustedIndex = index + ($scope.options.page * $scope.tagsPerPage);
|
||||
|
||||
if (index < entry.index_range.start) {
|
||||
return 'before';
|
||||
}
|
||||
|
||||
if (index > entry.index_range.end) {
|
||||
return 'after';
|
||||
}
|
||||
|
||||
if (index >= entry.index_range.start && index < entry.index_range.end) {
|
||||
return 'middle';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
$scope.trackLineClass = function(it, index) {
|
||||
var entry = $scope.getTrackEntryForIndex(it, index);
|
||||
if (!entry) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var adjustedIndex = index + ($scope.options.page * $scope.tagsPerPage);
|
||||
|
||||
if (index == entry.index_range.start) {
|
||||
return 'start';
|
||||
}
|
||||
|
||||
if (index == entry.index_range.end) {
|
||||
return 'end';
|
||||
}
|
||||
|
||||
if (index > entry.index_range.start && index < entry.index_range.end) {
|
||||
return 'middle';
|
||||
}
|
||||
|
||||
if (index < entry.index_range.start) {
|
||||
return 'before';
|
||||
}
|
||||
|
||||
if (index > entry.index_range.end) {
|
||||
return 'after';
|
||||
}
|
||||
};
|
||||
|
||||
$scope.tablePredicateClass = function(name, predicate, reverse) {
|
||||
if (name != predicate) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'current ' + (reverse ? 'reversed' : '');
|
||||
};
|
||||
|
||||
$scope.askDeleteTag = function(tag) {
|
||||
$scope.tagActionHandler.askDeleteTag(tag);
|
||||
};
|
||||
|
||||
$scope.askDeleteMultipleTags = function(tags) {
|
||||
if (tags.length == 1) {
|
||||
$scope.askDeleteTag(tags[0].name);
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.tagActionHandler.askDeleteMultipleTags(tags);
|
||||
};
|
||||
|
||||
$scope.askChangeTagsExpiration = function(tags) {
|
||||
if ($scope.inReadOnlyMode) {
|
||||
return;
|
||||
}
|
||||
$scope.tagActionHandler.askChangeTagsExpiration(tags);
|
||||
};
|
||||
|
||||
$scope.askAddTag = function(tag) {
|
||||
if ($scope.inReadOnlyMode) {
|
||||
return;
|
||||
}
|
||||
$scope.tagActionHandler.askAddTag(tag.image_id, tag.manifest_digest);
|
||||
};
|
||||
|
||||
$scope.showLabelEditor = function(tag) {
|
||||
if ($scope.inReadOnlyMode) {
|
||||
return;
|
||||
}
|
||||
if (!tag.manifest_digest) { return; }
|
||||
$scope.tagActionHandler.showLabelEditor(tag.manifest_digest);
|
||||
};
|
||||
|
||||
$scope.orderBy = function(predicate) {
|
||||
if (predicate == $scope.options.predicate) {
|
||||
$scope.options.reverse = !$scope.options.reverse;
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.options.reverse = false;
|
||||
$scope.options.predicate = predicate;
|
||||
};
|
||||
|
||||
$scope.commitTagFilter = function(tag) {
|
||||
var r = new RegExp('^[0-9a-fA-F]{7}$');
|
||||
return tag.name.match(r);
|
||||
};
|
||||
|
||||
$scope.allTagFilter = function(tag) {
|
||||
return true;
|
||||
};
|
||||
|
||||
$scope.noTagFilter = function(tag) {
|
||||
return false;
|
||||
};
|
||||
|
||||
$scope.imageIDFilter = function(image_id, tag) {
|
||||
return tag.image_id == image_id;
|
||||
};
|
||||
|
||||
$scope.setTab = function(tab) {
|
||||
$location.search('tab', tab);
|
||||
};
|
||||
|
||||
$scope.selectTrack = function(it) {
|
||||
$scope.checkedTags.checkByFilter(function(tag) {
|
||||
return $scope.imageIDFilter(it.image_id, tag);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.showHistory = function(checked) {
|
||||
if (!checked.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.historyFilter = $scope.getTagNames(checked);
|
||||
$scope.setTab('history');
|
||||
};
|
||||
|
||||
$scope.setExpanded = function(expanded) {
|
||||
$scope.expandedView = expanded;
|
||||
};
|
||||
|
||||
$scope.getTagNames = function(checked) {
|
||||
var names = checked.map(function(tag) {
|
||||
return tag.name;
|
||||
});
|
||||
|
||||
return names.join(',');
|
||||
};
|
||||
|
||||
$scope.handleLabelsChanged = function(manifest_digest) {
|
||||
delete $scope.labelCache[manifest_digest];
|
||||
};
|
||||
|
||||
$scope.loadManifestList = function(tag) {
|
||||
if (tag.manifest_list_loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
tag.manifest_list_loading = true;
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'manifestref': tag.manifest_digest
|
||||
};
|
||||
|
||||
ApiService.getRepoManifest(null, params).then(function(resp) {
|
||||
tag.manifest_list = JSON.parse(resp['manifest_data']);
|
||||
tag.manifest_list_loading = false;
|
||||
}, ApiService.errorDisplay('Could not load manifest list contents'))
|
||||
};
|
||||
|
||||
$scope.manifestsOf = function(tag) {
|
||||
if (!tag.is_manifest_list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!tag.manifest_list) {
|
||||
$scope.loadManifestList(tag);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!tag._mapped_manifests) {
|
||||
// Calculate once and cache to avoid angular digest cycles.
|
||||
tag._mapped_manifests = tag.manifest_list.manifests.map(function(manifest) {
|
||||
return {
|
||||
'raw': manifest,
|
||||
'os': manifest.platform.os,
|
||||
'size': manifest.size,
|
||||
'digest': manifest.digest,
|
||||
'description': `${manifest.platform.os} on ${manifest.platform.architecture}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return tag._mapped_manifests;
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
import { QuayRequireDirective } from './quay-require.directive';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("QuayRequireDirective", () => {
|
||||
var directive: QuayRequireDirective;
|
||||
var featuresMock: Mock<any>;
|
||||
var $elementMock: Mock<ng.IAugmentedJQuery>;
|
||||
var $scopeMock: Mock<ng.IScope>;
|
||||
var $transcludeMock: Mock<ng.ITranscludeFunction>;
|
||||
var ngIfDirectiveMock: Mock<ng.IDirective>;
|
||||
|
||||
beforeEach(() => {
|
||||
featuresMock = new Mock<any>();
|
||||
$elementMock = new Mock<ng.IAugmentedJQuery>();
|
||||
$scopeMock = new Mock<ng.IScope>();
|
||||
$transcludeMock = new Mock<ng.ITranscludeFunction>();
|
||||
ngIfDirectiveMock = new Mock<ng.IDirective>();
|
||||
|
||||
directive = new QuayRequireDirective(featuresMock.Object,
|
||||
$elementMock.Object,
|
||||
$scopeMock.Object,
|
||||
$transcludeMock.Object,
|
||||
[ngIfDirectiveMock.Object]);
|
||||
directive.requiredFeatures = ['BILLING', 'SOME_OTHER_FEATURE'];
|
||||
});
|
||||
|
||||
describe("ngAfterContentInit", () => {
|
||||
var linkMock: Mock<ng.IDirectiveLinkFn>;
|
||||
|
||||
beforeEach(() => {
|
||||
linkMock = new Mock<ng.IDirectiveLinkFn>();
|
||||
linkMock.setup(mock => (<Function>mock).apply);
|
||||
ngIfDirectiveMock.setup(mock => mock.link).is(linkMock.Object);
|
||||
});
|
||||
|
||||
it("calls ngIfDirective link method with own element's arguments to achieve ngIf functionality", () => {
|
||||
featuresMock.setup(mock => mock.matchesFeatures).is((features) => false);
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>(<Function>linkMock.Object).apply).calls.argsFor(0)[0]).toEqual(ngIfDirectiveMock.Object);
|
||||
expect((<Spy>(<Function>linkMock.Object).apply).calls.argsFor(0)[1][0]).toEqual($elementMock.Object);
|
||||
expect((<Spy>(<Function>linkMock.Object).apply).calls.argsFor(0)[1][1]).toEqual($scopeMock.Object);
|
||||
expect(Object.keys((<Spy>(<Function>linkMock.Object).apply).calls.argsFor(0)[1][2])).toEqual(['ngIf']);
|
||||
expect((<Spy>(<Function>linkMock.Object).apply).calls.argsFor(0)[1][3]).toEqual(null);
|
||||
expect((<Spy>(<Function>linkMock.Object).apply).calls.argsFor(0)[1][4]).toEqual($transcludeMock.Object);
|
||||
});
|
||||
|
||||
it("calls feature service to check if given features are present in application", () => {
|
||||
featuresMock.setup(mock => mock.matchesFeatures).is((features) => false);
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>(<Function>linkMock.Object).apply).calls.argsFor(0)[1][2]['ngIf']()).toBe(false);
|
||||
expect(featuresMock.Object.matchesFeatures).toHaveBeenCalled();
|
||||
expect((<Spy>featuresMock.Object.matchesFeatures).calls.argsFor(0)[0]).toEqual(directive.requiredFeatures);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
import { Directive, Inject, Input, AfterContentInit } from 'ng-metadata/core';
|
||||
|
||||
|
||||
/**
|
||||
* Structural directive that adds/removes its host element if the given list of feature flags are set.
|
||||
* Utilizes the existing AngularJS ngIf directive by applying its 'link' function to the host element's properties.
|
||||
*
|
||||
* Inspired by http://stackoverflow.com/a/29010910
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[quayRequire]',
|
||||
legacy: {
|
||||
transclude: 'element',
|
||||
}
|
||||
})
|
||||
export class QuayRequireDirective implements AfterContentInit {
|
||||
|
||||
@Input('<quayRequire') public requiredFeatures: string[] = [];
|
||||
|
||||
private ngIfDirective: ng.IDirective;
|
||||
|
||||
constructor(@Inject('Features') private features: any,
|
||||
@Inject('$element') private $element: ng.IAugmentedJQuery,
|
||||
@Inject('$scope') private $scope: ng.IScope,
|
||||
@Inject('$transclude') private $transclude: ng.ITranscludeFunction,
|
||||
@Inject('ngIfDirective') ngIfDirective: ng.IDirective[]) {
|
||||
this.ngIfDirective = ngIfDirective[0];
|
||||
}
|
||||
|
||||
public ngAfterContentInit(): void {
|
||||
const attrs: {[name: string]: () => boolean} = {'ngIf': () => this.features.matchesFeatures(this.requiredFeatures)};
|
||||
|
||||
(<Function>this.ngIfDirective.link).apply(this.ngIfDirective,
|
||||
[
|
||||
this.$scope,
|
||||
this.$element,
|
||||
attrs,
|
||||
null,
|
||||
this.$transclude
|
||||
]);
|
||||
}
|
||||
}
|
20
static/js/directives/ui/anchor.js
Normal file
20
static/js/directives/ui/anchor.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* An element which displays its contents wrapped in an <a> tag, but only if the href is not null.
|
||||
*/
|
||||
angular.module('quay').directive('anchor', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/anchor.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'href': '@href',
|
||||
'target': '@target',
|
||||
'isOnlyText': '=isOnlyText'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -0,0 +1,138 @@
|
|||
<div class="app-public-view-element">
|
||||
<div class="co-main-content-panel">
|
||||
<div class="app-row">
|
||||
<!-- Main panel -->
|
||||
<div class="col-md-9 col-sm-12 main-content">
|
||||
<!-- App Header -->
|
||||
<div class="app-header">
|
||||
<a href="https://coreos.com/blog/quay-application-registry-for-kubernetes.html" class="hidden-xs hidden-sm" style="float: right; padding: 6px;" ng-safenewtab><i class="fa fa-info-circle" style="margin-right: 6px;"></i>Learn more about applications</a>
|
||||
<h3><i class="fa ci-appcube"></i>{{ $ctrl.repository.namespace }}/{{ $ctrl.repository.name }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<cor-tab-panel cor-nav-tabs>
|
||||
<cor-tabs>
|
||||
<cor-tab tab-title="Description" tab-id="description">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
</cor-tab>
|
||||
<cor-tab tab-title="Channels" tab-id="channels">
|
||||
<i class="fa fa-tags"></i>
|
||||
</cor-tab>
|
||||
<cor-tab tab-title="Releases" tab-id="releases">
|
||||
<i class="fa ci-package"></i>
|
||||
</cor-tab>
|
||||
<cor-tab tab-title="Usage Logs" tab-id="logs" tab-init="$ctrl.showLogs()" ng-if="$ctrl.repository.can_admin">
|
||||
<i class="fa fa-bar-chart"></i>
|
||||
</cor-tab>
|
||||
<cor-tab tab-title="Settings" tab-id="settings" tab-init="$ctrl.showSettings()" ng-if="$ctrl.repository.can_admin">
|
||||
<i class="fa fa-gear"></i>
|
||||
</cor-tab>
|
||||
</cor-tabs>
|
||||
|
||||
<cor-tab-content>
|
||||
<!-- Description -->
|
||||
<cor-tab-pane id="description">
|
||||
<div class="description">
|
||||
<markdown-input content="$ctrl.repository.description"
|
||||
can-write="$ctrl.repository.can_write"
|
||||
(content-changed)="$ctrl.updateDescription($event.content)"
|
||||
field-title="repository description"></markdown-input>
|
||||
</div>
|
||||
</cor-tab-pane>
|
||||
|
||||
<!-- Channels -->
|
||||
<cor-tab-pane id="channels">
|
||||
<div ng-show="!$ctrl.repository.channels.length && $ctrl.repository.can_write">
|
||||
<h3>No channels found for this application</h3>
|
||||
<br>
|
||||
<p class="hidden-xs">
|
||||
To push a new channel (from within the Helm package directory and with the <a href="https://github.com/app-registry/appr-helm-plugin" ng-safenewtab>Helm registry plugin</a> installed):
|
||||
<pre class="command hidden-xs">helm registry push --namespace {{ $ctrl.repository.namespace }} --channel {channelName} {{ $ctrl.Config.SERVER_HOSTNAME }}</pre>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ng-show="$ctrl.repository.channels.length || !$ctrl.repository.can_write">
|
||||
<cor-table table-data="$ctrl.repository.channels" table-item-title="channels" filter-fields="['name']">
|
||||
<cor-table-col datafield="name" sortfield="name" title="Name"
|
||||
templateurl="/static/js/directives/ui/app-public-view/channel-name.html"></cor-table-col>
|
||||
<cor-table-col datafield="release" sortfield="release" title="Current Release"></cor-table-col>
|
||||
<cor-table-col datafield="last_modified" sortfield="last_modified" title="Last Modified"
|
||||
selected="true" kindof="datetime"
|
||||
templateurl="/static/js/directives/ui/app-public-view/last-modified.html"></cor-table-col>
|
||||
</cor-table>
|
||||
</div>
|
||||
</cor-tab-pane>
|
||||
|
||||
<!-- Releases -->
|
||||
<cor-tab-pane id="releases">
|
||||
<div ng-show="!$ctrl.repository.releases.length && $ctrl.repository.can_write">
|
||||
<h3>No releases found for this application</h3>
|
||||
<br>
|
||||
<p class="hidden-xs">
|
||||
To push a new release (from within the Helm package directory and with the <a href="https://coreos.com/apps" ng-safenewtab>Helm registry plugin</a> installed):
|
||||
<pre class="command hidden-xs">helm registry push --namespace {{ $ctrl.repository.namespace }} {{ $ctrl.Config.SERVER_HOSTNAME }}</pre>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ng-show="$ctrl.repository.releases.length || !$ctrl.repository.can_write">
|
||||
<cor-table table-data="$ctrl.repository.releases"
|
||||
table-item-title="releases"
|
||||
filter-fields="['name']"
|
||||
can-expand="true">
|
||||
<cor-table-col datafield="name" sortfield="name" title="Name"></cor-table-col>
|
||||
<cor-table-col datafield="last_modified" sortfield="last_modified"
|
||||
title="Created"
|
||||
selected="true" kindof="datetime"
|
||||
templateurl="/static/js/directives/ui/app-public-view/last-modified.html"></cor-table-col>
|
||||
<cor-table-col datafield="channels" title="Channels" item-limit="6"
|
||||
templateurl="/static/js/directives/ui/app-public-view/channels-list.html"></cor-table-col>
|
||||
</cor-table>
|
||||
</div>
|
||||
</cor-tab-pane>
|
||||
|
||||
<!-- Usage Logs-->
|
||||
<cor-tab-pane id="logs" ng-if="$ctrl.repository.can_admin">
|
||||
<div class="logs-view" repository="$ctrl.repository" makevisible="$ctrl.logsShown"></div>
|
||||
</cor-tab-pane>
|
||||
|
||||
<!-- Settings -->
|
||||
<cor-tab-pane id="settings" ng-if="$ctrl.repository.can_admin">
|
||||
<div class="repo-panel-settings" repository="$ctrl.repository" is-enabled="$ctrl.settingsShown"></div>
|
||||
</cor-tab-pane>
|
||||
</cor-tab-content>
|
||||
</cor-tab-panel>
|
||||
</div>
|
||||
|
||||
<!-- Side bar -->
|
||||
<div class="col-md-3 hidden-xs hidden-sm side-bar">
|
||||
<div>
|
||||
<visibility-indicator repository="$ctrl.repository"></visibility-indicator>
|
||||
</div>
|
||||
<div ng-if="$ctrl.repository.is_public">{{ $ctrl.repository.namespace }} is sharing this application publicly</div>
|
||||
<div ng-if="!$ctrl.repository.is_public">This application is private and only visible to those with permission</div>
|
||||
|
||||
<div class="sidebar-table" ng-if="$ctrl.repository.channels.length">
|
||||
<h4>Latest Channels</h4>
|
||||
<cor-table table-data="$ctrl.repository.channels" table-item-title="channels" filter-fields="['name']" compact="true" max-display-count="3">
|
||||
<cor-table-col datafield="name" sortfield="name" title="Name"
|
||||
templateurl="/static/js/directives/ui/app-public-view/channel-name.html"></cor-table-col>
|
||||
<cor-table-col style="word-wrap: break-word;"
|
||||
datafield="release" sortfield="release" title="Current Release"></cor-table-col>
|
||||
</cor-table>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-table" ng-if="$ctrl.repository.releases.length">
|
||||
<h4>Latest Releases</h4>
|
||||
<cor-table table-data="$ctrl.repository.releases" table-item-title="releases" filter-fields="['name']" compact="true" max-display-count="3">
|
||||
<cor-table-col style="word-wrap: break-word;"
|
||||
datafield="name" sortfield="name" title="Name"></cor-table-col>
|
||||
<cor-table-col datafield="last_modified" sortfield="last_modified"
|
||||
title="Created"
|
||||
selected="true" kindof="datetime"
|
||||
templateurl="/static/js/directives/ui/app-public-view/last-modified.html"></cor-table-col>
|
||||
</cor-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,34 @@
|
|||
import { Input, Component, Inject } from 'ng-metadata/core';
|
||||
|
||||
|
||||
/**
|
||||
* A component that displays the public information associated with an application repository.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-public-view',
|
||||
templateUrl: '/static/js/directives/ui/app-public-view/app-public-view.component.html'
|
||||
})
|
||||
export class AppPublicViewComponent {
|
||||
|
||||
@Input('<') public repository: any;
|
||||
|
||||
private settingsShown: number = 0;
|
||||
private logsShown: number = 0;
|
||||
|
||||
constructor(@Inject('Config') private Config: any) {
|
||||
this.updateDescription = this.updateDescription.bind(this);
|
||||
}
|
||||
|
||||
public showSettings(): void {
|
||||
this.settingsShown++;
|
||||
}
|
||||
|
||||
public showLogs(): void {
|
||||
this.logsShown++;
|
||||
}
|
||||
|
||||
private updateDescription(content: string) {
|
||||
this.repository.description = content;
|
||||
this.repository.put();
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<channel-icon name="item.name"></channel-icon><span style="vertical-align: middle; margin-left: 6px;">{{ item.name }}</span>
|
19
static/js/directives/ui/app-public-view/channels-list.html
Normal file
19
static/js/directives/ui/app-public-view/channels-list.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
<div style="display: flex; align-items: center;">
|
||||
<div style="display: flex; flex-wrap: wrap; width: 70%;">
|
||||
<!-- TODO: Move repeat logic to separate component -->
|
||||
<span ng-if="item.channels.length > 0"
|
||||
ng-repeat="channel_name in item.channels | limitTo : ($ctrl.rows[rowIndex].expanded ? item.channels.length : col.itemLimit)"
|
||||
ng-style="{
|
||||
'width': (100 / col.itemLimit) + '%',
|
||||
'margin-bottom': $ctrl.rows[rowIndex].expanded && $index < (item.channels.length - col.itemLimit) ? '5px' : ''
|
||||
}">
|
||||
<channel-icon name="channel_name"></channel-icon>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a ng-if="item.channels.length > col.itemLimit"
|
||||
ng-click="$ctrl.rows[rowIndex].expanded = !$ctrl.rows[rowIndex].expanded">
|
||||
{{ $ctrl.rows[rowIndex].expanded ? 'show less...' : item.channels.length - col.itemLimit + ' more...' }}
|
||||
</a>
|
||||
<span ng-if="!item.channels.length" class="empty">(None)</span>
|
||||
</div>
|
|
@ -0,0 +1 @@
|
|||
<time-ago datetime="item.last_modified"></time-ago>
|
|
@ -0,0 +1,33 @@
|
|||
<div class="resource-view" resource="$ctrl.appTokensResource">
|
||||
<div style="float: right; margin-left: 10px;" ng-show="!$ctrl.inReadOnlyMode">
|
||||
<button class="btn btn-primary" ng-click="$ctrl.askCreateToken()">Create Application Token</button>
|
||||
</div>
|
||||
<cor-table table-data="$ctrl.appTokens" table-item-title="tokens" filter-fields="['title']">
|
||||
<cor-table-col datafield="title" sortfield="title" title="Title" selected="true"
|
||||
bind-model="$ctrl"
|
||||
templateurl="/static/js/directives/ui/app-specific-token-manager/token-title.html"></cor-table-col>
|
||||
<cor-table-col datafield="last_accessed" sortfield="last_accessed" title="Last Accessed"
|
||||
kindof="datetime" templateurl="/static/js/directives/ui/app-specific-token-manager/last-accessed.html"></cor-table-col>
|
||||
<cor-table-col datafield="expiration" sortfield="expiration" title="Expiration"
|
||||
kindof="datetime" templateurl="/static/js/directives/ui/app-specific-token-manager/expiration.html"></cor-table-col>
|
||||
<cor-table-col datafield="created" sortfield="created" title="Created"
|
||||
kindof="datetime" templateurl="/static/js/directives/ui/app-specific-token-manager/created.html"></cor-table-col>
|
||||
<cor-table-col templateurl="/static/js/directives/ui/app-specific-token-manager/cog.html"
|
||||
bind-model="$ctrl" class="options-col" ng-if="!$ctrl.inReadOnlyMode"></cor-table-col>
|
||||
</cor-table>
|
||||
|
||||
<div class="credentials-dialog" credentials="$ctrl.tokenCredentials" secret-title="Application Token" entity-title="application token" entity-icon="fa-key"></div>
|
||||
|
||||
<!-- Revoke token confirm -->
|
||||
<div class="cor-confirm-dialog"
|
||||
dialog-context="$ctrl.revokeTokenInfo"
|
||||
dialog-action="$ctrl.revokeToken(info.token, callback)"
|
||||
dialog-title="Revoke Application Token"
|
||||
dialog-action-title="Revoke Token">
|
||||
<div class="co-alert co-alert-warning" style="margin-bottom: 10px;">
|
||||
Application token "{{ $ctrl.revokeTokenInfo.token.title }}" will be revoked and <strong>all</strong> applications and CLIs making use of the token will no longer operate.
|
||||
</div>
|
||||
|
||||
Proceed with revocation of this token?
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,78 @@
|
|||
import { Input, Component, Inject } from 'ng-metadata/core';
|
||||
import * as bootbox from "bootbox";
|
||||
|
||||
/**
|
||||
* A component that displays and manage all app specific tokens for a user.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-specific-token-manager',
|
||||
templateUrl: '/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.html',
|
||||
})
|
||||
export class AppSpecificTokenManagerComponent {
|
||||
private appTokensResource: any;
|
||||
private appTokens: Array<any>;
|
||||
private tokenCredentials: any;
|
||||
private revokeTokenInfo: any;
|
||||
private inReadOnlyMode: boolean;
|
||||
|
||||
constructor(@Inject('ApiService') private ApiService: any, @Inject('UserService') private UserService: any,
|
||||
@Inject('NotificationService') private NotificationService: any,
|
||||
@Inject('StateService') private StateService: any) {
|
||||
this.loadTokens();
|
||||
this.inReadOnlyMode = StateService.inReadOnlyMode();
|
||||
}
|
||||
|
||||
private loadTokens() {
|
||||
this.appTokensResource = this.ApiService.listAppTokensAsResource().get((resp) => {
|
||||
this.appTokens = resp['tokens'];
|
||||
});
|
||||
}
|
||||
|
||||
private askCreateToken() {
|
||||
bootbox.prompt('Please enter a descriptive title for the new application token', (title) => {
|
||||
if (!title) { return; }
|
||||
|
||||
const errorHandler = this.ApiService.errorDisplay('Could not create the application token');
|
||||
this.ApiService.createAppToken({title}).then((resp) => {
|
||||
this.loadTokens();
|
||||
}, errorHandler);
|
||||
});
|
||||
}
|
||||
|
||||
private showRevokeToken(token) {
|
||||
this.revokeTokenInfo = {
|
||||
'token': token,
|
||||
};
|
||||
};
|
||||
|
||||
private revokeToken(token, callback) {
|
||||
const errorHandler = this.ApiService.errorDisplay('Could not revoke application token', callback);
|
||||
const params = {
|
||||
'token_uuid': token['uuid'],
|
||||
};
|
||||
|
||||
this.ApiService.revokeAppToken(null, params).then((resp) => {
|
||||
this.loadTokens();
|
||||
|
||||
// Update the notification service so it hides any banners if we revoked an expiring token.
|
||||
this.NotificationService.update();
|
||||
callback(true);
|
||||
}, errorHandler);
|
||||
}
|
||||
|
||||
private showToken(token) {
|
||||
const errorHandler = this.ApiService.errorDisplay('Could not find application token');
|
||||
const params = {
|
||||
'token_uuid': token['uuid'],
|
||||
};
|
||||
|
||||
this.ApiService.getAppToken(null, params).then((resp) => {
|
||||
this.tokenCredentials = {
|
||||
'title': resp['token']['title'],
|
||||
'namespace': this.UserService.currentUser().username,
|
||||
'username': '$app',
|
||||
'password': resp['token']['token_code'],
|
||||
};
|
||||
}, errorHandler);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
<span class="cor-options-menu">
|
||||
<span class="cor-option" option-click="col.bindModel.showRevokeToken(item)">
|
||||
<i class="fa fa-times"></i> Revoke Token
|
||||
</span>
|
||||
</span>
|
|
@ -0,0 +1 @@
|
|||
<time-ago datetime="item.created"></time-ago>
|
|
@ -0,0 +1 @@
|
|||
<expiration-status-view expiration-date="item.expiration"></expiration-status-view>
|
|
@ -0,0 +1 @@
|
|||
<time-ago datetime="item.last_accessed"></time-ago>
|
|
@ -0,0 +1 @@
|
|||
<a ng-click="col.bindModel.showToken(item)">{{ item.title }}</a>
|
18
static/js/directives/ui/application-info.js
Normal file
18
static/js/directives/ui/application-info.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* An element which shows information about a registered OAuth application.
|
||||
*/
|
||||
angular.module('quay').directive('applicationInfo', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/application-info.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'application': '=application'
|
||||
},
|
||||
controller: function($scope, $element, ApiService) {}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
66
static/js/directives/ui/application-manager.js
Normal file
66
static/js/directives/ui/application-manager.js
Normal file
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Element for managing the applications of an organization.
|
||||
*/
|
||||
angular.module('quay').directive('applicationManager', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/application-manager.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'organization': '=organization',
|
||||
'makevisible': '=makevisible'
|
||||
},
|
||||
controller: function($scope, $element, ApiService) {
|
||||
$scope.loading = false;
|
||||
$scope.applications = [];
|
||||
$scope.feedback = null;
|
||||
|
||||
$scope.createApplication = function(appName) {
|
||||
if (!appName) { return; }
|
||||
|
||||
var params = {
|
||||
'orgname': $scope.organization.name
|
||||
};
|
||||
|
||||
var data = {
|
||||
'name': appName
|
||||
};
|
||||
|
||||
ApiService.createOrganizationApplication(data, params).then(function(resp) {
|
||||
$scope.applications.push(resp);
|
||||
|
||||
$scope.feedback = {
|
||||
'kind': 'success',
|
||||
'message': 'Application {application_name} created',
|
||||
'data': {
|
||||
'application_name': appName
|
||||
}
|
||||
};
|
||||
|
||||
}, ApiService.errorDisplay('Cannot create application'));
|
||||
};
|
||||
|
||||
var update = function() {
|
||||
if (!$scope.organization || !$scope.makevisible) { return; }
|
||||
if ($scope.loading) { return; }
|
||||
|
||||
$scope.loading = true;
|
||||
|
||||
var params = {
|
||||
'orgname': $scope.organization.name
|
||||
};
|
||||
|
||||
ApiService.getOrganizationApplications(null, params).then(function(resp) {
|
||||
$scope.loading = false;
|
||||
$scope.applications = resp['applications'] || [];
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch('organization', update);
|
||||
$scope.$watch('makevisible', update);
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
36
static/js/directives/ui/application-reference.js
Normal file
36
static/js/directives/ui/application-reference.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* An element which shows information about an OAuth application and provides a clickable link
|
||||
* for displaying a dialog with further information. Unlike application-info, this element is
|
||||
* intended for the *owner* of the application (since it requires the client ID).
|
||||
*/
|
||||
angular.module('quay').directive('applicationReference', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/application-reference.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'title': '=title',
|
||||
'clientId': '=clientId'
|
||||
},
|
||||
controller: function($scope, $element, ApiService, $modal) {
|
||||
$scope.showAppDetails = function() {
|
||||
var params = {
|
||||
'client_id': $scope.clientId
|
||||
};
|
||||
|
||||
ApiService.getApplicationInformation(null, params).then(function(resp) {
|
||||
$scope.applicationInfo = resp;
|
||||
$modal({
|
||||
title: 'Application Information',
|
||||
scope: $scope,
|
||||
template: '/static/directives/application-reference-dialog.html',
|
||||
show: true
|
||||
});
|
||||
}, ApiService.errorDisplay('Application could not be found'));
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
41
static/js/directives/ui/authorized-apps-manager.js
Normal file
41
static/js/directives/ui/authorized-apps-manager.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Element for managing the applications authorized by a user.
|
||||
*/
|
||||
angular.module('quay').directive('authorizedAppsManager', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/authorized-apps-manager.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'user': '=user',
|
||||
'isEnabled': '=isEnabled'
|
||||
},
|
||||
controller: function($scope, $element, ApiService) {
|
||||
$scope.$watch('isEnabled', function(enabled) {
|
||||
if (!enabled) { return; }
|
||||
loadAuthedApps();
|
||||
});
|
||||
|
||||
var loadAuthedApps = function() {
|
||||
if ($scope.authorizedAppsResource) { return; }
|
||||
|
||||
$scope.authorizedAppsResource = ApiService.listUserAuthorizationsAsResource().get(function(resp) {
|
||||
$scope.authorizedApps = resp['authorizations'];
|
||||
});
|
||||
};
|
||||
|
||||
$scope.deleteAccess = function(accessTokenInfo) {
|
||||
var params = {
|
||||
'access_token_uuid': accessTokenInfo['uuid']
|
||||
};
|
||||
|
||||
ApiService.deleteUserAuthorization(null, params).then(function(resp) {
|
||||
$scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1);
|
||||
}, ApiService.errorDisplay('Could not revoke authorization'));
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
65
static/js/directives/ui/avatar.js
Normal file
65
static/js/directives/ui/avatar.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* An element which displays an avatar for the given avatar data.
|
||||
*/
|
||||
angular.module('quay').directive('avatar', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/avatar.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'data': '=data',
|
||||
'size': '=size'
|
||||
},
|
||||
controller: function($scope, $element, AvatarService, Config, UIService, $timeout) {
|
||||
$scope.AvatarService = AvatarService;
|
||||
$scope.Config = Config;
|
||||
$scope.isLoading = true;
|
||||
$scope.showGravatar = false;
|
||||
$scope.loadGravatar = false;
|
||||
|
||||
$scope.imageCallback = function(result) {
|
||||
$scope.isLoading = false;
|
||||
|
||||
if (!result) {
|
||||
$scope.showGravatar = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine whether the gravatar is blank.
|
||||
var canvas = document.createElement("canvas");
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.drawImage($element.find('img')[0], 0, 0);
|
||||
|
||||
var blank = document.createElement("canvas");
|
||||
blank.width = 512;
|
||||
blank.height = 512;
|
||||
|
||||
var isBlank = canvas.toDataURL('text/png') == blank.toDataURL('text/png');
|
||||
$scope.showGravatar = !isBlank;
|
||||
};
|
||||
|
||||
$scope.$watch('size', function(size) {
|
||||
size = size * 1 || 16;
|
||||
$scope.fontSize = (size - 4) + 'px';
|
||||
$scope.lineHeight = size + 'px';
|
||||
$scope.imageSize = size;
|
||||
});
|
||||
|
||||
$scope.$watch('data', function(data) {
|
||||
if (!data) { return; }
|
||||
|
||||
$scope.loadGravatar = Config.AVATAR_KIND == 'gravatar' &&
|
||||
(data.kind == 'user' || data.kind == 'org');
|
||||
|
||||
$scope.isLoading = $scope.loadGravatar;
|
||||
$scope.hasGravatar = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
97
static/js/directives/ui/billing-invoices.js
Normal file
97
static/js/directives/ui/billing-invoices.js
Normal file
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Element for displaying the list of billing invoices for the user or organization.
|
||||
*/
|
||||
angular.module('quay').directive('billingInvoices', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/billing-invoices.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'organization': '=organization',
|
||||
'user': '=user',
|
||||
'makevisible': '=makevisible'
|
||||
},
|
||||
controller: function($scope, $element, $sce, ApiService) {
|
||||
$scope.loading = false;
|
||||
$scope.showCreateField = null;
|
||||
$scope.invoiceFields = [];
|
||||
|
||||
var update = function() {
|
||||
var hasValidUser = !!$scope.user;
|
||||
var hasValidOrg = !!$scope.organization;
|
||||
var isValid = hasValidUser || hasValidOrg;
|
||||
|
||||
if (!$scope.makevisible || !isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.loading = true;
|
||||
|
||||
ApiService.listInvoices($scope.organization).then(function(resp) {
|
||||
$scope.invoices = resp.invoices;
|
||||
$scope.loading = false;
|
||||
}, function() {
|
||||
$scope.invoices = [];
|
||||
$scope.loading = false;
|
||||
});
|
||||
|
||||
ApiService.listInvoiceFields($scope.organization).then(function(resp) {
|
||||
$scope.invoiceFields = resp.fields || [];
|
||||
}, function() {
|
||||
$scope.invoiceFields = [];
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch('organization', update);
|
||||
$scope.$watch('user', update);
|
||||
$scope.$watch('makevisible', update);
|
||||
|
||||
$scope.showCreateField = function() {
|
||||
$scope.createFieldInfo = {
|
||||
'title': '',
|
||||
'value': ''
|
||||
};
|
||||
};
|
||||
|
||||
$scope.askDeleteField = function(field) {
|
||||
bootbox.confirm('Are you sure you want to delete field ' + field.title + '?', function(r) {
|
||||
if (r) {
|
||||
var params = {
|
||||
'field_uuid': field.uuid
|
||||
};
|
||||
|
||||
ApiService.deleteInvoiceField($scope.organization, null, params).then(function(resp) {
|
||||
$scope.invoiceFields = $.grep($scope.invoiceFields, function(current) {
|
||||
return current.uuid != field.uuid
|
||||
});
|
||||
|
||||
}, ApiService.errorDisplay('Could not delete custom field'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.createCustomField = function(title, value, callback) {
|
||||
var data = {
|
||||
'title': title,
|
||||
'value': value
|
||||
};
|
||||
|
||||
if (!title || !value) {
|
||||
callback(false);
|
||||
bootbox.alert('Missing title or value');
|
||||
return;
|
||||
}
|
||||
|
||||
ApiService.createInvoiceField($scope.organization, data).then(function(resp) {
|
||||
$scope.invoiceFields.push(resp);
|
||||
callback(true);
|
||||
}, ApiService.errorDisplay('Could not create custom field'));
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
144
static/js/directives/ui/billing-management-panel.js
Normal file
144
static/js/directives/ui/billing-management-panel.js
Normal file
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* An element which displays the billing options for a user or an organization.
|
||||
*/
|
||||
angular.module('quay').directive('billingManagementPanel', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/billing-management-panel.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'user': '=user',
|
||||
'organization': '=organization',
|
||||
'isEnabled': '=isEnabled',
|
||||
'subscriptionStatus': '=subscriptionStatus'
|
||||
},
|
||||
controller: function($scope, $element, PlanService, ApiService, Features) {
|
||||
$scope.currentCard = null;
|
||||
$scope.subscription = null;
|
||||
$scope.updating = true;
|
||||
$scope.changeReceiptsInfo = null;
|
||||
$scope.context = {};
|
||||
$scope.subscriptionStatus = 'loading';
|
||||
|
||||
var setSubscription = function(sub) {
|
||||
$scope.subscription = sub;
|
||||
|
||||
// Load the plan info.
|
||||
PlanService.getPlanIncludingDeprecated(sub['plan'], function(plan) {
|
||||
$scope.currentPlan = plan;
|
||||
|
||||
if (!sub.hasSubscription) {
|
||||
$scope.updating = false;
|
||||
$scope.subscriptionStatus = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Load credit card information.
|
||||
PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) {
|
||||
$scope.currentCard = card;
|
||||
$scope.subscriptionStatus = 'valid';
|
||||
$scope.updating = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var update = function() {
|
||||
if (!$scope.isEnabled || !($scope.user || $scope.organization) || !Features.BILLING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.entity = $scope.user ? $scope.user : $scope.organization;
|
||||
$scope.invoice_email = $scope.entity.invoice_email;
|
||||
$scope.invoice_email_address = $scope.entity.invoice_email_address || $scope.entity.email;
|
||||
|
||||
$scope.updating = true;
|
||||
|
||||
// Load plan information.
|
||||
PlanService.getSubscription($scope.organization, setSubscription, function() {
|
||||
setSubscription({ 'plan': PlanService.getFreePlan() });
|
||||
});
|
||||
};
|
||||
|
||||
// Listen to plan changes.
|
||||
PlanService.registerListener(this, function(plan) {
|
||||
if (plan && plan.price > 0) {
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', function() {
|
||||
PlanService.unregisterListener(this);
|
||||
});
|
||||
|
||||
$scope.$watch('isEnabled', update);
|
||||
$scope.$watch('organization', update);
|
||||
$scope.$watch('user', update);
|
||||
|
||||
$scope.getEntityPrefix = function() {
|
||||
if ($scope.organization) {
|
||||
return '/organization/' + $scope.organization.name;
|
||||
} else {
|
||||
return '/user/' + $scope.user.username;
|
||||
}
|
||||
};
|
||||
|
||||
$scope.changeCreditCard = function() {
|
||||
var callbacks = {
|
||||
'opened': function() { },
|
||||
'closed': function() { },
|
||||
'started': function() { },
|
||||
'success': function(resp) {
|
||||
$scope.currentCard = resp.card;
|
||||
update();
|
||||
},
|
||||
'failure': function(resp) {
|
||||
if (!PlanService.isCardError(resp)) {
|
||||
bootbox.alert('Could not change credit card. Please try again later.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
PlanService.changeCreditCard($scope, $scope.organization ? $scope.organization.name : null, callbacks);
|
||||
};
|
||||
|
||||
$scope.getCreditImage = function(creditInfo) {
|
||||
if (!creditInfo || !creditInfo.type) { return 'credit.png'; }
|
||||
|
||||
var kind = creditInfo.type.toLowerCase() || 'credit';
|
||||
var supported = {
|
||||
'american express': 'amex',
|
||||
'credit': 'credit',
|
||||
'diners club': 'diners',
|
||||
'discover': 'discover',
|
||||
'jcb': 'jcb',
|
||||
'mastercard': 'mastercard',
|
||||
'visa': 'visa'
|
||||
};
|
||||
|
||||
kind = supported[kind] || 'credit';
|
||||
return kind + '.png';
|
||||
};
|
||||
|
||||
$scope.changeReceipts = function(info, callback) {
|
||||
$scope.entity['invoice_email'] = info['sendOption'] || false;
|
||||
$scope.entity['invoice_email_address'] = info['address'] || $scope.entity.email;
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Could not change billing options', callback);
|
||||
ApiService.changeDetails($scope.organization, $scope.entity).then(function(resp) {
|
||||
callback(true);
|
||||
update();
|
||||
}, errorHandler);
|
||||
};
|
||||
|
||||
$scope.showChangeReceipts = function() {
|
||||
$scope.changeReceiptsInfo = {
|
||||
'sendOption': $scope.invoice_email,
|
||||
'address': $scope.invoice_email_address
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
20
static/js/directives/ui/build-info-bar.js
Normal file
20
static/js/directives/ui/build-info-bar.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* An element which displays the status of a build in a nice compact bar.
|
||||
*/
|
||||
angular.module('quay').directive('buildInfoBar', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-info-bar.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'build': '=build',
|
||||
'showTime': '=showTime',
|
||||
'hideId': '=hideId'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
49
static/js/directives/ui/build-log-command.js
Normal file
49
static/js/directives/ui/build-log-command.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* An element which displays a command in a build.
|
||||
*/
|
||||
angular.module('quay').directive('buildLogCommand', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-log-command.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'command': '<command'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
$scope.getWithoutStep = function(fullTitle) {
|
||||
var colon = fullTitle.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $.trim(fullTitle.substring(colon + 1));
|
||||
};
|
||||
|
||||
$scope.isSecondaryFrom = function(fullTitle) {
|
||||
if (!fullTitle) { return false; }
|
||||
|
||||
var command = $scope.getWithoutStep(fullTitle);
|
||||
return command.indexOf('FROM ') == 0 && fullTitle.indexOf('Step 1 ') < 0;
|
||||
};
|
||||
|
||||
$scope.fromName = function(fullTitle) {
|
||||
var command = $scope.getWithoutStep(fullTitle);
|
||||
if (command.indexOf('FROM ') != 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = command.split(' ');
|
||||
for (var i = 0; i < parts.length - 1; i++) {
|
||||
var part = parts[i];
|
||||
if ($.trim(part) == 'as') {
|
||||
return parts[i + 1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
71
static/js/directives/ui/build-log-error.js
Normal file
71
static/js/directives/ui/build-log-error.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* An element which displays a build error in a nice format.
|
||||
*/
|
||||
angular.module('quay').directive('buildLogError', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-log-error.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'error': '=error',
|
||||
'entries': '=entries',
|
||||
'isSuperuser': '<isSuperuser'
|
||||
},
|
||||
controller: function($scope, $element, Config) {
|
||||
$scope.localPullInfo = null;
|
||||
|
||||
var calculateLocalPullInfo = function(entries) {
|
||||
var localInfo = {
|
||||
'isLocal': false
|
||||
};
|
||||
|
||||
// Find the 'pulling' phase entry, and then extra any metadata found under
|
||||
// it.
|
||||
for (var i = 0; i < $scope.entries.length; ++i) {
|
||||
var entry = $scope.entries[i];
|
||||
if (entry.type == 'phase' && entry.message == 'pulling') {
|
||||
var entryData = entry.data || {};
|
||||
if (entryData.base_image) {
|
||||
localInfo['isLocal'] = true || entryData['base_image'].indexOf(Config.SERVER_HOSTNAME + '/') == 0;
|
||||
localInfo['pullUsername'] = entryData['pull_username'];
|
||||
localInfo['repo'] = entryData['base_image'].substring(Config.SERVER_HOSTNAME.length);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.localPullInfo = localInfo;
|
||||
};
|
||||
|
||||
calculateLocalPullInfo($scope.entries);
|
||||
|
||||
$scope.getInternalError = function(entries) {
|
||||
var entry = entries[entries.length - 1];
|
||||
if (entry && entry.data && entry.data['internal_error']) {
|
||||
return entry.data['internal_error'];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
$scope.getBaseError = function(error) {
|
||||
if (!error || !error.data || !error.data.base_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return error.data.base_error;
|
||||
};
|
||||
|
||||
$scope.isPullError = function(error) {
|
||||
if (!error || !error.data || !error.data.base_error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return error.data.base_error.indexOf('Error: Status 403 trying to pull repository ') == 0;
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
18
static/js/directives/ui/build-log-phase.js
Normal file
18
static/js/directives/ui/build-log-phase.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* An element which displays the phase of a build nicely.
|
||||
*/
|
||||
angular.module('quay').directive('buildLogPhase', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-log-phase.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'phase': '=phase'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
215
static/js/directives/ui/build-logs-view.js
Normal file
215
static/js/directives/ui/build-logs-view.js
Normal file
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* An element which displays and auto-updates the logs from a build.
|
||||
*/
|
||||
angular.module('quay').directive('buildLogsView', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-logs-view.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'build': '=build',
|
||||
'useTimestamps': '=useTimestamps',
|
||||
'buildUpdated': '&buildUpdated',
|
||||
'isSuperUser': '=isSuperUser'
|
||||
},
|
||||
controller: function($scope, $element, $interval, $sanitize, ansi2html, ViewArray,
|
||||
AngularPollChannel, ApiService, Restangular, UtilService) {
|
||||
|
||||
var repoStatusApiCall = ApiService.getRepoBuildStatus;
|
||||
var repoLogApiCall = ApiService.getRepoBuildLogsAsResource;
|
||||
if( $scope.isSuperUser ){
|
||||
repoStatusApiCall = ApiService.getRepoBuildStatusSuperUser;
|
||||
repoLogApiCall = ApiService.getRepoBuildLogsSuperUserAsResource;
|
||||
}
|
||||
|
||||
$scope.logEntries = null;
|
||||
$scope.currentParentEntry = null;
|
||||
$scope.logStartIndex = 0;
|
||||
$scope.buildLogsText = '';
|
||||
$scope.currentBuild = null;
|
||||
$scope.loadError = null;
|
||||
|
||||
$scope.pollChannel = null;
|
||||
|
||||
var appendToTextLog = function(type, message) {
|
||||
if (type == 'phase') {
|
||||
text = 'Starting phase: ' + message + '\n';
|
||||
} else {
|
||||
text = message + '\n';
|
||||
}
|
||||
|
||||
$scope.buildLogsText += text.replace(new RegExp("\\033\\[[^m]+m"), '');
|
||||
};
|
||||
|
||||
var processLogs = function(logs, startIndex, endIndex) {
|
||||
if (!$scope.logEntries) { $scope.logEntries = []; }
|
||||
|
||||
// If the start index given is less than that requested, then we've received a larger
|
||||
// pool of logs, and we need to only consider the new ones.
|
||||
if (startIndex < $scope.logStartIndex) {
|
||||
logs = logs.slice($scope.logStartIndex - startIndex);
|
||||
}
|
||||
|
||||
for (var i = 0; i < logs.length; ++i) {
|
||||
var entry = logs[i];
|
||||
var type = entry['type'] || 'entry';
|
||||
if (type == 'command' || type == 'phase' || type == 'error') {
|
||||
entry['logs'] = ViewArray.create();
|
||||
entry['index'] = $scope.logStartIndex + i;
|
||||
|
||||
$scope.logEntries.push(entry);
|
||||
$scope.currentParentEntry = entry;
|
||||
} else if ($scope.currentParentEntry) {
|
||||
$scope.currentParentEntry['logs'].push(entry);
|
||||
}
|
||||
|
||||
appendToTextLog(type, entry['message']);
|
||||
}
|
||||
|
||||
return endIndex;
|
||||
};
|
||||
|
||||
var handleLogsData = function(logsData, callback) {
|
||||
// Process the logs we've received.
|
||||
$scope.logStartIndex = processLogs(logsData['logs'], logsData['start'], logsData['total']);
|
||||
|
||||
// If the build status is an error, automatically open the last command run.
|
||||
var currentBuild = $scope.currentBuild;
|
||||
if (currentBuild['phase'] == 'error') {
|
||||
for (var i = $scope.logEntries.length - 1; i >= 0; i--) {
|
||||
var currentEntry = $scope.logEntries[i];
|
||||
if (currentEntry['type'] == 'command') {
|
||||
currentEntry['logs'].setVisible(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the build phase is an error or a complete, then we mark the channel
|
||||
// as closed.
|
||||
callback(currentBuild['phase'] != 'error' && currentBuild['phase'] != 'complete');
|
||||
}
|
||||
|
||||
var getBuildStatusAndLogs = function(build, callback) {
|
||||
var params = {
|
||||
'repository': build.repository.namespace + '/' + build.repository.name,
|
||||
'build_uuid': build.id
|
||||
};
|
||||
|
||||
|
||||
repoStatusApiCall(null, params, true).then(function(resp) {
|
||||
if (resp.id != $scope.build.id) { callback(false); return; }
|
||||
|
||||
// Call the build updated handler.
|
||||
$scope.buildUpdated({'build': resp});
|
||||
|
||||
// Save the current build.
|
||||
$scope.currentBuild = resp;
|
||||
|
||||
// Load the updated logs for the build.
|
||||
var options = {
|
||||
'start': $scope.logStartIndex
|
||||
};
|
||||
|
||||
repoLogApiCall(params, true).withOptions(options).get(function(resp) {
|
||||
// If we get a logs url back, then we need to make another XHR request to retrieve the
|
||||
// data.
|
||||
var logsUrl = resp['logs_url'];
|
||||
if (logsUrl) {
|
||||
$.ajax({
|
||||
url: logsUrl,
|
||||
}).done(function(r) {
|
||||
$scope.$apply(function() {
|
||||
handleLogsData(r, callback);
|
||||
});
|
||||
}).error(function(xhr) {
|
||||
$scope.$apply(function() {
|
||||
if (xhr.status == 0) {
|
||||
UtilService.isAdBlockEnabled(function(result) {
|
||||
$scope.loadError = result ? 'blocked': 'request-failed';
|
||||
});
|
||||
} else {
|
||||
$scope.loadError = 'request-failed';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleLogsData(resp, callback);
|
||||
}, function(resp) {
|
||||
if (resp.status == 403) {
|
||||
$scope.loadError = 'unauthorized';
|
||||
} else {
|
||||
$scope.loadError = 'request-failed';
|
||||
}
|
||||
callback(false);
|
||||
});
|
||||
}, function() {
|
||||
$scope.loadError = 'request-failed';
|
||||
callback(false);
|
||||
});
|
||||
};
|
||||
|
||||
var startWatching = function(build) {
|
||||
// Create a new channel for polling the build status and logs.
|
||||
var conductStatusAndLogRequest = function(callback) {
|
||||
getBuildStatusAndLogs(build, callback);
|
||||
};
|
||||
|
||||
// Make sure to cancel any existing watchers first.
|
||||
stopWatching();
|
||||
|
||||
// Register a new poll channel to start watching.
|
||||
$scope.pollChannel = AngularPollChannel.create($scope, conductStatusAndLogRequest, 5 * 1000 /* 5s */);
|
||||
$scope.pollChannel.start();
|
||||
};
|
||||
|
||||
var stopWatching = function() {
|
||||
if ($scope.pollChannel) {
|
||||
$scope.pollChannel.stop();
|
||||
$scope.pollChannel = null;
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('useTimestamps', function() {
|
||||
if (!$scope.logEntries) { return; }
|
||||
$scope.logEntries = $scope.logEntries.slice();
|
||||
});
|
||||
|
||||
$scope.$watch('build', function(build) {
|
||||
if (build) {
|
||||
startWatching(build);
|
||||
} else {
|
||||
stopWatching();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.hasLogs = function(container) {
|
||||
return container.logs.hasEntries;
|
||||
};
|
||||
|
||||
$scope.formatDatetime = function(datetimeString) {
|
||||
// Note: The standard format required by the Date constructor in JS is
|
||||
// "2011-10-10T14:48:00" for date-times. The date-time string we get is exactly that,
|
||||
// but with a space instead of a 'T', so we just replace it.
|
||||
var dt = new Date(datetimeString.replace(' ', 'T'));
|
||||
return dt.toLocaleString();
|
||||
}
|
||||
|
||||
$scope.processANSI = function(message, container) {
|
||||
var filter = container.logs._filter = (container.logs._filter || ansi2html.create());
|
||||
|
||||
// Note: order is important here.
|
||||
var setup = filter.getSetupHtml();
|
||||
var stream = filter.addInputToStream(message || '');
|
||||
var teardown = filter.getTeardownHtml();
|
||||
return setup + stream + teardown;
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
21
static/js/directives/ui/build-message.js
Normal file
21
static/js/directives/ui/build-message.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* An element which displays a user-friendly message for the current phase of a build.
|
||||
*/
|
||||
angular.module('quay').directive('buildMessage', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-message.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'phase': '=phase'
|
||||
},
|
||||
controller: function($scope, $element, BuildService) {
|
||||
$scope.getBuildMessage = function (phase) {
|
||||
return BuildService.getBuildMessage(phase);
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
23
static/js/directives/ui/build-mini-status.js
Normal file
23
static/js/directives/ui/build-mini-status.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* An element which displays the status of a build as a mini-bar.
|
||||
*/
|
||||
angular.module('quay').directive('buildMiniStatus', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-mini-status.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'build': '=build',
|
||||
'canView': '=canView'
|
||||
},
|
||||
controller: function($scope, $element, BuildService) {
|
||||
$scope.isBuilding = function(build) {
|
||||
if (!build) { return true; }
|
||||
return BuildService.isActive(build)
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
53
static/js/directives/ui/build-progress.js
Normal file
53
static/js/directives/ui/build-progress.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* An element which displays a progressbar for the given build.
|
||||
*/
|
||||
angular.module('quay').directive('buildProgress', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-progress.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'build': '=build'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
$scope.getPercentage = function(buildInfo) {
|
||||
switch (buildInfo.phase) {
|
||||
case 'pulling':
|
||||
return buildInfo.status.pull_completion * 100;
|
||||
break;
|
||||
|
||||
case 'building':
|
||||
return (buildInfo.status.current_command / buildInfo.status.total_commands) * 100;
|
||||
break;
|
||||
|
||||
case 'pushing':
|
||||
return buildInfo.status.push_completion * 100;
|
||||
break;
|
||||
|
||||
case 'priming-cache':
|
||||
return buildInfo.status.cache_completion * 100;
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
return 100;
|
||||
break;
|
||||
|
||||
case 'initializing':
|
||||
case 'checking-cache':
|
||||
case 'starting':
|
||||
case 'waiting':
|
||||
case 'cannot_load':
|
||||
case 'unpacking':
|
||||
return 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
22
static/js/directives/ui/build-state-icon.js
Normal file
22
static/js/directives/ui/build-state-icon.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* An element which displays an icon representing the state of the build.
|
||||
*/
|
||||
angular.module('quay').directive('buildStateIcon', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-state-icon.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'build': '=build'
|
||||
},
|
||||
controller: function($scope, $element, BuildService) {
|
||||
$scope.isBuilding = function(build) {
|
||||
if (!build) { return true; }
|
||||
return BuildService.isActive(build);
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
18
static/js/directives/ui/build-status.js
Normal file
18
static/js/directives/ui/build-status.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* DEPRECATED: An element which displays the status of a build.
|
||||
*/
|
||||
angular.module('quay').directive('buildStatus', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/build-status.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'build': '=build'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
<span class="channel-icon-element" data-title="{{ $ctrl.name }}" bs-tooltip>
|
||||
<span class="hexagon" ng-style="{'background-color': $ctrl.color($ctrl.name)}">
|
||||
<span class="before" ng-style="{'border-bottom-color': $ctrl.color($ctrl.name)}"></span>
|
||||
<span class="after" ng-style="{'border-top-color': $ctrl.color($ctrl.name)}"></span>
|
||||
</span>
|
||||
<b>{{ $ctrl.initial($ctrl.name) }}</b>
|
||||
</span>
|
|
@ -0,0 +1,47 @@
|
|||
import { Input, Component, Inject } from 'ng-metadata/core';
|
||||
|
||||
|
||||
/**
|
||||
* A component that displays the icon of a channel.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'channel-icon',
|
||||
templateUrl: '/static/js/directives/ui/channel-icon/channel-icon.component.html',
|
||||
})
|
||||
export class ChannelIconComponent {
|
||||
@Input('<') public name: string;
|
||||
private colors: any;
|
||||
|
||||
constructor(@Inject('Config') Config: any, @Inject('md5') private md5: any) {
|
||||
this.colors = Config['CHANNEL_COLORS'];
|
||||
}
|
||||
|
||||
private initial(name: string): string {
|
||||
if (name == 'alpha') {
|
||||
return 'α';
|
||||
}
|
||||
if (name == 'beta') {
|
||||
return 'β';
|
||||
}
|
||||
if (name == 'stable') {
|
||||
return 'S';
|
||||
}
|
||||
return name[0].toUpperCase();
|
||||
}
|
||||
|
||||
private color(name: string): string {
|
||||
if (name == 'alpha') {
|
||||
return this.colors[0];
|
||||
}
|
||||
if (name == 'beta') {
|
||||
return this.colors[1];
|
||||
}
|
||||
if (name == 'stable') {
|
||||
return this.colors[2];
|
||||
}
|
||||
|
||||
var hash: string = this.md5.createHash(name);
|
||||
var num: number = parseInt(hash.substr(0, 4));
|
||||
return this.colors[num % this.colors.length];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import { ClipboardCopyDirective } from './clipboard-copy.directive';
|
||||
import * as Clipboard from 'clipboard';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("ClipboardCopyDirective", () => {
|
||||
var directive: ClipboardCopyDirective;
|
||||
var $elementMock: any;
|
||||
var $timeoutMock: any;
|
||||
var $documentMock: any;
|
||||
var clipboardFactory: any;
|
||||
var clipboardMock: Mock<Clipboard>;
|
||||
|
||||
beforeEach(() => {
|
||||
$elementMock = new Mock<ng.IAugmentedJQuery>();
|
||||
$timeoutMock = jasmine.createSpy('$timeoutSpy').and.callFake((fn: () => void, delay) => fn());
|
||||
$documentMock = new Mock<ng.IDocumentService>();
|
||||
clipboardMock = new Mock<Clipboard>();
|
||||
clipboardMock.setup(mock => mock.on).is((eventName: string, callback: (event) => void) => {});
|
||||
clipboardFactory = jasmine.createSpy('clipboardFactory').and.returnValue(clipboardMock.Object);
|
||||
directive = new ClipboardCopyDirective(<any>[$elementMock.Object],
|
||||
$timeoutMock,
|
||||
<any>[$documentMock.Object],
|
||||
clipboardFactory);
|
||||
directive.copyTargetSelector = "#copy-input-box-0";
|
||||
});
|
||||
|
||||
describe("ngAfterContentInit", () => {
|
||||
|
||||
it("initializes new Clipboard instance", () => {
|
||||
const target = new Mock<ng.IAugmentedJQuery>();
|
||||
$documentMock.setup(mock => mock.querySelector).is(selector => target.Object);
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect(clipboardFactory).toHaveBeenCalled();
|
||||
expect((<Spy>clipboardFactory.calls.argsFor(0)[0])).toEqual($elementMock.Object);
|
||||
expect((<Spy>clipboardFactory.calls.argsFor(0)[1]['target']())).toEqual(target.Object);
|
||||
});
|
||||
|
||||
it("sets error callback for Clipboard instance", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>clipboardMock.Object.on.calls.argsFor(0)[0])).toEqual('error');
|
||||
expect((<Spy>clipboardMock.Object.on.calls.argsFor(0)[1])).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets success callback for Clipboard instance", (done) => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>clipboardMock.Object.on.calls.argsFor(1)[0])).toEqual('success');
|
||||
expect((<Spy>clipboardMock.Object.on.calls.argsFor(1)[1])).toBeDefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngOnDestroy", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
clipboardMock.setup(mock => mock.destroy).is(() => null);
|
||||
});
|
||||
|
||||
it("calls method to destroy Clipboard instance if set", (done) => {
|
||||
directive.ngAfterContentInit();
|
||||
directive.ngOnDestroy();
|
||||
|
||||
expect((<Spy>clipboardMock.Object.destroy)).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
|
||||
it("does not call method to destroy Clipboard instance if not set", () => {
|
||||
directive.ngOnDestroy();
|
||||
|
||||
expect((<Spy>clipboardMock.Object.destroy)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,63 @@
|
|||
import { Directive, Inject, Input, AfterContentInit, OnDestroy } from 'ng-metadata/core';
|
||||
import * as Clipboard from 'clipboard';
|
||||
|
||||
|
||||
@Directive({
|
||||
selector: '[clipboardCopy]'
|
||||
})
|
||||
export class ClipboardCopyDirective implements AfterContentInit, OnDestroy {
|
||||
|
||||
@Input('@clipboardCopy') public copyTargetSelector: string;
|
||||
|
||||
private clipboard: Clipboard;
|
||||
|
||||
constructor(@Inject('$element') private $element: ng.IAugmentedJQuery,
|
||||
@Inject('$timeout') private $timeout: ng.ITimeoutService,
|
||||
@Inject('$document') private $document: ng.IDocumentService,
|
||||
@Inject('clipboardFactory') private clipboardFactory: (elem, options) => Clipboard) {
|
||||
|
||||
}
|
||||
|
||||
public ngAfterContentInit(): void {
|
||||
// FIXME: Need to wait for DOM to render to find target element
|
||||
this.$timeout(() => {
|
||||
this.clipboard = this.clipboardFactory(this.$element[0], {target: (trigger) => {
|
||||
return this.$document[0].querySelector(this.copyTargetSelector);
|
||||
}});
|
||||
|
||||
this.clipboard.on("error", (e) => {
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
this.clipboard.on('success', (e) => {
|
||||
const container = e.trigger.parentNode.parentNode.parentNode;
|
||||
const messageElem = container.querySelector('.clipboard-copied-message');
|
||||
if (!messageElem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resets the animation.
|
||||
var elem = messageElem;
|
||||
elem.style.display = 'none';
|
||||
elem.classList.remove('animated');
|
||||
|
||||
// Show the notification.
|
||||
setTimeout(() => {
|
||||
elem.style.display = 'inline-block';
|
||||
elem.classList.add('animated');
|
||||
}, 10);
|
||||
|
||||
// Reset the notification.
|
||||
setTimeout(() => {
|
||||
elem.style.display = 'none';
|
||||
}, 5000);
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
if (this.clipboard) {
|
||||
this.clipboard.destroy();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
<div class="context-path-select-element">
|
||||
<div class="dropdown-select" placeholder="'Enter a docker context'"
|
||||
selected-item="$ctrl.selectedContext"
|
||||
lookahead-items="$ctrl.contexts"
|
||||
handle-input="$ctrl.setContext(input)"
|
||||
handle-item-selected="$ctrl.setSelectedContext(datum.value)"
|
||||
allow-custom-input="true"
|
||||
hide-dropdown="$ctrl.contexts.length <= 0">
|
||||
<!-- Icons -->
|
||||
<i class="dropdown-select-icon none-icon fa fa-folder-o fa-lg"
|
||||
ng-show="$ctrl.isUnknownContext"></i>
|
||||
<i class="dropdown-select-icon none-icon fa fa-folder fa-lg" style="color: black;"
|
||||
ng-show="!$ctrl.isUnknownContext"></i>
|
||||
<i class="dropdown-select-icon fa fa-folder fa-lg"></i>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<ul class="dropdown-select-menu pull-right" role="menu">
|
||||
<li ng-repeat="context in $ctrl.contexts">
|
||||
<a ng-click="$ctrl.setSelectedContext(context)"
|
||||
ng-if="context">
|
||||
<i class="fa fa-folder fa-lg"></i> {{ context }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="padding: 10px">
|
||||
<div class="co-alert co-alert-danger"
|
||||
ng-show="!$ctrl.isValidContext && $ctrl.currentContext">
|
||||
Path is an invalid context.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,107 @@
|
|||
import { ContextPathSelectComponent, ContextChangeEvent } from './context-path-select.component';
|
||||
|
||||
|
||||
describe("ContextPathSelectComponent", () => {
|
||||
var component: ContextPathSelectComponent;
|
||||
var currentContext: string;
|
||||
var isValidContext: boolean;
|
||||
var contexts: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
component = new ContextPathSelectComponent();
|
||||
currentContext = '/';
|
||||
isValidContext = false;
|
||||
contexts = ['/'];
|
||||
component.currentContext = currentContext;
|
||||
component.isValidContext = isValidContext;
|
||||
component.contexts = contexts;
|
||||
});
|
||||
|
||||
describe("ngOnChanges", () => {
|
||||
|
||||
it("sets valid context flag to true if current context is valid", () => {
|
||||
component.ngOnChanges({});
|
||||
|
||||
expect(component.isValidContext).toBe(true);
|
||||
});
|
||||
|
||||
it("sets valid context flag to false if current context is invalid", () => {
|
||||
component.currentContext = "asdfdsf";
|
||||
component.ngOnChanges({});
|
||||
|
||||
expect(component.isValidContext).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setContext", () => {
|
||||
var newContext: string;
|
||||
|
||||
beforeEach(() => {
|
||||
newContext = '/conf';
|
||||
});
|
||||
|
||||
it("sets current context to given context", () => {
|
||||
component.setContext(newContext);
|
||||
|
||||
expect(component.currentContext).toEqual(newContext);
|
||||
});
|
||||
|
||||
it("sets valid context flag to true if given context is valid", () => {
|
||||
component.setContext(newContext);
|
||||
|
||||
expect(component.isValidContext).toBe(true);
|
||||
});
|
||||
|
||||
it("sets valid context flag to false if given context is invalid", () => {
|
||||
component.setContext("asdfsadfs");
|
||||
|
||||
expect(component.isValidContext).toBe(false);
|
||||
});
|
||||
|
||||
it("emits output event indicating build context changed", (done) => {
|
||||
component.contextChanged.subscribe((event: ContextChangeEvent) => {
|
||||
expect(event.contextDir).toEqual(newContext);
|
||||
expect(event.isValid).toEqual(component.isValidContext);
|
||||
done();
|
||||
});
|
||||
|
||||
component.setContext(newContext);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSelectedContext", () => {
|
||||
var newContext: string;
|
||||
|
||||
beforeEach(() => {
|
||||
newContext = '/conf';
|
||||
});
|
||||
|
||||
it("sets current context to given context", () => {
|
||||
component.setSelectedContext(newContext);
|
||||
|
||||
expect(component.currentContext).toEqual(newContext);
|
||||
});
|
||||
|
||||
it("sets valid context flag to true if given context is valid", () => {
|
||||
component.setSelectedContext(newContext);
|
||||
|
||||
expect(component.isValidContext).toBe(true);
|
||||
});
|
||||
|
||||
it("sets valid context flag to false if given context is invalid", () => {
|
||||
component.setSelectedContext("a;lskjdf;ldsa");
|
||||
|
||||
expect(component.isValidContext).toBe(false);
|
||||
});
|
||||
|
||||
it("emits output event indicating build context changed", (done) => {
|
||||
component.contextChanged.subscribe((event: ContextChangeEvent) => {
|
||||
expect(event.contextDir).toEqual(newContext);
|
||||
expect(event.isValid).toEqual(component.isValidContext);
|
||||
done();
|
||||
});
|
||||
|
||||
component.setSelectedContext(newContext);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
import { Input, Component, OnChanges, SimpleChanges, Output, EventEmitter } from 'ng-metadata/core';
|
||||
|
||||
|
||||
/**
|
||||
* A component that allows the user to select the location of the Context in their source code repository.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'context-path-select',
|
||||
templateUrl: '/static/js/directives/ui/context-path-select/context-path-select.component.html'
|
||||
})
|
||||
export class ContextPathSelectComponent implements OnChanges {
|
||||
|
||||
@Input('<') public currentContext: string = '';
|
||||
@Input('<') public contexts: string[];
|
||||
@Output() public contextChanged: EventEmitter<ContextChangeEvent> = new EventEmitter();
|
||||
public isValidContext: boolean;
|
||||
private isUnknownContext: boolean = true;
|
||||
private selectedContext: string | null = null;
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
this.isValidContext = this.checkContext(this.currentContext, this.contexts);
|
||||
}
|
||||
|
||||
public setContext(context: string): void {
|
||||
this.currentContext = context;
|
||||
this.selectedContext = null;
|
||||
this.isValidContext = this.checkContext(context, this.contexts);
|
||||
|
||||
this.contextChanged.emit({contextDir: context, isValid: this.isValidContext});
|
||||
}
|
||||
|
||||
public setSelectedContext(context: string): void {
|
||||
this.currentContext = context;
|
||||
this.selectedContext = context;
|
||||
this.isValidContext = this.checkContext(context, this.contexts);
|
||||
|
||||
this.contextChanged.emit({contextDir: context, isValid: this.isValidContext});
|
||||
}
|
||||
|
||||
private checkContext(context: string = '', contexts: string[] = []): boolean {
|
||||
this.isUnknownContext = false;
|
||||
var isValidContext: boolean = false;
|
||||
|
||||
if (context.length > 0 && context[0] === '/') {
|
||||
isValidContext = true;
|
||||
this.isUnknownContext = contexts.indexOf(context) != -1;
|
||||
}
|
||||
return isValidContext;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build context changed event.
|
||||
*/
|
||||
export type ContextChangeEvent = {
|
||||
contextDir: string;
|
||||
isValid: boolean;
|
||||
};
|
74
static/js/directives/ui/convert-user-to-org.js
Normal file
74
static/js/directives/ui/convert-user-to-org.js
Normal file
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Displays a panel for converting the current user to an organization.
|
||||
*/
|
||||
angular.module('quay').directive('convertUserToOrg', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/convert-user-to-org.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'info': '=info'
|
||||
},
|
||||
controller: function($scope, $element, $location, Features, PlanService, Config, ApiService, CookieService, UserService) {
|
||||
$scope.convertStep = 0;
|
||||
$scope.org = {};
|
||||
$scope.loading = false;
|
||||
$scope.user = null;
|
||||
$scope.Features = Features;
|
||||
|
||||
$scope.$watch('info', function(info) {
|
||||
if (info && info.user) {
|
||||
$scope.user = info.user;
|
||||
$scope.accountType = 'user';
|
||||
$scope.convertStep = 0;
|
||||
$('#convertAccountModal').modal({});
|
||||
}
|
||||
});
|
||||
|
||||
$scope.showConvertForm = function() {
|
||||
$scope.convertStep = 1;
|
||||
};
|
||||
|
||||
$scope.nextStep = function() {
|
||||
if (Features.BILLING) {
|
||||
PlanService.getMatchingBusinessPlan(function(plan) {
|
||||
$scope.org.plan = plan;
|
||||
});
|
||||
|
||||
PlanService.getPlans(function(plans) {
|
||||
$scope.orgPlans = plans;
|
||||
});
|
||||
|
||||
$scope.convertStep = 2;
|
||||
} else {
|
||||
$scope.performConversion();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.performConversion = function() {
|
||||
if (Config.AUTHENTICATION_TYPE != 'Database') { return; }
|
||||
$scope.convertStep = 3;
|
||||
|
||||
var errorHandler = ApiService.errorDisplay(function() {
|
||||
$('#convertAccountModal').modal('hide');
|
||||
});
|
||||
|
||||
var data = {
|
||||
'adminUser': $scope.org.adminUser,
|
||||
'adminPassword': $scope.org.adminPassword,
|
||||
'plan': $scope.org.plan ? $scope.org.plan.stripeId : ''
|
||||
};
|
||||
|
||||
ApiService.convertUserToOrganization(data).then(function(resp) {
|
||||
CookieService.putPermanent('quay.namespace', $scope.user.username);
|
||||
UserService.load();
|
||||
$('#convertAccountModal').modal('hide');
|
||||
$location.path('/');
|
||||
}, errorHandler);
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
23
static/js/directives/ui/copy-box.js
Normal file
23
static/js/directives/ui/copy-box.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* An element which displays a textfield with a "Copy to Clipboard" icon next to it.
|
||||
*/
|
||||
angular.module('quay').directive('copyBox', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/copy-box.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'value': '=value',
|
||||
},
|
||||
controller: function($scope, $element, $rootScope) {
|
||||
$scope.disabled = false;
|
||||
|
||||
var number = $rootScope.__copyBoxIdCounter || 0;
|
||||
$rootScope.__copyBoxIdCounter = number + 1;
|
||||
$scope.inputId = "copy-box-input-" + number;
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
45
static/js/directives/ui/cor-table/cor-table-col.component.ts
Normal file
45
static/js/directives/ui/cor-table/cor-table-col.component.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { Input, Component, OnInit, Inject, Host } from 'ng-metadata/core';
|
||||
import { CorTableComponent } from './cor-table.component';
|
||||
|
||||
|
||||
/**
|
||||
* Defines a column (optionally sortable) in the table.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-table-col',
|
||||
template: '',
|
||||
})
|
||||
export class CorTableColumn implements OnInit {
|
||||
|
||||
@Input('@') public title: string;
|
||||
@Input('@') public templateurl: string;
|
||||
@Input('@') public datafield: string;
|
||||
@Input('@') public sortfield: string;
|
||||
@Input('@') public selected: string;
|
||||
@Input('=') public bindModel: any;
|
||||
@Input('@') public style: string;
|
||||
@Input('@') public class: string;
|
||||
@Input('@') public kindof: string;
|
||||
@Input('<') public itemLimit: number = 5;
|
||||
|
||||
constructor(@Host() @Inject(CorTableComponent) private parent: CorTableComponent,
|
||||
@Inject('TableService') private tableService: any) {
|
||||
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.parent.addColumn(this);
|
||||
}
|
||||
|
||||
public isNumeric(): boolean {
|
||||
return this.kindof == 'datetime';
|
||||
}
|
||||
|
||||
public processColumnForOrdered(value: any): any {
|
||||
if (this.kindof == 'datetime' && value) {
|
||||
return this.tableService.getReversedTimestamp(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.cor-table-element .co-top-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
}
|
67
static/js/directives/ui/cor-table/cor-table.component.html
Normal file
67
static/js/directives/ui/cor-table/cor-table.component.html
Normal file
|
@ -0,0 +1,67 @@
|
|||
<div class="cor-table-element">
|
||||
<span ng-transclude></span>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="co-top-bar" ng-if="!$ctrl.compact">
|
||||
<span class="co-filter-box with-options" ng-if="$ctrl.tableData.length && $ctrl.filterFields.length">
|
||||
<span class="page-controls"
|
||||
total-count="$ctrl.orderedData.entries.length"
|
||||
current-page="$ctrl.options.page"
|
||||
page-size="$ctrl.maxDisplayCount"></span>
|
||||
<span class="filter-message" ng-if="$ctrl.options.filter">
|
||||
Showing {{ $ctrl.orderedData.entries.length }} of {{ $ctrl.tableData.length }} {{ ::$ctrl.tableItemTitle }}
|
||||
</span>
|
||||
<input class="form-control" type="text"
|
||||
placeholder="Filter {{ ::$ctrl.tableItemTitle }}..."
|
||||
ng-model="$ctrl.options.filter"
|
||||
ng-change="$ctrl.refreshOrder()">
|
||||
</span>
|
||||
|
||||
<!-- Compact/expand rows toggle -->
|
||||
<div ng-if="!$ctrl.compact && $ctrl.canExpand" class="tab-header-controls">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn" ng-class="!$ctrl.expandRows ? 'btn-primary active' : 'btn-default'"
|
||||
ng-click="$ctrl.setExpanded(false)">
|
||||
Compact
|
||||
</button>
|
||||
<button class="btn" ng-class="$ctrl.expandRows ? 'btn-info active' : 'btn-default'"
|
||||
ng-click="$ctrl.setExpanded(true)">
|
||||
Expanded
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div class="empty" ng-if="!$ctrl.tableData.length && $ctrl.compact != 'true'">
|
||||
<div class="empty-primary-msg">No {{ ::$ctrl.tableItemTitle }} found.</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<table class="co-table co-fixed-table" ng-show="$ctrl.tableData.length">
|
||||
<thead>
|
||||
<td ng-repeat="col in $ctrl.columns"
|
||||
ng-class="$ctrl.tablePredicateClass(col)" style="{{ ::col.style }}"
|
||||
class="{{ ::col.class }}">
|
||||
<a ng-click="$ctrl.setOrder(col)">{{ ::col.title }}</a>
|
||||
</td>
|
||||
</thead>
|
||||
<tbody ng-repeat="item in $ctrl.orderedData.visibleEntries" ng-init="rowIndex = $index"
|
||||
ng-if="($index >= $ctrl.options.page * $ctrl.maxDisplayCount &&
|
||||
$index < ($ctrl.options.page + 1) * $ctrl.maxDisplayCount)">
|
||||
<tr>
|
||||
<td ng-repeat="col in $ctrl.columns"
|
||||
style="{{ ::col.style }}" class="{{ ::col.class }}">
|
||||
<div ng-if="col.templateurl" ng-include="col.templateurl"></div>
|
||||
<div ng-if="!col.templateurl">{{ item[col.datafield] }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="empty" ng-if="!$ctrl.orderedData.entries.length && $ctrl.tableData.length"
|
||||
style="margin-top: 20px;">
|
||||
<div class="empty-primary-msg">No matching {{ ::$ctrl.tableItemTitle }} found.</div>
|
||||
<div class="empty-secondary-msg">Try adjusting your filter above.</div>
|
||||
</div>
|
||||
</div>
|
126
static/js/directives/ui/cor-table/cor-table.component.spec.ts
Normal file
126
static/js/directives/ui/cor-table/cor-table.component.spec.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
import { Mock } from 'ts-mocks';
|
||||
import { CorTableComponent, CorTableOptions } from './cor-table.component';
|
||||
import { CorTableColumn } from './cor-table-col.component';
|
||||
import { SimpleChanges } from 'ng-metadata/core';
|
||||
import { ViewArray } from '../../../services/view-array/view-array';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTableComponent", () => {
|
||||
var component: CorTableComponent;
|
||||
var tableServiceMock: Mock<any>;
|
||||
var tableData: any[];
|
||||
var columnMocks: Mock<CorTableColumn>[];
|
||||
var orderedDataMock: Mock<ViewArray>;
|
||||
|
||||
beforeEach(() => {
|
||||
orderedDataMock = new Mock<ViewArray>();
|
||||
orderedDataMock.setup(mock => mock.visibleEntries).is([]);
|
||||
tableServiceMock = new Mock<any>();
|
||||
tableServiceMock.setup(mock => mock.buildOrderedItems)
|
||||
.is((items, options, filterFields, numericFields, extrafilter?) => orderedDataMock.Object);
|
||||
|
||||
tableData = [
|
||||
{name: "apple", last_modified: 1496068383000, version: "1.0.0"},
|
||||
{name: "pear", last_modified: 1496068383001, version: "1.1.0"},
|
||||
{name: "orange", last_modified: 1496068383002, version: "1.0.0"},
|
||||
{name: "banana", last_modified: 1496068383000, version: "2.0.0"},
|
||||
];
|
||||
|
||||
columnMocks = Object.keys(tableData[0])
|
||||
.map((key, index) => {
|
||||
const col = new Mock<CorTableColumn>();
|
||||
col.setup(mock => mock.isNumeric).is(() => index == 1 ? true : false);
|
||||
col.setup(mock => mock.processColumnForOrdered).is((value) => "dummy");
|
||||
col.setup(mock => mock.datafield).is(key);
|
||||
|
||||
return col;
|
||||
});
|
||||
|
||||
component = new CorTableComponent(tableServiceMock.Object);
|
||||
component.tableData = tableData;
|
||||
component.filterFields = ['name', 'version'];
|
||||
component.compact = false;
|
||||
component.tableItemTitle = "fruits";
|
||||
component.maxDisplayCount = 10;
|
||||
// Add columns
|
||||
columnMocks.forEach(colMock => component.addColumn(colMock.Object));
|
||||
(<Spy>tableServiceMock.Object.buildOrderedItems).calls.reset();
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
|
||||
it("sets table options", () => {
|
||||
expect(component.options.filter).toEqual('');
|
||||
expect(component.options.reverse).toBe(false);
|
||||
expect(component.options.predicate).toEqual('');
|
||||
expect(component.options.page).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngOnChanges", () => {
|
||||
var changes: SimpleChanges;
|
||||
|
||||
it("calls table service to build ordered items if table data is changed", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes processed table data to table service", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.tableData = changes['tableData'].currentValue;
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[0]).not.toEqual(tableData);
|
||||
});
|
||||
|
||||
it("passes options to table service", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[1]).toEqual(component.options);
|
||||
});
|
||||
|
||||
it("passes filter fields to table service", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[2]).toEqual(component.filterFields);
|
||||
});
|
||||
|
||||
it("passes numeric fields to table service", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
const expectedNumericCols: string[] = columnMocks.filter(colMock => colMock.Object.isNumeric())
|
||||
.map(colMock => colMock.Object.datafield);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[3]).toEqual(expectedNumericCols);
|
||||
});
|
||||
|
||||
it("resets to first page if table data is changed", () => {
|
||||
component.options.page = 1;
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect(component.options.page).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addColumn", () => {
|
||||
var columnMock: Mock<CorTableColumn>;
|
||||
|
||||
beforeEach(() => {
|
||||
columnMock = new Mock<CorTableColumn>();
|
||||
columnMock.setup(mock => mock.isNumeric).is(() => false);
|
||||
});
|
||||
|
||||
it("calls table service to build ordered items with new column", () => {
|
||||
component.addColumn(columnMock.Object);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
110
static/js/directives/ui/cor-table/cor-table.component.ts
Normal file
110
static/js/directives/ui/cor-table/cor-table.component.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { Input, Component, OnChanges, SimpleChanges, Inject } from 'ng-metadata/core';
|
||||
import { CorTableColumn } from './cor-table-col.component';
|
||||
import { ViewArray } from '../../../services/view-array/view-array';
|
||||
import './cor-table.component.css';
|
||||
|
||||
|
||||
/**
|
||||
* A component that displays a table of information, with optional filtering and automatic sorting.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-table',
|
||||
templateUrl: '/static/js/directives/ui/cor-table/cor-table.component.html',
|
||||
legacy: {
|
||||
transclude: true
|
||||
}
|
||||
})
|
||||
export class CorTableComponent implements OnChanges {
|
||||
|
||||
@Input('<') public tableData: any[] = [];
|
||||
@Input('@') public tableItemTitle: string;
|
||||
@Input('<') public filterFields: string[];
|
||||
@Input('<') public compact: boolean = false;
|
||||
@Input('<') public maxDisplayCount: number = 10;
|
||||
@Input('<') public canExpand: boolean = false;
|
||||
@Input('<') public expandRows: boolean = false;
|
||||
|
||||
public orderedData: ViewArray;
|
||||
public options: CorTableOptions = {
|
||||
filter: '',
|
||||
reverse: false,
|
||||
predicate: '',
|
||||
page: 0,
|
||||
};
|
||||
|
||||
private rows: CorTableRow[] = [];
|
||||
private columns: CorTableColumn[] = [];
|
||||
|
||||
constructor(@Inject('TableService') private tableService: any) {
|
||||
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['tableData'] !== undefined) {
|
||||
this.refreshOrder();
|
||||
}
|
||||
}
|
||||
|
||||
public addColumn(col: CorTableColumn): void {
|
||||
this.columns.push(col);
|
||||
|
||||
if (col.selected == 'true') {
|
||||
this.options['predicate'] = col.datafield;
|
||||
}
|
||||
|
||||
this.refreshOrder();
|
||||
}
|
||||
|
||||
private setOrder(col: CorTableColumn): void {
|
||||
this.tableService.orderBy(col.datafield, this.options);
|
||||
this.refreshOrder();
|
||||
}
|
||||
|
||||
private setExpanded(isExpanded: boolean): void {
|
||||
this.expandRows = isExpanded;
|
||||
this.rows.forEach((row) => row.expanded = isExpanded);
|
||||
}
|
||||
|
||||
private tablePredicateClass(col: CorTableColumn, options: any) {
|
||||
return this.tableService.tablePredicateClass(col.datafield, this.options.predicate, this.options.reverse);
|
||||
}
|
||||
|
||||
private refreshOrder(): void {
|
||||
this.options.page = 0;
|
||||
|
||||
var columnMap: {[name: string]: CorTableColumn} = {};
|
||||
this.columns.forEach(function(col) {
|
||||
columnMap[col.datafield] = col;
|
||||
});
|
||||
|
||||
const numericCols: string[] = this.columns.filter(col => col.isNumeric())
|
||||
.map(col => col.datafield);
|
||||
|
||||
const processed: any[] = this.tableData.map((item) => {
|
||||
Object.keys(item).forEach((key) => {
|
||||
if (columnMap[key]) {
|
||||
item[key] = columnMap[key].processColumnForOrdered(item[key]);
|
||||
}
|
||||
});
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
this.orderedData = this.tableService.buildOrderedItems(processed, this.options, this.filterFields, numericCols);
|
||||
this.rows = this.orderedData.visibleEntries.map((item) => Object.assign({}, {expanded: false, rowData: item}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type CorTableOptions = {
|
||||
filter: string;
|
||||
reverse: boolean;
|
||||
predicate: string;
|
||||
page: number;
|
||||
};
|
||||
|
||||
|
||||
export type CorTableRow = {
|
||||
expanded: boolean;
|
||||
rowData: any;
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
import { CorCookieTabsDirective } from './cor-cookie-tabs.directive';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorCookieTabsDirective", () => {
|
||||
var directive: CorCookieTabsDirective;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var cookieServiceMock: Mock<any>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "subscribe").and.returnValue(null);
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
cookieServiceMock = new Mock<any>();
|
||||
cookieServiceMock.setup(mock => mock.putPermanent).is((cookieName, value) => null);
|
||||
|
||||
directive = new CorCookieTabsDirective(panelMock.Object, cookieServiceMock.Object);
|
||||
directive.cookieName = "quay.credentialsTab";
|
||||
});
|
||||
|
||||
describe("ngAfterContentInit", () => {
|
||||
const tabId: string = "description";
|
||||
|
||||
beforeEach(() => {
|
||||
cookieServiceMock.setup(mock => mock.get).is((name) => tabId);
|
||||
spyOn(activeTab, "next").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("calls cookie service to retrieve initial tab id", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>cookieServiceMock.Object.get).calls.argsFor(0)[0]).toEqual(directive.cookieName);
|
||||
});
|
||||
|
||||
it("emits retrieved tab id as next active tab", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls cookie service to put new permanent cookie on active tab changes", () => {
|
||||
directive.ngAfterContentInit();
|
||||
const tabId: string = "description";
|
||||
(<Spy>panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](tabId);
|
||||
|
||||
expect((<Spy>cookieServiceMock.Object.putPermanent).calls.argsFor(0)[0]).toEqual(directive.cookieName);
|
||||
expect((<Spy>cookieServiceMock.Object.putPermanent).calls.argsFor(0)[1]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,30 @@
|
|||
import { Directive, Inject, Host, AfterContentInit, Input } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* Adds routing capabilities to cor-tab-panel using a browser cookie.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[corCookieTabs]'
|
||||
})
|
||||
export class CorCookieTabsDirective implements AfterContentInit {
|
||||
|
||||
@Input('@corCookieTabs') public cookieName: string;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent,
|
||||
@Inject('CookieService') private cookieService: any) {
|
||||
|
||||
}
|
||||
|
||||
public ngAfterContentInit(): void {
|
||||
// Set initial tab
|
||||
const tabId: string = this.cookieService.get(this.cookieName);
|
||||
|
||||
this.panel.activeTab.next(tabId);
|
||||
|
||||
this.panel.activeTab.subscribe((tab: string) => {
|
||||
this.cookieService.putPermanent(this.cookieName, tab);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import { CorNavTabsDirective } from './cor-nav-tabs.directive';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorNavTabsDirective", () => {
|
||||
var directive: CorNavTabsDirective;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var $locationMock: Mock<ng.ILocationService>;
|
||||
var $rootScopeMock: Mock<ng.IRootScopeService>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
const tabId: string = "description";
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "next").and.returnValue(null);
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
$locationMock = new Mock<ng.ILocationService>();
|
||||
$locationMock.setup(mock => mock.search).is(() => <any>{tab: tabId});
|
||||
$rootScopeMock = new Mock<ng.IRootScopeService>();
|
||||
$rootScopeMock.setup(mock => mock.$on);
|
||||
|
||||
directive = new CorNavTabsDirective(panelMock.Object, $locationMock.Object, $rootScopeMock.Object);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
|
||||
it("subscribes to $routeUpdate event on the root scope", () => {
|
||||
expect((<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[0]).toEqual("$routeUpdate");
|
||||
});
|
||||
|
||||
it("calls location service to retrieve tab id from URL query parameters on route update", () => {
|
||||
(<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[1]();
|
||||
|
||||
expect(<Spy>$locationMock.Object.search).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits retrieved tab id as next active tab on route update", () => {
|
||||
(<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[1]();
|
||||
|
||||
expect((<Spy>activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngAfterContentInit", () => {
|
||||
const path: string = "quay.io/repository/devtable/simple";
|
||||
|
||||
beforeEach(() => {
|
||||
$locationMock.setup(mock => mock.path).is(() => <any>path);
|
||||
});
|
||||
|
||||
it("calls location service to retrieve the current URL path and sets panel's base path", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect(panelMock.Object.basePath).toEqual(path);
|
||||
});
|
||||
|
||||
it("calls location service to retrieve tab id from URL query parameters", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect(<Spy>$locationMock.Object.search).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits retrieved tab id as next active tab", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import { Directive, Inject, Host, AfterContentInit, Input } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* Adds routing capabilities to cor-tab-panel, either using URL query parameters, or browser cookie.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[corNavTabs]'
|
||||
})
|
||||
export class CorNavTabsDirective implements AfterContentInit {
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent,
|
||||
@Inject('$location') private $location: ng.ILocationService,
|
||||
@Inject('$rootScope') private $rootScope: ng.IRootScopeService) {
|
||||
this.$rootScope.$on('$routeUpdate', () => {
|
||||
const tabId: string = this.$location.search()['tab'];
|
||||
this.panel.activeTab.next(tabId);
|
||||
});
|
||||
}
|
||||
|
||||
public ngAfterContentInit(): void {
|
||||
this.panel.basePath = this.$location.path();
|
||||
|
||||
// Set initial tab
|
||||
const tabId: string = this.$location.search()['tab'];
|
||||
this.panel.activeTab.next(tabId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<div class="co-tab-content tab-content col-md-11" ng-transclude></div>
|
|
@ -0,0 +1,17 @@
|
|||
import { Component } from 'ng-metadata/core';
|
||||
|
||||
|
||||
/**
|
||||
* A component that is placed under a cor-tabs to wrap tab content with additional styling.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-content',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-content/cor-tab-content.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
replace: true,
|
||||
}
|
||||
})
|
||||
export class CorTabContentComponent {
|
||||
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<div class="co-tab-pane" ng-show="$ctrl.isActiveTab">
|
||||
<div ng-transclude />
|
||||
</div>
|
|
@ -0,0 +1,63 @@
|
|||
import { CorTabPaneComponent } from './cor-tab-pane.component';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTabPaneComponent", () => {
|
||||
var component: CorTabPaneComponent;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "subscribe").and.callThrough();
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
|
||||
component = new CorTabPaneComponent(panelMock.Object);
|
||||
component.id = 'description';
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
panelMock.setup(mock => mock.addTabPane);
|
||||
});
|
||||
|
||||
it("adds self as tab pane to panel", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.addTabPane).calls.argsFor(0)[0]).toBe(component);
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing if active tab ID is undefined", () => {
|
||||
component.ngOnInit();
|
||||
component.isActiveTab = true;
|
||||
panelMock.Object.activeTab.next(null);
|
||||
|
||||
expect(component.isActiveTab).toEqual(true);
|
||||
});
|
||||
|
||||
it("sets self as active if active tab ID matches tab ID", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(component.id);
|
||||
|
||||
expect(component.isActiveTab).toEqual(true);
|
||||
});
|
||||
|
||||
it("sets self as inactive if active tab ID does not match tab ID", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(component.id.split('').reverse().join(''));
|
||||
|
||||
expect(component.isActiveTab).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
import { Component, Input, Inject, Host, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import 'rxjs/add/operator/filter';
|
||||
|
||||
|
||||
/**
|
||||
* A component that creates a single tab pane under a cor-tabs component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-pane',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
}
|
||||
})
|
||||
export class CorTabPaneComponent implements OnInit {
|
||||
|
||||
@Input('@') public id: string;
|
||||
|
||||
public isActiveTab: boolean = false;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent) {
|
||||
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.panel.addTabPane(this);
|
||||
|
||||
this.panel.activeTab
|
||||
.filter(tabId => tabId != undefined)
|
||||
.subscribe((tabId: string) => {
|
||||
this.isActiveTab = (this.id === tabId);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<div class="co-main-content-panel co-tab-panel co-fx-box-shadow-heavy">
|
||||
<div class="co-tab-container" ng-class="$ctrl.isVertical() ? 'vertical': 'horizontal'" ng-transclude></div>
|
||||
</div>
|
|
@ -0,0 +1,132 @@
|
|||
import { CorTabPanelComponent } from './cor-tab-panel.component';
|
||||
import { CorTabComponent } from '../cor-tab/cor-tab.component';
|
||||
import { SimpleChanges } from 'ng-metadata/core';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTabPanelComponent", () => {
|
||||
var component: CorTabPanelComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new CorTabPanelComponent();
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
var tabs: CorTabComponent[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Add tabs to panel
|
||||
tabs.push(new CorTabComponent(component));
|
||||
tabs[0].tabId = "info";
|
||||
tabs.forEach((tab) => component.addTab(tab));
|
||||
|
||||
spyOn(component.activeTab, "subscribe").and.callThrough();
|
||||
spyOn(component.activeTab, "next").and.callThrough();
|
||||
spyOn(component.tabChange, "emit").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect(<Spy>component.activeTab.subscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits next active tab with tab ID of first registered tab if given tab ID is null", () => {
|
||||
component.ngOnInit();
|
||||
component.activeTab.next(null);
|
||||
|
||||
expect((<Spy>component.activeTab.next).calls.argsFor(1)[0]).toEqual(tabs[0].tabId);
|
||||
});
|
||||
|
||||
it("does not emit output event for tab change if tab ID is null", () => {
|
||||
component.ngOnInit();
|
||||
component.activeTab.next(null);
|
||||
|
||||
expect((<Spy>component.tabChange.emit).calls.allArgs).not.toContain(null);
|
||||
});
|
||||
|
||||
it("emits output event for tab change when tab ID is not null", () => {
|
||||
component.ngOnInit();
|
||||
const tabId: string = "description";
|
||||
component.activeTab.next(tabId);
|
||||
|
||||
expect((<Spy>component.tabChange.emit).calls.argsFor(1)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngOnChanges", () => {
|
||||
var changes: SimpleChanges;
|
||||
var tabs: CorTabComponent[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Add tabs to panel
|
||||
tabs.push(new CorTabComponent(component));
|
||||
tabs.forEach((tab) => component.addTab(tab));
|
||||
|
||||
changes = {
|
||||
'selectedIndex': {
|
||||
currentValue: 0,
|
||||
previousValue: null,
|
||||
isFirstChange: () => false
|
||||
},
|
||||
};
|
||||
|
||||
spyOn(component.activeTab, "next").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("emits next active tab if 'selectedIndex' input changes and is valid", () => {
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>component.activeTab.next).calls.argsFor(0)[0]).toEqual(tabs[changes['selectedIndex'].currentValue].tabId);
|
||||
});
|
||||
|
||||
it("does nothing if 'selectedIndex' input changed to invalid value", () => {
|
||||
changes['selectedIndex'].currentValue = 100;
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect(<Spy>component.activeTab.next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTab", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(component.activeTab, "next").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("emits next active tab if it is not set", () => {
|
||||
const tab: CorTabComponent = new CorTabComponent(component);
|
||||
component.addTab(tab);
|
||||
|
||||
expect((<Spy>component.activeTab.next).calls.argsFor(0)[0]).toEqual(tab.tabId);
|
||||
});
|
||||
|
||||
it("does not emit next active tab if it is already set", () => {
|
||||
spyOn(component.activeTab, "getValue").and.returnValue("description");
|
||||
const tab: CorTabComponent = new CorTabComponent(component);
|
||||
component.addTab(tab);
|
||||
|
||||
expect(<Spy>component.activeTab.next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTabPane", () => {
|
||||
|
||||
});
|
||||
|
||||
describe("isVertical", () => {
|
||||
|
||||
it("returns true if orientation is 'vertical'", () => {
|
||||
component.orientation = 'vertical';
|
||||
const isVertical: boolean = component.isVertical();
|
||||
|
||||
expect(isVertical).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if orientation is not 'vertical'", () => {
|
||||
const isVertical: boolean = component.isVertical();
|
||||
|
||||
expect(isVertical).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,65 @@
|
|||
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabComponent } from '../cor-tab/cor-tab.component';
|
||||
import { CorTabPaneComponent } from '../cor-tab-pane/cor-tab-pane.component';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
|
||||
|
||||
/**
|
||||
* A component that contains a cor-tabs and handles all of its logic.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-panel',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.html',
|
||||
legacy: {
|
||||
transclude: true
|
||||
}
|
||||
})
|
||||
export class CorTabPanelComponent implements OnInit, OnChanges {
|
||||
|
||||
@Input('@') public orientation: 'horizontal' | 'vertical' = 'horizontal';
|
||||
|
||||
@Output() public tabChange: EventEmitter<string> = new EventEmitter();
|
||||
|
||||
public basePath: string;
|
||||
public activeTab = new BehaviorSubject<string>(null);
|
||||
|
||||
private tabs: CorTabComponent[] = [];
|
||||
private tabPanes: {[id: string]: CorTabPaneComponent} = {};
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.activeTab.subscribe((tabId: string) => {
|
||||
// Catch null values and replace with tabId of first tab
|
||||
if (!tabId && this.tabs[0]) {
|
||||
this.activeTab.next(this.tabs[0].tabId);
|
||||
} else {
|
||||
this.tabChange.emit(tabId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
switch (Object.keys(changes)[0]) {
|
||||
case 'selectedIndex':
|
||||
if (this.tabs.length > changes['selectedIndex'].currentValue) {
|
||||
this.activeTab.next(this.tabs[changes['selectedIndex'].currentValue].tabId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public addTab(tab: CorTabComponent): void {
|
||||
this.tabs.push(tab);
|
||||
|
||||
if (!this.activeTab.getValue()) {
|
||||
this.activeTab.next(this.tabs[0].tabId);
|
||||
}
|
||||
}
|
||||
|
||||
public addTabPane(tabPane: CorTabPaneComponent): void {
|
||||
this.tabPanes[tabPane.id] = tabPane;
|
||||
}
|
||||
|
||||
public isVertical(): boolean {
|
||||
return this.orientation == 'vertical';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
<li class="cor-tab-itself" ng-class="{'active': $ctrl.isActive, 'co-top-tab': !$ctrl.parent.isVertical()}">
|
||||
<a href="{{ $ctrl.panel.basePath ? $ctrl.panel.basePath + '?tab=' + $ctrl.tabId : '' }}"
|
||||
ng-click="$ctrl.tabClicked($event)">
|
||||
<span class="cor-tab-icon"
|
||||
data-title="{{ ::($ctrl.panel.isVertical() ? $ctrl.tabTitle : '') }}"
|
||||
data-placement="right"
|
||||
data-container="body"
|
||||
style="display: inline-block"
|
||||
bs-tooltip>
|
||||
<span ng-transclude /><span class="horizontal-label">{{ ::$ctrl.tabTitle }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
|
@ -0,0 +1,85 @@
|
|||
import { CorTabComponent } from './cor-tab.component';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTabComponent", () => {
|
||||
var component: CorTabComponent;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "subscribe").and.callThrough();
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
|
||||
component = new CorTabComponent(panelMock.Object);
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
panelMock.setup(mock => mock.addTab);
|
||||
spyOn(component.tabInit, "emit").and.returnValue(null);
|
||||
spyOn(component.tabShow, "emit").and.returnValue(null);
|
||||
spyOn(component.tabHide, "emit").and.returnValue(null);
|
||||
component.tabId = "description";
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing if active tab ID is undefined", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(null);
|
||||
|
||||
expect(<Spy>component.tabInit.emit).not.toHaveBeenCalled();
|
||||
expect(<Spy>component.tabShow.emit).not.toHaveBeenCalled();
|
||||
expect(<Spy>component.tabHide.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab init if it is new active tab", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(component.tabId);
|
||||
|
||||
expect(<Spy>component.tabInit.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab show if it is new active tab", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(component.tabId);
|
||||
|
||||
expect(<Spy>component.tabShow.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab hide if active tab changes to different tab", () => {
|
||||
const newTabId: string = component.tabId.split('').reverse().join('');
|
||||
component.ngOnInit();
|
||||
// Call twice, first time to set 'isActive' to true
|
||||
panelMock.Object.activeTab.next(component.tabId);
|
||||
panelMock.Object.activeTab.next(newTabId);
|
||||
|
||||
expect(<Spy>component.tabHide.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit output event for tab hide if was not previously active tab", () => {
|
||||
const newTabId: string = component.tabId.split('').reverse().join('');
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(newTabId);
|
||||
|
||||
expect(<Spy>component.tabHide.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds self as tab to panel", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.addTab).calls.argsFor(0)[0]).toBe(component);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
import { Component, Input, Output, Inject, EventEmitter, Host, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import 'rxjs/add/operator/filter';
|
||||
|
||||
|
||||
/**
|
||||
* A component that creates a single tab under a cor-tabs component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
}
|
||||
})
|
||||
export class CorTabComponent implements OnInit {
|
||||
@Input('@') public tabId: string;
|
||||
@Input('@') public tabTitle: string;
|
||||
@Input('<') public tabActive: boolean = false;
|
||||
|
||||
@Output() public tabInit: EventEmitter<any> = new EventEmitter();
|
||||
@Output() public tabShow: EventEmitter<any> = new EventEmitter();
|
||||
@Output() public tabHide: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
private isActive: boolean = false;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent) {
|
||||
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.isActive = this.tabActive;
|
||||
|
||||
this.panel.activeTab
|
||||
.filter(tabId => tabId != undefined)
|
||||
.subscribe((tabId: string) => {
|
||||
if (!this.isActive && this.tabId === tabId) {
|
||||
this.isActive = true;
|
||||
this.tabInit.emit({});
|
||||
this.tabShow.emit({});
|
||||
} else if (this.isActive && this.tabId !== tabId) {
|
||||
this.isActive = false;
|
||||
this.tabHide.emit({});
|
||||
}
|
||||
});
|
||||
|
||||
this.panel.addTab(this);
|
||||
}
|
||||
|
||||
private tabClicked(event: MouseEvent): void {
|
||||
if (!this.panel.basePath) {
|
||||
event.preventDefault();
|
||||
this.panel.activeTab.next(this.tabId);
|
||||
}
|
||||
}
|
||||
}
|
4
static/js/directives/ui/cor-tabs/cor-tabs.component.html
Normal file
4
static/js/directives/ui/cor-tabs/cor-tabs.component.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
<span class="co-tab-element" ng-class="$ctrl.isClosed ? 'closed' : 'open'">
|
||||
<span class="xs-toggle" ng-click="$ctrl.toggleClosed($event)"></span>
|
||||
<ul ng-class="$ctrl.parent.isVertical() ? 'co-tabs col-md-1' : 'co-top-tab-bar'" ng-transclude></ul>
|
||||
</span>
|
26
static/js/directives/ui/cor-tabs/cor-tabs.component.ts
Normal file
26
static/js/directives/ui/cor-tabs/cor-tabs.component.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { Component, Input, Output, Inject, EventEmitter, Host } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from './cor-tab-panel/cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* A component that holds the actual tabs.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tabs',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tabs.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
}
|
||||
})
|
||||
export class CorTabsComponent {
|
||||
|
||||
private isClosed: boolean = true;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private parent: CorTabPanelComponent) {
|
||||
|
||||
}
|
||||
|
||||
private toggleClosed(e): void {
|
||||
this.isClosed = !this.isClosed;
|
||||
}
|
||||
}
|
33
static/js/directives/ui/cor-tabs/cor-tabs.module.ts
Normal file
33
static/js/directives/ui/cor-tabs/cor-tabs.module.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { NgModule } from 'ng-metadata/core';
|
||||
import { CorTabsComponent } from './cor-tabs.component';
|
||||
import { CorTabComponent } from './cor-tab/cor-tab.component';
|
||||
import { CorNavTabsDirective } from './cor-nav-tabs/cor-nav-tabs.directive';
|
||||
import { CorTabContentComponent } from './cor-tab-content/cor-tab-content.component';
|
||||
import { CorTabPaneComponent } from './cor-tab-pane/cor-tab-pane.component';
|
||||
import { CorTabPanelComponent } from './cor-tab-panel/cor-tab-panel.component';
|
||||
import { CorCookieTabsDirective } from './cor-cookie-tabs/cor-cookie-tabs.directive';
|
||||
|
||||
|
||||
/**
|
||||
* Module containing everything needed for cor-tabs.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
||||
],
|
||||
declarations: [
|
||||
CorNavTabsDirective,
|
||||
CorTabComponent,
|
||||
CorTabContentComponent,
|
||||
CorTabPaneComponent,
|
||||
CorTabPanelComponent,
|
||||
CorTabsComponent,
|
||||
CorCookieTabsDirective,
|
||||
],
|
||||
providers: [
|
||||
|
||||
]
|
||||
})
|
||||
export class CorTabsModule {
|
||||
|
||||
}
|
13
static/js/directives/ui/cor-tabs/cor-tabs.view-object.ts
Normal file
13
static/js/directives/ui/cor-tabs/cor-tabs.view-object.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { element, by, browser, $, ElementFinder, ExpectedConditions as until } from 'protractor';
|
||||
|
||||
|
||||
export class CorTabsViewObject {
|
||||
|
||||
public selectTabByTitle(title: string) {
|
||||
return $(`cor-tab[tab-title="${title}"] a`).click();
|
||||
}
|
||||
|
||||
public isActiveTab(title: string) {
|
||||
return $(`cor-tab[tab-title="${title}"] .cor-tab-itself.active`).isPresent();
|
||||
}
|
||||
}
|
119
static/js/directives/ui/create-entity-dialog.js
Normal file
119
static/js/directives/ui/create-entity-dialog.js
Normal file
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* An element which displays a create entity dialog.
|
||||
*/
|
||||
angular.module('quay').directive('createEntityDialog', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/create-entity-dialog.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'info': '=info',
|
||||
|
||||
'entityKind': '@entityKind',
|
||||
'entityTitle': '@entityTitle',
|
||||
'entityIcon': '@entityIcon',
|
||||
'entityNameRegex': '@entityNameRegex',
|
||||
'allowEntityDescription': '@allowEntityDescription',
|
||||
|
||||
'entityCreateRequested': '&entityCreateRequested',
|
||||
'entityCreateCompleted': '&entityCreateCompleted'
|
||||
},
|
||||
|
||||
controller: function($scope, $element, ApiService, UIService, UserService) {
|
||||
$scope.context = {
|
||||
'setPermissionsCounter': 0
|
||||
};
|
||||
|
||||
$scope.$on('$destroy', function() {
|
||||
if ($scope.inBody) {
|
||||
document.body.removeChild($element[0]);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.hide = function() {
|
||||
$element.find('.modal').modal('hide');
|
||||
if ($scope.entity) {
|
||||
$scope.entityCreateCompleted({'entity': $scope.entity});
|
||||
$scope.entity = null;
|
||||
}
|
||||
};
|
||||
|
||||
$scope.show = function() {
|
||||
$scope.entityName = null;
|
||||
$scope.entityDescription = null;
|
||||
$scope.entity = null;
|
||||
$scope.entityForPermissions = null;
|
||||
$scope.creating = false;
|
||||
$scope.view = 'enterName';
|
||||
$scope.enterNameForm.$setPristine(true);
|
||||
|
||||
// Move the dialog to the body to prevent it from nesting if called
|
||||
// from within another dialog.
|
||||
$element.find('.modal').modal({});
|
||||
$scope.inBody = true;
|
||||
document.body.appendChild($element[0]);
|
||||
};
|
||||
|
||||
var entityCreateCallback = function(entity) {
|
||||
$scope.entity = entity;
|
||||
|
||||
if (!entity || $scope.info.skip_permissions) {
|
||||
$scope.hide();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
$scope.createEntity = function() {
|
||||
$scope.view = 'creating';
|
||||
$scope.entityCreateRequested({
|
||||
'name': $scope.entityName,
|
||||
'description': $scope.entityDescription,
|
||||
'callback': entityCreateCallback
|
||||
});
|
||||
};
|
||||
|
||||
$scope.permissionsSet = function(repositories) {
|
||||
$scope.entity['repo_count'] = repositories.length;
|
||||
$scope.hide();
|
||||
};
|
||||
|
||||
$scope.settingPermissions = function() {
|
||||
$scope.view = 'settingperms';
|
||||
};
|
||||
|
||||
$scope.setPermissions = function() {
|
||||
$scope.context.setPermissionsCounter++;
|
||||
};
|
||||
|
||||
$scope.repositoriesLoaded = function(repositories) {
|
||||
if (repositories && !repositories.length) {
|
||||
$scope.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.view = 'setperms';
|
||||
};
|
||||
|
||||
$scope.$watch('entityNameRegex', function(r) {
|
||||
if (r) {
|
||||
$scope.entityNameRegexObj = new RegExp(r);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('info', function(info) {
|
||||
if (!info || !info.namespace) {
|
||||
$scope.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.namespace = UserService.getNamespace(info.namespace);
|
||||
if ($scope.namespace) {
|
||||
$scope.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
170
static/js/directives/ui/create-external-notification.js
Normal file
170
static/js/directives/ui/create-external-notification.js
Normal file
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* An element which displays a form to register a new external notification on a repository.
|
||||
*/
|
||||
angular.module('quay').directive('createExternalNotification', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/create-external-notification.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'notificationCreated': '¬ificationCreated',
|
||||
'defaultData': '=defaultData'
|
||||
},
|
||||
controller: function($scope, $element, ExternalNotificationData, ApiService, $timeout, StringBuilderService) {
|
||||
$scope.currentEvent = null;
|
||||
$scope.currentMethod = null;
|
||||
$scope.status = '';
|
||||
$scope.currentConfig = {};
|
||||
$scope.currentEventConfig = {};
|
||||
$scope.clearCounter = 0;
|
||||
$scope.unauthorizedEmail = false;
|
||||
|
||||
$scope.events = ExternalNotificationData.getSupportedEvents();
|
||||
$scope.methods = ExternalNotificationData.getSupportedMethods();
|
||||
|
||||
$scope.getPattern = function(field) {
|
||||
if (field._cached_regex) {
|
||||
return field._cached_regex;
|
||||
}
|
||||
|
||||
field._cached_regex = new RegExp(field.pattern);
|
||||
return field._cached_regex;
|
||||
};
|
||||
|
||||
$scope.setEvent = function(event) {
|
||||
$scope.currentEvent = event;
|
||||
$scope.currentEventConfig = {};
|
||||
};
|
||||
|
||||
$scope.setMethod = function(method) {
|
||||
$scope.currentConfig = {};
|
||||
$scope.currentMethod = method;
|
||||
$scope.unauthorizedEmail = false;
|
||||
};
|
||||
|
||||
$scope.hasRegexMismatch = function(err, fieldName) {
|
||||
if (!err.pattern) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < err.pattern.length; ++i) {
|
||||
var current = err.pattern[i];
|
||||
var value = current.$viewValue;
|
||||
var elem = $element.find('#' + fieldName);
|
||||
if (value == elem[0].value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
$scope.createNotification = function() {
|
||||
if (!$scope.currentConfig.email) {
|
||||
$scope.performCreateNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.status = 'checking-email';
|
||||
$scope.checkEmailAuthorization();
|
||||
};
|
||||
|
||||
$scope.checkEmailAuthorization = function() {
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'email': $scope.currentConfig.email
|
||||
};
|
||||
|
||||
ApiService.checkRepoEmailAuthorized(null, params).then(function(resp) {
|
||||
$scope.handleEmailCheck(resp.confirmed);
|
||||
}, function(resp) {
|
||||
$scope.handleEmailCheck(false);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.performCreateNotification = function() {
|
||||
$scope.status = 'creating';
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
||||
};
|
||||
|
||||
var data = {
|
||||
'event': $scope.currentEvent.id,
|
||||
'method': $scope.currentMethod.id,
|
||||
'config': $scope.currentConfig,
|
||||
'eventConfig': $scope.currentEventConfig,
|
||||
'title': $scope.currentTitle
|
||||
};
|
||||
|
||||
ApiService.createRepoNotification(data, params).then(function(resp) {
|
||||
$scope.status = '';
|
||||
$scope.notificationCreated({'notification': resp});
|
||||
});
|
||||
};
|
||||
|
||||
$scope.handleEmailCheck = function(isAuthorized) {
|
||||
if (isAuthorized) {
|
||||
$scope.performCreateNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.status == 'authorizing-email-sent') {
|
||||
$scope.watchEmail();
|
||||
} else {
|
||||
$scope.status = 'unauthorized-email';
|
||||
}
|
||||
|
||||
$scope.unauthorizedEmail = true;
|
||||
$('#authorizeEmailModal').modal({});
|
||||
};
|
||||
|
||||
$scope.sendAuthEmail = function() {
|
||||
$scope.status = 'authorizing-email';
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'email': $scope.currentConfig.email
|
||||
};
|
||||
|
||||
ApiService.sendAuthorizeRepoEmail(null, params).then(function(resp) {
|
||||
$scope.status = 'authorizing-email-sent';
|
||||
$scope.watchEmail();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.watchEmail = function() {
|
||||
// TODO: change this to SSE?
|
||||
$timeout(function() {
|
||||
$scope.checkEmailAuthorization();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
$scope.cancelEmailAuth = function() {
|
||||
$scope.status = '';
|
||||
$('#authorizeEmailModal').modal('hide');
|
||||
};
|
||||
|
||||
$scope.getHelpUrl = function(field, config) {
|
||||
var helpUrl = field['help_url'];
|
||||
if (!helpUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return StringBuilderService.buildUrl(helpUrl, config);
|
||||
};
|
||||
|
||||
$scope.$watch('defaultData', function(counter) {
|
||||
if ($scope.defaultData && $scope.defaultData['currentEvent']) {
|
||||
$scope.setEvent($scope.defaultData['currentEvent']);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
47
static/js/directives/ui/create-robot-dialog.js
Normal file
47
static/js/directives/ui/create-robot-dialog.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* An element which displays a dialog for creating a robot account.
|
||||
*/
|
||||
angular.module('quay').directive('createRobotDialog', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/create-robot-dialog.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'info': '=info',
|
||||
'robotCreated': '&robotCreated'
|
||||
},
|
||||
controller: function($scope, $element, ApiService, UserService, NAME_PATTERNS) {
|
||||
$scope.ROBOT_PATTERN = NAME_PATTERNS.ROBOT_PATTERN;
|
||||
|
||||
$scope.robotFinished = function(robot) {
|
||||
$scope.robotCreated({'robot': robot});
|
||||
};
|
||||
|
||||
$scope.createRobot = function(name, description, callback) {
|
||||
var organization = $scope.info.namespace;
|
||||
if (!UserService.isOrganization(organization)) {
|
||||
organization = null;
|
||||
}
|
||||
|
||||
var params = {
|
||||
'robot_shortname': name
|
||||
};
|
||||
|
||||
var data = {
|
||||
'description': description || ''
|
||||
};
|
||||
|
||||
var errorDisplay = ApiService.errorDisplay('Cannot create robot account', function() {
|
||||
callback(null);
|
||||
});
|
||||
|
||||
ApiService.createRobot(organization, data, params).then(function(resp) {
|
||||
callback(resp);
|
||||
}, errorDisplay);
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
49
static/js/directives/ui/create-team-dialog.js
Normal file
49
static/js/directives/ui/create-team-dialog.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* An element which displays a dialog for creating a team.
|
||||
*/
|
||||
angular.module('quay').directive('createTeamDialog', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/create-team-dialog.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'info': '=info',
|
||||
'teamCreated': '&teamCreated'
|
||||
},
|
||||
controller: function($scope, $element, ApiService, UserService, NAME_PATTERNS) {
|
||||
$scope.TEAM_PATTERN = NAME_PATTERNS.TEAM_PATTERN;
|
||||
|
||||
$scope.teamFinished = function(team) {
|
||||
$scope.teamCreated({'team': team});
|
||||
};
|
||||
|
||||
$scope.createTeam = function(name, callback) {
|
||||
var data = {
|
||||
'name': name,
|
||||
'role': 'member'
|
||||
};
|
||||
|
||||
var params = {
|
||||
'orgname': $scope.info.namespace,
|
||||
'teamname': name
|
||||
};
|
||||
|
||||
var errorDisplay = ApiService.errorDisplay('Cannot create team', function() {
|
||||
callback(null);
|
||||
});
|
||||
|
||||
ApiService.updateOrganizationTeam(data, params).then(function(resp) {
|
||||
if (!resp.new_team) {
|
||||
callback(null);
|
||||
bootbox.alert('Team with name "' + resp.name + '" already exists')
|
||||
return;
|
||||
}
|
||||
callback(resp);
|
||||
}, errorDisplay);
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
218
static/js/directives/ui/credentials-dialog.js
Normal file
218
static/js/directives/ui/credentials-dialog.js
Normal file
|
@ -0,0 +1,218 @@
|
|||
/**
|
||||
* An element which displays a credentials dialog.
|
||||
*/
|
||||
angular.module('quay').directive('credentialsDialog', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/credentials-dialog.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'credentials': '=credentials',
|
||||
'secretTitle': '@secretTitle',
|
||||
'entityTitle': '@entityTitle',
|
||||
'entityIcon': '@entityIcon'
|
||||
},
|
||||
|
||||
controller: function($scope, $element, $rootScope, Config) {
|
||||
$scope.Config = Config;
|
||||
|
||||
$scope.k8s = {};
|
||||
$scope.rkt = {};
|
||||
$scope.docker = {};
|
||||
|
||||
$scope.$on('$destroy', function() {
|
||||
if ($scope.inBody) {
|
||||
document.body.removeChild($element[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate a unique ID for the dialog.
|
||||
if (!$rootScope.credentialsDialogCounter) {
|
||||
$rootScope.credentialsDialogCounter = 0;
|
||||
}
|
||||
|
||||
$rootScope.credentialsDialogCounter++;
|
||||
|
||||
$scope.hide = function() {
|
||||
$element.find('.modal').modal('hide');
|
||||
};
|
||||
|
||||
$scope.show = function() {
|
||||
$element.find('.modal').modal({});
|
||||
|
||||
// Move the dialog to the body to prevent it from being affected
|
||||
// by being placed inside other tables.
|
||||
$scope.inBody = true;
|
||||
document.body.appendChild($element[0]);
|
||||
};
|
||||
|
||||
$scope.$watch('credentials', function(credentials) {
|
||||
if (!credentials) {
|
||||
$scope.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.show();
|
||||
});
|
||||
|
||||
$scope.downloadFile = function(info) {
|
||||
var blob = new Blob([info.contents]);
|
||||
FileSaver.saveAs(blob, info.filename);
|
||||
};
|
||||
|
||||
$scope.viewFile = function(context) {
|
||||
context.viewingFile = true;
|
||||
};
|
||||
|
||||
$scope.isDownloadSupported = function() {
|
||||
var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
|
||||
if (isSafari) {
|
||||
// Doesn't work properly in Safari, sadly.
|
||||
return false;
|
||||
}
|
||||
|
||||
try { return !!new Blob(); } catch(e) {}
|
||||
return false;
|
||||
};
|
||||
|
||||
$scope.getNamespace = function(credentials) {
|
||||
if (!credentials || !credentials.username) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (credentials.namespace) {
|
||||
return credentials.namespace;
|
||||
}
|
||||
|
||||
return credentials.username.split('+')[0];
|
||||
};
|
||||
|
||||
$scope.getMesosFilename = function(credentials) {
|
||||
return $scope.getSuffixedFilename(credentials, 'auth.tar.gz');
|
||||
};
|
||||
|
||||
$scope.getMesosFile = function(credentials) {
|
||||
var tarFile = new Tar();
|
||||
tarFile.append('.docker/config.json', $scope.getDockerConfig(credentials), {});
|
||||
contents = (new Zlib.Gzip(tarFile.getData())).compress();
|
||||
return {
|
||||
'filename': $scope.getMesosFilename(credentials),
|
||||
'contents': contents
|
||||
}
|
||||
};
|
||||
|
||||
$scope.getDockerConfig = function(credentials) {
|
||||
var auths = {};
|
||||
auths[Config['SERVER_HOSTNAME']] = {
|
||||
'auth': $.base64.encode(credentials.username + ":" + credentials.password),
|
||||
'email': ''
|
||||
};
|
||||
|
||||
var config = {
|
||||
'auths': auths
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, ' ');
|
||||
};
|
||||
|
||||
$scope.getDockerFile = function(credentials) {
|
||||
return {
|
||||
'filename': $scope.getRktFilename(credentials),
|
||||
'contents': $scope.getDockerConfig(credentials)
|
||||
}
|
||||
};
|
||||
|
||||
$scope.getDockerLogin = function(credentials) {
|
||||
if (!credentials || !credentials.username) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var escape = function(v) {
|
||||
if (!v) { return v; }
|
||||
return v.replace('$', '\\$');
|
||||
};
|
||||
|
||||
return 'docker login -u="' + escape(credentials.username) + '" -p="' + credentials.password + '" ' + Config['SERVER_HOSTNAME'];
|
||||
};
|
||||
|
||||
$scope.getDockerFilename = function(credentials) {
|
||||
return $scope.getSuffixedFilename(credentials, 'auth.json')
|
||||
};
|
||||
|
||||
$scope.getRktFile = function(credentials) {
|
||||
var config = {
|
||||
'rktKind': 'auth',
|
||||
'rktVersion': 'v1',
|
||||
'domains': [Config['SERVER_HOSTNAME']],
|
||||
'type': 'basic',
|
||||
'credentials': {
|
||||
'user': credentials['username'],
|
||||
'password': credentials['password']
|
||||
}
|
||||
};
|
||||
|
||||
var contents = JSON.stringify(config, null, ' ');
|
||||
return {
|
||||
'filename': $scope.getRktFilename(credentials),
|
||||
'contents': contents
|
||||
}
|
||||
};
|
||||
|
||||
$scope.getRktFilename = function(credentials) {
|
||||
return $scope.getSuffixedFilename(credentials, 'auth.json')
|
||||
};
|
||||
|
||||
$scope.getKubernetesSecretName = function(credentials) {
|
||||
if (!credentials || !credentials.username) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $scope.getSuffixedFilename(credentials, 'pull-secret');
|
||||
};
|
||||
|
||||
$scope.getKubernetesFile = function(credentials) {
|
||||
var dockerConfigJson = $scope.getDockerConfig(credentials);
|
||||
var contents = 'apiVersion: v1\n' +
|
||||
'kind: Secret\n' +
|
||||
'metadata:\n' +
|
||||
' name: ' + $scope.getKubernetesSecretName(credentials) + '\n' +
|
||||
'data:\n' +
|
||||
' .dockerconfigjson: ' + $.base64.encode(dockerConfigJson) + '\n' +
|
||||
'type: kubernetes.io/dockerconfigjson'
|
||||
|
||||
return {
|
||||
'filename': $scope.getKubernetesFilename(credentials),
|
||||
'contents': contents
|
||||
}
|
||||
};
|
||||
|
||||
$scope.getKubernetesFilename = function(credentials) {
|
||||
return $scope.getSuffixedFilename(credentials, 'secret.yml')
|
||||
};
|
||||
|
||||
$scope.getEscaped = function(item) {
|
||||
var escaped = item.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
if (escaped[0] == '-') {
|
||||
escaped = escaped.substr(1);
|
||||
}
|
||||
return escaped;
|
||||
};
|
||||
|
||||
$scope.getSuffixedFilename = function(credentials, suffix) {
|
||||
if (!credentials || !credentials.username) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var prefix = $scope.getEscaped(credentials.username);
|
||||
if (credentials.title) {
|
||||
prefix = $scope.getEscaped(credentials.title);
|
||||
}
|
||||
|
||||
return prefix + '-' + suffix;
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
18
static/js/directives/ui/credentials.js
Normal file
18
static/js/directives/ui/credentials.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* An element which displays a credentials for a build trigger.
|
||||
*/
|
||||
angular.module('quay').directive('credentials', function() {
|
||||
var directiveDefinitionObject = {
|
||||
templateUrl: '/static/directives/credentials.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'trigger': '=trigger'
|
||||
},
|
||||
controller: function($scope, TriggerService) {
|
||||
TriggerService.populateTemplate($scope, 'credentials');
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
59
static/js/directives/ui/datetime-picker.js
Normal file
59
static/js/directives/ui/datetime-picker.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* An element which displays a datetime picker.
|
||||
*/
|
||||
angular.module('quay').directive('datetimePicker', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/datetime-picker.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'datetime': '=datetime',
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
var datetimeSet = false;
|
||||
|
||||
$(function() {
|
||||
$element.find('input').datetimepicker({
|
||||
'format': 'LLL',
|
||||
'sideBySide': true,
|
||||
'showClear': true,
|
||||
'minDate': new Date(),
|
||||
'debug': false
|
||||
});
|
||||
|
||||
$element.find('input').on("dp.change", function (e) {
|
||||
$scope.$apply(function() {
|
||||
$scope.datetime = e.date ? e.date.unix() : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$scope.$watch('selected_datetime', function(value) {
|
||||
if (!datetimeSet) { return; }
|
||||
|
||||
if (!value) {
|
||||
if ($scope.datetime) {
|
||||
$scope.datetime = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.datetime = (new Date(value)).getTime()/1000;
|
||||
});
|
||||
|
||||
$scope.$watch('datetime', function(value) {
|
||||
if (!value) {
|
||||
$scope.selected_datetime = null;
|
||||
datetimeSet = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.selected_datetime = moment.unix(value).format('LLL');
|
||||
datetimeSet = true;
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue