initial import for Open Source 🎉

This commit is contained in:
Jimmy Zelinskie 2019-11-12 11:09:47 -05:00
parent 1898c361f3
commit 9c0dd3b722
2048 changed files with 218743 additions and 0 deletions

View 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);
});
}
};
});

View 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});
});
});
}
}
}]);

View 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
}
});

View 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];
}
});

View 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');
}
}
});

View 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';
}
});

View 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;
};
});

View file

@ -0,0 +1,8 @@
/**
* Reversing filter.
*/
angular.module('quay').filter('reverse', function() {
return function(items) {
return items.slice().reverse();
};
});

View file

@ -0,0 +1,8 @@
/**
* Slice filter.
*/
angular.module('quay').filter('slice', function() {
return function(arr, start, end) {
return (arr || []).slice(start, end);
};
});

View 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;
};
});

View 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);
}
};
}]);

View 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);
});
}
};
});

View file

@ -0,0 +1,7 @@
angular.module('quay').directive('ngBlur', function() {
return function( scope, elem, attrs ) {
elem.bind('blur', function() {
scope.$apply(attrs.ngBlur);
});
};
});

View 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;
})
};
});

View 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});
})
});
}
}
};
});

View 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);
});
};
});

View 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();
});
});
}
};
});

View 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');
});
};
});

View 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;
};
});

View 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);
});
};
});

View 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)
});
}
};
});

View 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;
});
}
};
});

View 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();
}
};
}
]);

View 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;
});

View 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;
});

View 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;
});

View 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 '[![Docker Repository on ' + Config.REGISTRY_TITLE_SHORT + '](' + imageUrl +
' "Docker Repository on ' + Config.REGISTRY_TITLE_SHORT + '")](' + 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;
});

View 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;
});

View file

@ -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);
});
});
});

View file

@ -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
]);
}
}

View 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;
});

View file

@ -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>

View file

@ -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();
}
}

View file

@ -0,0 +1 @@
<channel-icon name="item.name"></channel-icon><span style="vertical-align: middle; margin-left: 6px;">{{ item.name }}</span>

View 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>

View file

@ -0,0 +1 @@
<time-ago datetime="item.last_modified"></time-ago>

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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>

View file

@ -0,0 +1 @@
<time-ago datetime="item.created"></time-ago>

View file

@ -0,0 +1 @@
<expiration-status-view expiration-date="item.expiration"></expiration-status-view>

View file

@ -0,0 +1 @@
<time-ago datetime="item.last_accessed"></time-ago>

View file

@ -0,0 +1 @@
<a ng-click="col.bindModel.showToken(item)">{{ item.title }}</a>

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View file

@ -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>

View file

@ -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];
}
}

View file

@ -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();
});
});
});

View file

@ -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();
}
}
}

View file

@ -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>

View file

@ -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);
});
});
});

View file

@ -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;
};

View 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;
});

View 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;
});

View 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;
}
}

View file

@ -0,0 +1,5 @@
.cor-table-element .co-top-bar {
display: flex;
justify-content: flex-end;
align-items: baseline;
}

View 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>

View 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();
});
});
});

View 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;
};

View file

@ -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);
});
});
});

View file

@ -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);
});
}
}

View file

@ -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);
});
});
});

View file

@ -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);
}
}

View file

@ -0,0 +1 @@
<div class="co-tab-content tab-content col-md-11" ng-transclude></div>

View file

@ -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 {
}

View file

@ -0,0 +1,3 @@
<div class="co-tab-pane" ng-show="$ctrl.isActiveTab">
<div ng-transclude />
</div>

View file

@ -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);
});
});
});

View file

@ -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);
});
}
}

View file

@ -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>

View file

@ -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);
});
});
});

View file

@ -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';
}
}

View file

@ -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>

View file

@ -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);
});
});
});

View file

@ -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);
}
}
}

View 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>

View 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;
}
}

View 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 {
}

View 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();
}
}

View 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;
});

View 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': '&notificationCreated',
'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;
});

View 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;
});

View 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;
});

View 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;
});

View 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;
});

View 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