Merge branch 'master' into no-signing-whitelist

This commit is contained in:
Evan Cordell 2017-07-12 15:50:32 -04:00 committed by GitHub
commit 45bf7efc84
434 changed files with 10877 additions and 11061 deletions

View file

@ -1,109 +0,0 @@
import * as React from "react";
import Build from "./build";
import Throbber from "./throbber";
interface IBody {
description: string;
api: Object;
repository: Object;
}
interface IBodyState {
currentBuild: any;
intervalId: number;
}
/**
* The Component for the main body of the repo page
* @param {string} description - The description of the repository
* @param {object} api - The ApiService injected from Angular
* @param {object} repository - The list of properties for the repository
*/
class body extends React.Component<IBody, IBodyState> {
static propTypes = {
description: React.PropTypes.string.isRequired,
api: React.PropTypes.object.isRequired,
repository: React.PropTypes.object.isRequired,
}
constructor(props){
super(props)
this.state = {
currentBuild: [],
intervalId: null
};
}
componentDidMount() {
let intervalId: number = window.setInterval(() => this.getBuilds(), 1000);
this.setState({
currentBuild: this.state.currentBuild,
intervalId: intervalId
});
}
comoponentDidUnmount() {
clearInterval(this.state.intervalId);
}
getBuilds() {
let api: any = this.props.api;
let repository: any = this.props.repository;
let params: Object = {
'repository': repository.namespace + '/' + repository.name,
'limit': 8
};
api.getRepoBuildsAsResource(params, true).get((data) => {
let builds: Array<Object> = [];
data.builds.forEach((element, i) => {
builds.push({
user: element.manual_user,
id: element.id,
display_name: element.display_name,
started: element.started,
tags: element.tags,
phase: element.phase,
trigger: element.trigger,
trigger_metadata: element.trigger_metadata
});
});
this.setState({
currentBuild: builds,
intervalId: this.state.intervalId
});
});
}
render () {
let description: string = this.props.description;
if (description === null || description === "") {
description = "No Description";
}
return(
<div>
<ul className="nav nav-tabs rp-tabs">
<li className="active">
<a data-target="#tab1" data-toggle="tab">Description</a>
</li>
<li>
<a data-target="#tab2" data-toggle="tab">Automated Builds</a>
</li>
</ul>
<div className="panel-body rp-panelBody">
<div className="tab-content">
<div className="tab-pane in active" id="tab1">
<div className="rp-description">{description}</div>
</div>
<div className="tab-pane" id="tab2">
<div className="panel-body">
<h3 className="tab-header">Repository Builds</h3>
<Build data={this.state.currentBuild}/>
</div>
</div>
</div>
</div>
</div>
);
}
}
export default body;

View file

@ -1,73 +0,0 @@
import * as React from 'react';
import * as moment from "moment";
import Throbber from "./throbber";
export default class Build extends React.Component<any, any> {
render () {
let builds: any = this.props.data;
let buildsTable: any = [];
let table: any;
if (Object.keys(builds).length === 0) {
buildsTable.push('Loading')
table = <Throbber />
}
else {
// Get Builds
builds.forEach((element, i) => {
let tags: Array<any> = []
element.tags.forEach(tag => {
tags.push(
<span className="building-tag">
<span className="tag-span rp-tagSpan">
<i className="fa fa-tag"></i> {tag}
</span>
</span>
);
});
let buildId: string = element.id.split('-')[0];
let phase: string = element.phase ? element.phase : 'Cannot retrieve phase';
let started: string = element.started ? element.started : 'Cannot retrieve start date';
let message: string;
if (element.trigger_metadata && element.trigger_metadata.commit_info && element.trigger_metadata.commit_info.message){
message = element.trigger_metadata.commit_info.message;
}
else {
message = 'Cannot retrieve message';
}
buildsTable.push(
<tr key={buildId}>
<td>{phase}</td>
<td>{buildId}</td>
<td>{message}</td>
<td>{moment(started).format('l')}</td>
<td>{tags}</td>
</tr>
)
});
// Build the table
table = (
<table className="co-table">
<thead>
<tr>
<td></td>
<td>BUILD ID</td>
<td>TRIGGERED BY</td>
<td>DATE STARTED</td>
<td>TAGS</td>
</tr>
</thead>
<tbody>
{buildsTable}
</tbody>
</table>
);
}
return(
<div className="row">
{table}
</div>
);
}
}

View file

@ -1,43 +0,0 @@
import * as React from "react";
interface IHeader {
name: string;
namespace: string;
}
/**
* The Component for the header of the repo page
* @param {string} name - The name of the repository
* @param {string} namespace - The namespace of the repository
*/
class repoHeader extends React.Component<IHeader, {}> {
static propTypes = {
name: React.PropTypes.string.isRequired,
namespace: React.PropTypes.string.isRequired
}
render () {
return(
<div className="row rp-header__row">
<div className="rp-title">{this.props.namespace}/{this.props.name}</div>
<div className="rp-button">
<div className="dropdown">
<button className="btn rp-button__dropdown dropdown-toggle" type="button" data-toggle="dropdown">
<span className="rp-button__text">
Run with <span className="rp-button__text--bold">Docker</span>
</span>
<span className="caret"></span>
</button>
<ul className="dropdown-menu">
<li><a href="#">Squashed Docker Image</a></li>
<li><a href="#">Rocket Fetch</a></li>
<li><a href="#">Basic Docker Pull</a></li>
</ul>
</div>
</div>
</div>
);
}
}
export default repoHeader;

View file

@ -1,31 +0,0 @@
import "sass/repo-page/repo-page.scss";
import repoHeader from "./header";
import repoSidebar from "./sidebar";
import repoBody from "./body";
rpHeaderDirective.$inject = [
'reactDirective',
];
export function rpHeaderDirective(reactDirective) {
return reactDirective(repoHeader);
}
rpSidebarDirective.$inject = [
'reactDirective',
];
export function rpSidebarDirective(reactDirective) {
return reactDirective(repoSidebar);
}
rpBodyDirective.$inject = [
'reactDirective',
'ApiService',
];
export function rpBodyDirective(reactDirective, ApiService) {
return reactDirective(repoBody, undefined, {}, {api: ApiService});
}

View file

@ -1,107 +0,0 @@
import * as React from "react";
import * as moment from "moment";
interface tag {
image_id: string;
last_modified: string;
name: string;
size: number;
}
interface ISidebar {
isPublic: string;
tags: Array<tag>;
repository: Object
}
/**
* The Component for the sidebar of the repo page
* @param {string} isPublic - A string that states whether the repository is private or public
* @param {tag} tags - The list of tags for the repository
* @param {object} repository - The list of properties for the repository
*/
class repoSidebar extends React.Component<ISidebar, {}> {
static propTypes = {
isPublic: React.PropTypes.string.isRequired,
tags: React.PropTypes.array.isRequired,
repository: React.PropTypes.object.isRequired
}
render () {
let isPublic: string = (this.props.isPublic) ? "Public" : "Private";
let sortedTags: Array<any> = [];
let tagRows: Array<any> = [];
let badgeIcon: string = (this.props.isPublic) ? "rp-badge__icon--public" : "rp-badge__icon--private";
let repository: any = this.props.repository;
let sharing: string = repository.company || repository.namespace;
if (Object.keys(this.props.tags).length > 0) {
for (let tagObject in this.props.tags) {
sortedTags.push({
name: this.props.tags[tagObject].name,
lastModified: Date.parse(this.props.tags[tagObject].last_modified)
});
}
sortedTags = sortedTags.sort(function(a, b) {
return b.lastModified - a.lastModified;
});
sortedTags.slice(0,5).forEach(function(el, i){
tagRows.push(
<tr>
<td>
<i className="fa fa-tag rp-imagesTable__tagIcon" aria-hidden="true"></i>
{el.name}
</td>
<td>
{moment(el.lastModified).fromNow()}
</td>
</tr>
);
});
}
else {
tagRows.push(
<tr>
<td>
No Tags Available
</td>
<td>
</td>
</tr>
)
}
return(
<div>
<div className="rp-badge">
<div className={badgeIcon}>
{isPublic}
</div>
</div>
<div className="rp-sharing">
{sharing} is sharing this container {this.props.isPublic ? "publically" : "privately"}
</div>
<div className="rp-imagesHeader">
Latest Images
</div>
<div>
<table className="co-table co-fixed-table rp-imagesTable">
<thead>
<tr>
<th className="rp-imagesTable__headerCell">NAME</th>
<th className="rp-imagesTable__headerCell">LAST MODIFIED</th>
</tr>
</thead>
<tbody>
{tagRows}
</tbody>
</table>
</div>
</div>
);
}
}
export default repoSidebar;

View file

@ -1,15 +0,0 @@
import * as React from 'react';
import * as moment from "moment";
export default class Throbber extends React.Component<any, any> {
render () {
return(
<div className="co-m-loader co-an-fade-in-out rp-throbber">
<div className="co-m-loader-dot__one"></div>
<div className="co-m-loader-dot__two"></div>
<div className="co-m-loader-dot__three"></div>
</div>
);
}
}

View file

@ -50,4 +50,3 @@ angular.module('quay').directive('repoPanelInfo', function () {
};
return directiveDefinitionObject;
});

View file

@ -39,7 +39,7 @@ angular.module('quay').directive('repoPanelTags', function () {
$scope.labelCache = {};
$scope.imageVulnerabilities = {};
$scope.repoSignatureInfo = null;
$scope.repoDelegationsInfo = null;
$scope.defcon1 = {};
$scope.hasDefcon1 = false;
@ -50,16 +50,16 @@ angular.module('quay').directive('repoPanelTags', function () {
}
$scope.repoSignatureError = false;
$scope.repoSignatureInfo = null;
$scope.repoDelegationsInfo = null;
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name
};
ApiService.getRepoSignatures(null, params).then(function(resp) {
$scope.repoSignatureInfo = resp;
$scope.repoDelegationsInfo = resp;
}, function() {
$scope.repoSignatureInfo = {'error': true};
$scope.repoDelegationsInfo = {'error': true};
});
};

View file

@ -38,4 +38,4 @@ export class QuayRequireDirective implements AfterContentInit {
this.$transclude
]);
}
}
}

View file

@ -2,7 +2,7 @@
<div class="co-main-content-panel">
<div class="app-row">
<!-- Main panel -->
<div class="col-md-9 main-content">
<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>
@ -10,89 +10,101 @@
</div>
<!-- Tabs -->
<ul class="co-top-tab-bar">
<li class="co-top-tab" ng-class="$ctrl.currentTab == 'description' ? 'active': ''" ng-click="$ctrl.showTab('description')">
Description
</li>
<li class="co-top-tab" ng-class="$ctrl.currentTab == 'channels' ? 'active': ''" ng-click="$ctrl.showTab('channels')">
Channels
</li>
<li class="co-top-tab" ng-class="$ctrl.currentTab == 'releases' ? 'active': ''" ng-click="$ctrl.showTab('releases')">
Releases
</li>
<li class="co-top-tab" ng-class="$ctrl.currentTab == 'settings' ? 'active': ''" ng-click="$ctrl.showTab('settings')"
ng-if="$ctrl.repository.can_admin">
Settings
</li>
</ul>
<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>
<div class="tab-content">
<div ng-show="$ctrl.currentTab == 'description'">
<div class="description markdown-input"
content="$ctrl.repository.description"
can-write="$ctrl.repository.can_write"
content-changed="$ctrl.updateDescription"
field-title="'application description'">
</div>
</div>
<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>
<div ng-show="$ctrl.currentTab == 'channels'">
<div ng-show="!$ctrl.repository.channels.length && $ctrl.repository.can_write">
<h3>No channels found for this application</h3>
<br>
<p>
To push a new channel (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">
helm registry push --namespace {{ $ctrl.repository.namespace }} --channel {channelName} {{ $ctrl.Config.SERVER_HOSTNAME }}
</pre>
</p>
</div>
<!-- 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>
</div> <!-- /channels -->
<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>
<div ng-show="$ctrl.currentTab == 'releases'">
<div ng-show="!$ctrl.repository.releases.length && $ctrl.repository.can_write">
<h3>No releases found for this application</h3>
<br>
<p>
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">
helm registry push --namespace {{ $ctrl.repository.namespace }} {{ $ctrl.Config.SERVER_HOSTNAME }}
</pre>
</p>
</div>
<!-- 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']">
<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"
templateurl="/static/js/directives/ui/app-public-view/channels-list.html"></cor-table-col>
</cor-table>
</div>
</div> <!-- /releases -->
<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>
<div ng-show="$ctrl.currentTab == 'settings'" ng-if="$ctrl.repository.can_admin">
<div class="repo-panel-settings" repository="$ctrl.repository" is-enabled="$ctrl.settingsShown"></div>
</div>
</div>
<!-- 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 side-bar">
<div class="col-md-3 hidden-xs hidden-sm side-bar">
<div>
<visibility-indicator repository="$ctrl.repository"></visibility-indicator>
</div>
@ -121,4 +133,4 @@ helm registry push --namespace {{ $ctrl.repository.namespace }} {{ $ctrl.Config.
</div>
</div>
</div>
</div>
</div>

View file

@ -9,23 +9,26 @@ import { Input, Component, Inject } from 'ng-metadata/core';
templateUrl: '/static/js/directives/ui/app-public-view/app-public-view.component.html'
})
export class AppPublicViewComponent {
@Input('<') public repository: any;
private currentTab: string = 'description';
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();
}
public showTab(tab: string): void {
this.currentTab = tab;
if (tab == 'settings') {
this.settingsShown++;
}
}
}

View file

@ -1,4 +1,18 @@
<span ng-repeat="channel_name in item.channels">
<channel-icon name="channel_name"></channel-icon>
</span>
<span ng-if="!item.channels.length" class="empty">(None)</span>
<div style="display: flex; align-items: center;">
<div style="display: flex; flex-wrap: wrap; width: 70%;">
<!-- TODO(alecmerdler): Move repeat logic to separate component -->
<span 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

@ -23,10 +23,6 @@ angular.module('quay').directive('buildLogsView', function () {
repoStatusApiCall = ApiService.getRepoBuildStatusSuperUser;
repoLogApiCall = ApiService.getRepoBuildLogsSuperUserAsResource;
}
var result = $element.find('#copyButton').clipboardCopy();
if (!result) {
$element.find('#copyButton').hide();
}
$scope.logEntries = null;
$scope.currentParentEntry = null;

View file

@ -44,4 +44,4 @@ export class ChannelIconComponent {
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

@ -1,4 +1,4 @@
import { ContextPathSelectComponent } from './context-path-select.component';
import { ContextPathSelectComponent, ContextChangeEvent } from './context-path-select.component';
describe("ContextPathSelectComponent", () => {
@ -57,23 +57,33 @@ describe("ContextPathSelectComponent", () => {
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 context: string;
var newContext: string;
beforeEach(() => {
context = '/conf';
newContext = '/conf';
});
it("sets current context to given context", () => {
component.setSelectedContext(context);
component.setSelectedContext(newContext);
expect(component.currentContext).toEqual(context);
expect(component.currentContext).toEqual(newContext);
});
it("sets valid context flag to true if given context is valid", () => {
component.setSelectedContext(context);
component.setSelectedContext(newContext);
expect(component.isValidContext).toBe(true);
});
@ -83,5 +93,15 @@ describe("ContextPathSelectComponent", () => {
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

@ -1,4 +1,4 @@
import { Input, Component, OnChanges, SimpleChanges } from 'ng-metadata/core';
import { Input, Component, OnChanges, SimpleChanges, Output, EventEmitter } from 'ng-metadata/core';
/**
@ -10,10 +10,10 @@ import { Input, Component, OnChanges, SimpleChanges } from 'ng-metadata/core';
})
export class ContextPathSelectComponent implements OnChanges {
// FIXME: Use one-way data binding
@Input('=') public currentContext: string = '';
@Input('=') public isValidContext: boolean;
@Input('=') public contexts: string[];
@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;
@ -25,12 +25,16 @@ export class ContextPathSelectComponent implements OnChanges {
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 {
@ -39,8 +43,17 @@ export class ContextPathSelectComponent implements OnChanges {
if (context.length > 0 && context[0] === '/') {
isValidContext = true;
this.isUnknownContext = true;
this.isUnknownContext = contexts.indexOf(context) != -1;
}
return isValidContext;
}
}
/**
* Build context changed event.
*/
export type ContextChangeEvent = {
contextDir: string;
isValid: boolean;
};

View file

@ -1,53 +1,5 @@
$.fn.clipboardCopy = function() {
if (__zeroClipboardSupported) {
(new ZeroClipboard($(this)));
return true;
}
this.hide();
return false;
};
// Initialize the clipboard system.
(function () {
__zeroClipboardSupported = true;
ZeroClipboard.config({
'swfPath': 'static/lib/ZeroClipboard.swf'
});
ZeroClipboard.on("error", function(e) {
__zeroClipboardSupported = false;
});
ZeroClipboard.on('aftercopy', function(e) {
var container = e.target.parentNode.parentNode.parentNode;
var message = $(container).find('.clipboard-copied-message')[0];
if (!message) {
return;
}
// Resets the animation.
var elem = message;
elem.style.display = 'none';
elem.classList.remove('animated');
// Show the notification.
setTimeout(function() {
elem.style.display = 'inline-block';
elem.classList.add('animated');
}, 10);
// Reset the notification.
setTimeout(function() {
elem.style.display = 'none';
}, 5000);
});
})();
/**
* An element which displays a textfield with a "Copy to Clipboard" icon next to it. Note
* that this method depends on the clipboard copying library in the lib/ folder.
* An element which displays a textfield with a "Copy to Clipboard" icon next to it.
*/
angular.module('quay').directive('copyBox', function () {
var directiveDefinitionObject = {
@ -66,13 +18,6 @@ angular.module('quay').directive('copyBox', function () {
var number = $rootScope.__copyBoxIdCounter || 0;
$rootScope.__copyBoxIdCounter = number + 1;
$scope.inputId = "copy-box-input-" + number;
var button = $($element).find('.copy-icon');
var input = $($element).find('input');
input.attr('id', $scope.inputId);
button.attr('data-clipboard-target', $scope.inputId);
$scope.disabled = !button.clipboardCopy();
}
};
return directiveDefinitionObject;

View file

@ -10,14 +10,20 @@ import { CorTableComponent } from './cor-table.component';
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) {
constructor(@Host() @Inject(CorTableComponent) private parent: CorTableComponent,
@Inject('TableService') private tableService: any) {
}
@ -29,9 +35,9 @@ export class CorTableColumn implements OnInit {
return this.kindof == 'datetime';
}
public processColumnForOrdered(tableService: any, value: any): any {
public processColumnForOrdered(value: any): any {
if (this.kindof == 'datetime') {
return tableService.getReversedTimestamp(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

@ -1,33 +1,57 @@
<div class="cor-table-element">
<span ng-transclude/>
<span ng-transclude></span>
<!-- Filter -->
<div class="co-top-bar" ng-if="$ctrl.compact != 'true'">
<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 }}
Showing {{ $ctrl.orderedData.entries.length }} of {{ $ctrl.tableData.length }} {{ ::$ctrl.tableItemTitle }}
</span>
<input class="form-control" type="text" ng-model="$ctrl.options.filter"
placeholder="Filter {{ $ctrl.tableItemTitle }}..." ng-change="$ctrl.refreshOrder()">
<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 class="empty-primary-msg">No {{ ::$ctrl.tableItemTitle }} found.</div>
</div>
<!-- Table -->
<table class="co-table" ng-show="$ctrl.tableData.length">
<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, $ctrl.options)">
<a ng-click="$ctrl.setOrder(col)">{{ col.title }}</a>
<td ng-repeat="col in $ctrl.columns"
ng-class="$ctrl.tablePredicateClass(col)" style="{{ ::col.style }}">
<a ng-click="$ctrl.setOrder(col)">{{ ::col.title }}</a>
</td>
</thead>
<tbody ng-repeat="item in $ctrl.orderedData.visibleEntries | limitTo:$ctrl.maxDisplayCount">
<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">
<div ng-include="col.templateurl" ng-if="col.templateurl"></div>
<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>
@ -36,7 +60,7 @@
<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-primary-msg">No matching {{ ::$ctrl.tableItemTitle }} found.</div>
<div class="empty-secondary-msg">Try adjusting your filter above.</div>
</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

@ -1,5 +1,7 @@
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';
/**
@ -13,23 +15,28 @@ import { CorTableColumn } from './cor-table-col.component';
}
})
export class CorTableComponent implements OnChanges {
@Input('<') public tableData: any[];
@Input('<') public tableData: any[] = [];
@Input('@') public tableItemTitle: string;
@Input('<') public filterFields: string[];
@Input('@') public compact: string;
@Input('<') public maxDisplayCount: number;
private columns: CorTableColumn[];
private orderedData: any;
private options: any;
@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) {
constructor(@Inject('TableService') private TableService: any) {
this.columns = [];
this.options = {
'filter': '',
'reverse': false,
'predicate': '',
'page': 0,
};
}
public ngOnChanges(changes: SimpleChanges): void {
@ -49,38 +56,55 @@ export class CorTableComponent implements OnChanges {
}
private setOrder(col: CorTableColumn): void {
this.TableService.orderBy(col.datafield, this.options);
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);
return this.tableService.tablePredicateClass(col.datafield, this.options.predicate, this.options.reverse);
}
private refreshOrder(): void {
var columnMap = {};
this.options.page = 0;
var columnMap: {[name: string]: CorTableColumn} = {};
this.columns.forEach(function(col) {
columnMap[col.datafield] = col;
});
const filterCols = this.columns.filter(col => !!col.sortfield)
const numericCols: string[] = this.columns.filter(col => col.isNumeric())
.map(col => col.datafield);
const numericCols = this.columns.filter(col => col.isNumeric())
.map(col => col.datafield);
const tableData = this.tableData || [];
const processed = tableData.map((item) => {
var newObj = Object.assign({}, item);
const processed: any[] = this.tableData.map((item) => {
Object.keys(item).forEach((key) => {
if (columnMap[key]) {
newObj[key] = columnMap[key].processColumnForOrdered(this.TableService, item[key]);
item[key] = columnMap[key].processColumnForOrdered(item[key]);
}
});
return newObj;
return item;
});
this.orderedData = this.TableService.buildOrderedItems(processed, this.options, filterCols, numericCols);
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,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 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

@ -6,10 +6,12 @@ import { Component } from 'ng-metadata/core';
*/
@Component({
selector: 'cor-tab-content',
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-content.component.html',
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-content/cor-tab-content.component.html',
legacy: {
transclude: true,
replace: true,
}
})
export class CorTabContentComponent {}
export class CorTabContentComponent {
}

View file

@ -1,83 +0,0 @@
import { CorTabComponent } from './cor-tab.component';
import { CorTabPanelComponent } from './cor-tab-panel.component';
/**
* Defines an interface for reading and writing the current tab state.
*/
export interface CorTabCurrentHandler {
getInitialTabId(): string
notifyTabChanged(tab: CorTabComponent, isDefaultTab: boolean)
dispose(): void
}
export function CorTabCurrentHandlerFactory(options?: any): CorTabCurrentHandler {
switch (options.type) {
case "cookie":
return new CookieCurrentTabHandler(options.cookieService, options.cookieName);
default:
return new LocationCurrentTabHandler(options.panel, options.$location, options.$rootScope);
}
}
/**
* Reads and writes the tab from the `tab` query parameter in the location.
*/
export class LocationCurrentTabHandler implements CorTabCurrentHandler {
private cancelWatchHandle: Function;
constructor (private panel: CorTabPanelComponent,
private $location: ng.ILocationService,
private $rootScope: ng.IRootScopeService) {
}
private checkLocation(): void {
var specifiedTabId = this.$location.search()['tab'];
var specifiedTab = this.panel.findTab(specifiedTabId);
this.panel.setActiveTab(specifiedTab);
}
public getInitialTabId(): string {
if (!this.cancelWatchHandle) {
this.cancelWatchHandle = this.$rootScope.$on('$routeUpdate', () => this.checkLocation());
}
return this.$location.search()['tab'];
}
public notifyTabChanged(tab: CorTabComponent, isDefaultTab: boolean) {
var newSearch = $.extend(this.$location.search(), {});
if (isDefaultTab) {
delete newSearch['tab'];
} else {
newSearch['tab'] = tab.tabId;
}
this.$location.search(newSearch);
}
public dispose(): void {
this.cancelWatchHandle();
}
}
/**
* Reads and writes the tab from a cookie,.
*/
export class CookieCurrentTabHandler implements CorTabCurrentHandler {
constructor (private CookieService: any, private cookieName: string) {}
public getInitialTabId(): string {
return this.CookieService.get(this.cookieName);
}
public notifyTabChanged(tab: CorTabComponent, isDefaultTab: boolean) {
if (isDefaultTab) {
this.CookieService.clear(this.cookieName);
} else {
this.CookieService.putPermanent(this.cookieName, tab.tabId);
}
}
public dispose(): void {}
}

View file

@ -1,31 +0,0 @@
import { Component, Input, Inject, Host, OnInit } from 'ng-metadata/core';
import { CorTabPanelComponent } from './cor-tab-panel.component';
/**
* 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.component.html',
legacy: {
transclude: true,
}
})
export class CorTabPaneComponent implements OnInit {
@Input('@') public id: string;
// Whether this is the active tab.
private isActiveTab: boolean = false;
constructor(@Host() @Inject(CorTabPanelComponent) private parent: CorTabPanelComponent) {
}
public ngOnInit(): void {
this.parent.addTabPane(this);
}
public changeState(isActive: boolean): void {
this.isActiveTab = isActive;
}
}

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

@ -1,3 +0,0 @@
<div class="co-main-content-panel co-tab-panel co-fx-box-shadow-heavy">
<div class="co-tab-container" ng-transclude></div>
</div>

View file

@ -1,117 +0,0 @@
import { Component, Input, Inject, OnDestroy } from 'ng-metadata/core';
import { CorTabComponent } from './cor-tab.component';
import { CorTabPaneComponent } from './cor-tab-pane.component';
import { CorTabCurrentHandler, LocationCurrentTabHandler, CookieCurrentTabHandler, CorTabCurrentHandlerFactory } from './cor-tab-handlers'
/**
* 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.component.html',
legacy: {
transclude: true,
}
})
export class CorTabPanelComponent implements OnDestroy {
// If 'true', the currently selected tab will be remembered via a cookie and not the page URL.
@Input('@') public rememberCookie: string;
// The tabs under this tabs component.
private tabs: CorTabComponent[] = [];
// The tab panes under the tabs component, indexed by the tab id.
private tabPanes: {[id: string]: CorTabPaneComponent} = {};
// The currently active tab, if any.
private activeTab: CorTabComponent = null;
// Whether the initial tab was set.
private initialTabSet: boolean = false;
// The handler to use to read/write the current tab.
private currentTabHandler: CorTabCurrentHandler = null;
constructor(@Inject('$location') private $location: ng.ILocationService,
@Inject('$rootScope') private $rootScope: ng.IRootScopeService,
@Inject('CookieService') private CookieService: any,
@Inject('CorTabCurrentHandlerFactory') private CorTabCurrentHandlerFactory: (Object) => CorTabCurrentHandler) {
}
public ngOnDestroy(): void {
if (this.currentTabHandler) {
this.currentTabHandler.dispose();
}
}
public tabClicked(tab: CorTabComponent): void {
this.setActiveTab(tab);
}
public findTab(tabId: string): CorTabComponent {
if (!this.tabs.length) {
return null;
}
var tab = this.tabs.find(function(current) {
return current.tabId == tabId;
}) || this.tabs[0];
if (!this.tabPanes[tab.tabId]) {
return null;
}
return tab;
}
public setActiveTab(tab: CorTabComponent): void {
if (this.activeTab == tab) {
return;
}
if (this.activeTab != null) {
this.activeTab.changeState(false);
this.tabPanes[this.activeTab.tabId].changeState(false);
}
this.activeTab = tab;
this.activeTab.changeState(true);
this.tabPanes[this.activeTab.tabId].changeState(true);
this.currentTabHandler.notifyTabChanged(tab, this.tabs[0] == tab);
}
public addTab(tab: CorTabComponent): void {
this.tabs.push(tab);
this.checkInitialTab();
}
public addTabPane(tabPane: CorTabPaneComponent): void {
this.tabPanes[tabPane.id] = tabPane;
this.checkInitialTab();
}
private checkInitialTab(): void {
if (this.tabs.length < 1 || this.initialTabSet) {
return;
}
this.currentTabHandler = this.CorTabCurrentHandlerFactory({
type: this.rememberCookie ? 'cookie' : 'location',
cookieService: this.CookieService,
cookeName: this.rememberCookie,
panel: this,
$location: this.$location,
$rootScope: this.$rootScope,
});
var tabId = this.currentTabHandler.getInitialTabId();
var tab = this.findTab(tabId);
if (!tab) {
return;
}
this.initialTabSet = true;
this.setActiveTab(tab);
}
}

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: BehaviorSubject<string> = new BehaviorSubject(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

@ -1,10 +0,0 @@
<li ng-class="{'active': $ctrl.isActive}">
<a ng-click="$ctrl.tabClicked()">
<span data-title="{{ ::$ctrl.tabTitle }}"
data-placement="right"
data-container="body"
style="display: inline-block"
bs-tooltip><span ng-transclude/></span><span class="visible-xs-inline xs-label">{{ ::$ctrl.tabTitle }}</span>
</span>
</a>
</li>

View file

@ -1,46 +0,0 @@
import { Component, Input, Output, Inject, EventEmitter, Host, OnInit } from 'ng-metadata/core';
import { CorTabPanelComponent } from './cor-tab-panel.component';
/**
* 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.component.html',
legacy: {
transclude: true,
}
})
export class CorTabComponent implements OnInit {
@Input('@') public tabId: string;
@Input('@') public tabTitle: string;
@Output() public tabInit: EventEmitter<any> = new EventEmitter();
@Output() public tabShown: EventEmitter<any> = new EventEmitter();
@Output() public tabHidden: EventEmitter<any> = new EventEmitter();
// Whether this is the active tab.
private isActive: boolean = false;
constructor(@Host() @Inject(CorTabPanelComponent) private parent: CorTabPanelComponent) {
}
public ngOnInit(): void {
this.parent.addTab(this);
}
public changeState(isActive: boolean): void {
this.isActive = isActive;
if (isActive) {
this.tabInit.emit({});
this.tabShown.emit({});
} else {
this.tabHidden.emit({});
}
}
private tabClicked(): void {
this.parent.tabClicked(this);
}
}

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,54 @@
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;
@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.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

@ -1,4 +1,4 @@
<span class="co-tab-element" ng-class="$ctrl.isClosed ? 'closed' : 'open'">
<span class="xs-toggle" ng-click="$ctrl.toggleClosed($event)"></span>
<ul class="co-tabs col-md-1" ng-transclude></ul>
<ul ng-class="$ctrl.parent.isVertical() ? 'co-tabs col-md-1' : 'co-top-tab-bar'" ng-transclude></ul>
</span>

View file

@ -1,5 +1,5 @@
import { Component, Host, Inject } from 'ng-metadata/core';
import { CorTabComponent } from './cor-tab.component';
import { Component, Input, Output, Inject, EventEmitter, Host } from 'ng-metadata/core';
import { CorTabPanelComponent } from './cor-tab-panel/cor-tab-panel.component';
/**
@ -13,9 +13,13 @@ import { CorTabComponent } from './cor-tab.component';
}
})
export class CorTabsComponent {
// If true, the tabs are in a closed state. Only applies in the mobile view.
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): Promise<void> {
return Promise.resolve($(`cor-tab[tab-title="${title}"] a`).click());
}
public isActiveTab(title: string): Promise<boolean> {
return Promise.resolve($(`cor-tab[tab-title="${title}"] .cor-tab-itself.active`).isPresent());
}
}

View file

@ -60,7 +60,7 @@ angular.module('quay').directive('credentialsDialog', function () {
$scope.downloadFile = function(info) {
var blob = new Blob([info.contents]);
saveAs(blob, info.filename);
FileSaver.saveAs(blob, info.filename);
};
$scope.viewFile = function(context) {
@ -170,7 +170,7 @@ angular.module('quay').directive('credentialsDialog', function () {
return '';
}
return $scope.getEscapedUsername(credentials) + '-pull-secret';
return $scope.getEscapedUsername(credentials).toLowerCase() + '-pull-secret';
};
$scope.getKubernetesFile = function(credentials) {

View file

@ -1,4 +1,4 @@
import { DockerfilePathSelectComponent } from './dockerfile-path-select.component';
import { DockerfilePathSelectComponent, PathChangeEvent } from './dockerfile-path-select.component';
describe("DockerfilePathSelectComponent", () => {
@ -60,6 +60,16 @@ describe("DockerfilePathSelectComponent", () => {
expect(component.isValidPath).toBe(false);
});
it("emits output event indicating Dockerfile path has changed", (done) => {
component.pathChanged.subscribe((event: PathChangeEvent) => {
expect(event.path).toEqual(newPath);
expect(event.isValid).toBe(component.isValidPath);
done();
});
component.setPath(newPath);
});
});
describe("setCurrentPath", () => {
@ -86,5 +96,15 @@ describe("DockerfilePathSelectComponent", () => {
expect(component.isValidPath).toBe(false);
});
it("emits output event indicating Dockerfile path has changed", (done) => {
component.pathChanged.subscribe((event: PathChangeEvent) => {
expect(event.path).toEqual(newPath);
expect(event.isValid).toBe(component.isValidPath);
done();
});
component.setSelectedPath(newPath);
});
});
});
});

View file

@ -1,4 +1,4 @@
import { Input, Component, OnChanges, SimpleChanges } from 'ng-metadata/core';
import { Input, Output, EventEmitter, Component, OnChanges, SimpleChanges } from 'ng-metadata/core';
/**
@ -10,11 +10,11 @@ import { Input, Component, OnChanges, SimpleChanges } from 'ng-metadata/core';
})
export class DockerfilePathSelectComponent implements OnChanges {
// FIXME: Use one-way data binding
@Input('=') public currentPath: string = '';
@Input('=') public isValidPath: boolean;
@Input('=') public paths: string[];
@Input('=') public supportsFullListing: boolean;
@Input('<') public currentPath: string = '';
@Input('<') public paths: string[];
@Input('<') public supportsFullListing: boolean;
@Output() public pathChanged: EventEmitter<PathChangeEvent> = new EventEmitter();
public isValidPath: boolean;
private isUnknownPath: boolean = true;
private selectedPath: string | null = null;
@ -26,12 +26,16 @@ export class DockerfilePathSelectComponent implements OnChanges {
this.currentPath = path;
this.selectedPath = null;
this.isValidPath = this.checkPath(path, this.paths, this.supportsFullListing);
this.pathChanged.emit({path: this.currentPath, isValid: this.isValidPath});
}
public setSelectedPath(path: string): void {
this.currentPath = path;
this.selectedPath = path;
this.isValidPath = this.checkPath(path, this.paths, this.supportsFullListing);
this.pathChanged.emit({path: this.currentPath, isValid: this.isValidPath});
}
private checkPath(path: string = '', paths: string[] = [], supportsFullListing: boolean): boolean {
@ -45,3 +49,12 @@ export class DockerfilePathSelectComponent implements OnChanges {
return isValidPath;
}
}
/**
* Dockerfile path changed event.
*/
export type PathChangeEvent = {
path: string;
isValid: boolean;
};

View file

@ -80,7 +80,7 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
source: dropdownHound.ttAdapter(),
templates: {
'suggestion': function (datum) {
template = datum['template'] ? datum['template'](datum) : datum['value'];
template = datum['template'] ? datum['template'](datum) : '<span>' + datum['value'] + '</span>';
return template;
}
}

View file

@ -1,6 +1,7 @@
import { Input, Output, Component, Inject } from 'ng-metadata/core';
import { Input, Component, Inject } from 'ng-metadata/core';
import * as moment from "moment";
/**
* A component that allows for selecting a time duration.
*/
@ -9,6 +10,7 @@ import * as moment from "moment";
templateUrl: '/static/js/directives/ui/duration-input/duration-input.component.html'
})
export class DurationInputComponent implements ng.IComponentController {
@Input('<') public min: string;
@Input('<') public max: string;
@Input('=?') public value: string;
@ -17,7 +19,7 @@ export class DurationInputComponent implements ng.IComponentController {
private min_s: number;
private max_s: number;
constructor (@Inject('$scope') private $scope: ng.IScope) {
constructor(@Inject('$scope') private $scope: ng.IScope) {
}
@ -33,7 +35,7 @@ export class DurationInputComponent implements ng.IComponentController {
}
private updateValue(): void {
this.value = this.seconds + 's';
this.value = `${this.seconds}s`;
}
private refresh(): void {
@ -41,8 +43,8 @@ export class DurationInputComponent implements ng.IComponentController {
this.max_s = this.toSeconds(this.max || '1h');
if (this.value) {
this.seconds = this.toSeconds(this.value || '0s')
};
this.seconds = this.toSeconds(this.value || '0s');
}
}
private durationExplanation(durationSeconds: string): string {

View file

@ -68,7 +68,10 @@ angular.module('quay').directive('entitySearch', function () {
};
$scope.lazyLoad = function() {
if (!$scope.namespace || !$scope.lazyLoading) { return; }
if (!$scope.namespace || !$scope.thisUser || !$scope.lazyLoading) { return; }
$scope.isAdmin = UserService.isNamespaceAdmin($scope.namespace);
$scope.isOrganization = !!UserService.getOrganization($scope.namespace);
// Reset the cached teams and robots.
$scope.teams = null;
@ -359,8 +362,13 @@ angular.module('quay').directive('entitySearch', function () {
$scope.$watch('namespace', function(namespace) {
if (!namespace) { return; }
$scope.isAdmin = UserService.isNamespaceAdmin(namespace);
$scope.isOrganization = !!UserService.getOrganization(namespace);
$scope.lazyLoad();
});
UserService.updateUserIn($scope, function(currentUser){
if (currentUser.anonymous) { return; }
$scope.thisUser = currentUser;
$scope.lazyLoad();
});
$scope.$watch('currentEntity', function(entity) {

View file

@ -125,7 +125,6 @@ angular.module('quay').directive('fetchTagDialog', function () {
updateFormats();
$element.find('#copyClipboard').clipboardCopy();
$element.find('#fetchTagDialog').modal({});
}
};

View file

@ -38,15 +38,15 @@ angular.module('quay').directive('globalMessageTab', function () {
ApiService.createGlobalMessage(data, null).then(function (resp) {
$scope.creatingMessage = false;
$scope.newMessage = {
'media_type': 'text/markdown',
'severity': 'info'
};
$('#createMessageModal').modal('hide');
$scope.loadMessageInternal();
}, errorHandler)
};
$scope.updateMessage = function(content) {
$scope.newMessage.content = content;
};
$scope.showDeleteMessage = function (uuid) {
$scope.messageToDelete = uuid;

View file

@ -86,6 +86,18 @@ angular.module('quay').directive('imageFeatureView', function () {
$scope.$watch('options.reverse', buildOrderedFeatures);
$scope.$watch('options.filter', buildOrderedFeatures);
$scope.$watch('repository', function(repository) {
if ($scope.isEnabled && $scope.repository && $scope.image) {
loadImageVulnerabilities();
}
});
$scope.$watch('image', function(image) {
if ($scope.isEnabled && $scope.repository && $scope.image) {
loadImageVulnerabilities();
}
});
$scope.$watch('isEnabled', function(isEnabled) {
if ($scope.isEnabled && $scope.repository && $scope.image) {
loadImageVulnerabilities();

View file

@ -123,6 +123,18 @@ angular.module('quay').directive('imageVulnerabilityView', function () {
$scope.$watch('options.filter', buildOrderedVulnerabilities);
$scope.$watch('options.fixableVulns', buildOrderedVulnerabilities);
$scope.$watch('repository', function(repository) {
if ($scope.isEnabled && $scope.repository && $scope.image) {
loadImageVulnerabilities();
}
});
$scope.$watch('image', function(image) {
if ($scope.isEnabled && $scope.repository && $scope.image) {
loadImageVulnerabilities();
}
});
$scope.$watch('isEnabled', function(isEnabled) {
if ($scope.isEnabled && $scope.repository && $scope.image) {
loadImageVulnerabilities();

View file

@ -1,82 +1,104 @@
import { LinearWorkflowSectionComponent } from './linear-workflow-section.component';
import { LinearWorkflowComponent } from './linear-workflow.component';
import { SimpleChanges } from 'ng-metadata/core';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("LinearWorkflowSectionComponent", () => {
var component: LinearWorkflowSectionComponent;
var parentMock: LinearWorkflowComponent;
var parentMock: Mock<LinearWorkflowComponent>;
beforeEach(() => {
parentMock = new LinearWorkflowComponent();
component = new LinearWorkflowSectionComponent(parentMock);
parentMock = new Mock<LinearWorkflowComponent>();
component = new LinearWorkflowSectionComponent(parentMock.Object);
component.sectionId = "mysection";
});
describe("ngOnInit", () => {
it("calls parent component to add itself as a section", () => {
var addSectionSpy: Spy = spyOn(parentMock, "addSection").and.returnValue(null);
parentMock.setup(mock => mock.addSection).is((section) => null);
component.ngOnInit();
expect(addSectionSpy.calls.argsFor(0)[0]).toBe(component);
expect((<Spy>parentMock.Object.addSection).calls.argsFor(0)[0]).toBe(component);
});
});
describe("ngOnChanges", () => {
var onSectionInvalidSpy: Spy;
var changesObj: SimpleChanges;
beforeEach(() => {
onSectionInvalidSpy = spyOn(parentMock, "onSectionInvalid").and.returnValue(null);
parentMock.setup(mock => mock.onSectionInvalid).is((section) => null);
changesObj = {
sectionValid: {
currentValue: true,
previousValue: false,
isFirstChange: () => false,
},
skipSection: {
currentValue: true,
previousValue: false,
isFirstChange: () => false,
},
};
});
it("does nothing if 'sectionValid' input not changed", () => {
component.ngOnChanges({});
expect(onSectionInvalidSpy).not.toHaveBeenCalled();
expect((<Spy>parentMock.Object.onSectionInvalid)).not.toHaveBeenCalled();
});
it("does nothing if 'sectionValid' input's previous value is falsy", () => {
changesObj['sectionValid'].previousValue = null;
component.ngOnChanges(changesObj);
expect((<Spy>parentMock.Object.onSectionInvalid)).not.toHaveBeenCalled();
});
it("does nothing if 'sectionValid' input is true", () => {
component.ngOnChanges(changesObj);
expect(onSectionInvalidSpy).not.toHaveBeenCalled();
expect((<Spy>parentMock.Object.onSectionInvalid)).not.toHaveBeenCalled();
});
it("calls parent method to inform that section is invalid if 'sectionValid' input changed to false", () => {
changesObj['sectionValid'].previousValue = true;
changesObj['sectionValid'].currentValue = false;
component.ngOnChanges(changesObj);
expect(onSectionInvalidSpy.calls.argsFor(0)[0]).toEqual(component.sectionId);
expect((<Spy>parentMock.Object.onSectionInvalid).calls.argsFor(0)[0]).toEqual(component.sectionId);
});
it("calls parent method to go to next section if 'skipSection' input is true and is current section", () => {
delete changesObj['sectionValid'];
parentMock.setup(mock => mock.onNextSection).is(() => null);
component.isCurrentSection = true;
component.ngOnChanges(changesObj);
expect(<Spy>parentMock.Object.onNextSection).toHaveBeenCalled();
});
});
describe("onSubmitSection", () => {
var onNextSectionSpy: Spy;
beforeEach(() => {
onNextSectionSpy = spyOn(parentMock, "onNextSection").and.returnValue(null);
parentMock.setup(mock => mock.onNextSection).is(() => null);
});
it("does nothing if section is invalid", () => {
component.sectionValid = false;
component.onSubmitSection();
expect(onNextSectionSpy).not.toHaveBeenCalled();
expect(<Spy>parentMock.Object.onNextSection).not.toHaveBeenCalled();
});
it("calls parent method to go to next section if section is valid", () => {
component.sectionValid = true;
component.onSubmitSection();
expect(onNextSectionSpy).toHaveBeenCalled();
expect(<Spy>parentMock.Object.onNextSection).toHaveBeenCalled();
});
});
});

View file

@ -1,4 +1,4 @@
import { Component, Input, Inject, Host, OnChanges, OnInit, SimpleChanges } from 'ng-metadata/core';
import { Component, Input, Inject, Host, OnChanges, OnInit, SimpleChanges, HostListener } from 'ng-metadata/core';
import { LinearWorkflowComponent } from './linear-workflow.component';
@ -16,7 +16,8 @@ export class LinearWorkflowSectionComponent implements OnChanges, OnInit {
@Input('@') public sectionId: string;
@Input('@') public sectionTitle: string;
@Input() public sectionValid: boolean = false;
@Input('<') public sectionValid: boolean = false;
@Input('<') public skipSection: boolean = false;
public sectionVisible: boolean = false;
public isCurrentSection: boolean = false;
@ -25,12 +26,24 @@ export class LinearWorkflowSectionComponent implements OnChanges, OnInit {
}
public ngOnInit(): void {
this.parent.addSection(this);
if (!this.skipSection) {
this.parent.addSection(this);
}
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes['sectionValid'] !== undefined && !changes['sectionValid'].currentValue) {
this.parent.onSectionInvalid(this.sectionId);
switch (Object.keys(changes)[0]) {
case 'sectionValid':
if (changes['sectionValid'].previousValue && !changes['sectionValid'].currentValue && this.parent) {
this.parent.onSectionInvalid(this.sectionId);
}
break;
case 'skipSection':
if (changes['skipSection'].currentValue && this.isCurrentSection && this.parent) {
this.parent.onNextSection();
}
break;
}
}

View file

@ -8,12 +8,12 @@
<td>
<!-- Next button -->
<button class="btn btn-primary"
ng-disabled="!$ctrl.currentSection.component.sectionValid"
ng-click="$ctrl.onNextSection()"
ng-class="{
'btn-success': $ctrl.currentSection.index == $ctrl.sections.length - 1,
'btn-lg': $ctrl.currentSection.index == $ctrl.sections.length - 1
}">
ng-disabled="!$ctrl.currentSection.component.sectionValid"
ng-click="$ctrl.onNextSection()"
ng-class="{
'btn-success': $ctrl.currentSection.index == $ctrl.sections.length - 1,
'btn-lg': $ctrl.currentSection.index == $ctrl.sections.length - 1
}">
<span ng-if="$ctrl.currentSection.index != sections.length - 1">Continue</span>
<span ng-if="$ctrl.currentSection.index == sections.length - 1">
<i class="fa fa-check-circle"></i>{{ ::$ctrl.doneTitle }}
@ -27,7 +27,7 @@
<b>Next:</b>
<ul>
<li ng-repeat="section in $ctrl.sections"
ng-if="section.index > $ctrl.currentSection.index">
ng-if="section.index > $ctrl.currentSection.index && !section.component.skipSection">
{{ section.component.sectionTitle }}
</li>
</ul>

View file

@ -17,23 +17,13 @@ describe("LinearWorkflowComponent", () => {
newSection = new LinearWorkflowSectionComponent(component);
});
it("does not set 'sectionVisible' or 'isCurrentSection' of given section if not the first section added", () => {
component.addSection(new LinearWorkflowSectionComponent(component));
component.addSection(newSection);
expect(newSection.sectionVisible).toBe(false);
expect(newSection.isCurrentSection).toBe(false);
});
it("sets 'sectionVisible' of given section to true if it is the first section added", () => {
it("sets 'sectionVisible' and 'isCurrentSection' to first section in list that is not skipped", () => {
var skippedSection: LinearWorkflowSectionComponent = new LinearWorkflowSectionComponent(component);
skippedSection.skipSection = true;
component.addSection(skippedSection);
component.addSection(newSection);
expect(newSection.sectionVisible).toBe(true);
});
it("sets 'isCurrentSection' of given section to true if it is the first section added", () => {
component.addSection(newSection);
expect(newSection.isCurrentSection).toBe(true);
});
});
@ -71,6 +61,21 @@ describe("LinearWorkflowComponent", () => {
expect(nextSection.isCurrentSection).toBe(true);
expect(nextSection.sectionVisible).toBe(true);
});
it("does not set the current section to a skipped section", () => {
var skippedSection: LinearWorkflowSectionComponent = new LinearWorkflowSectionComponent(component);
var nextSection: LinearWorkflowSectionComponent = new LinearWorkflowSectionComponent(component);
skippedSection.skipSection = true;
component.addSection(skippedSection);
component.addSection(nextSection);
component.onNextSection();
expect(currentSection.isCurrentSection).toBe(false);
expect(skippedSection.isCurrentSection).toBe(false);
expect(skippedSection.sectionVisible).toBe(false);
expect(nextSection.isCurrentSection).toBe(true);
expect(nextSection.sectionVisible).toBe(true);
});
});
describe("onSectionInvalid", () => {

View file

@ -26,10 +26,8 @@ export class LinearWorkflowComponent {
component: component,
});
if (this.sections.length == 1) {
this.currentSection = this.sections[0];
this.currentSection.component.sectionVisible = true;
this.currentSection.component.isCurrentSection = true;
if (this.sections.length > 0 && !this.currentSection) {
this.setNextSection(0);
}
}
@ -39,9 +37,7 @@ export class LinearWorkflowComponent {
}
else if (this.currentSection.component.sectionValid && this.currentSection.index + 1 < this.sections.length) {
this.currentSection.component.isCurrentSection = false;
this.currentSection = this.sections[this.currentSection.index + 1];
this.currentSection.component.sectionVisible = true;
this.currentSection.component.isCurrentSection = true;
this.setNextSection(this.currentSection.index + 1);
}
}
@ -50,6 +46,7 @@ export class LinearWorkflowComponent {
if (invalidSection.index <= this.currentSection.index) {
invalidSection.component.isCurrentSection = true;
this.currentSection = invalidSection;
this.sections.forEach((section) => {
if (section.index > invalidSection.index) {
section.component.sectionVisible = false;
@ -58,6 +55,17 @@ export class LinearWorkflowComponent {
});
}
}
private setNextSection(startingIndex: number = 0): void {
// Find the next section that is not set to be skipped
this.currentSection = this.sections.slice(startingIndex)
.filter(section => !section.component.skipSection)[0];
if (this.currentSection) {
this.currentSection.component.sectionVisible = true;
this.currentSection.component.isCurrentSection = true;
}
}
}
@ -67,4 +75,4 @@ export class LinearWorkflowComponent {
export type SectionInfo = {
index: number;
component: LinearWorkflowSectionComponent;
}
};

View file

@ -1,3 +1,6 @@
import { LogUsageChart } from '../../graphing';
/**
* Element which displays usage logs for the given entity.
*/
@ -61,6 +64,8 @@ angular.module('quay').directive('logsView', function () {
'push_repo': function(metadata) {
if (metadata.tag) {
return 'Push of {tag} to repository {namespace}/{repo}';
} else if (metadata.release) {
return 'Push of {release} to repository {namespace}/{repo}';
} else {
return 'Repository push to {namespace}/{repo}';
}
@ -91,6 +96,15 @@ angular.module('quay').directive('logsView', function () {
description = 'tag {tag} from repository {namespace}/{repo}';
} else if (metadata.manifest_digest) {
description = 'digest {manifest_digest} from repository {namespace}/{repo}';
} else if (metadata.release) {
description = 'release {release}';
if (metadata.channel) {
description += ' via channel {channel}';
}
if (metadata.mediatype) {
description += ' for {mediatype}';
}
description += ' from repository {namespace}/{repo}';
}
if (metadata.token) {
@ -225,6 +239,11 @@ angular.module('quay').directive('logsView', function () {
return 'Delete notification of event "' + eventData['title'] + '" for repository {namespace}/{repo}';
},
'reset_repo_notification': function(metadata) {
var eventData = ExternalNotificationData.getEventInfo(metadata.event);
return 'Re-enable notification of event "' + eventData['title'] + '" for repository {namespace}/{repo}';
},
'regenerate_robot_token': 'Regenerated token for robot {robot}',
'service_key_create': function(metadata) {
@ -302,6 +321,7 @@ angular.module('quay').directive('logsView', function () {
'reset_application_client_secret': 'Reset Client Secret',
'add_repo_notification': 'Add repository notification',
'delete_repo_notification': 'Delete repository notification',
'reset_repo_notification': 'Re-enable repository notification',
'regenerate_robot_token': 'Regenerate Robot Token',
'service_key_create': 'Create Service Key',
'service_key_approve': 'Approve Service Key',

View file

@ -1,64 +0,0 @@
<div class="manage-trigger-custom-git-element manage-trigger-control">
<linear-workflow
done-title="Create Trigger"
(on-workflow-complete)="$ctrl.activateTrigger.emit({config: $ctrl.config})">
<!-- Section: Repository -->
<linear-workflow-section class="row"
section-id="repo"
section-title="Git Repository"
section-valid="$ctrl.config.build_source !== undefined">
<div class="col-lg-7 col-md-7 col-sm-12 main-col">
<h3>Enter repository</h3>
<strong>Please enter the HTTP or SSH style URL used to clone your git repository:</strong>
<input class="form-control" type="text" placeholder="git@example.com:namespace/repository.git"
ng-model="$ctrl.config.build_source"
ng-pattern="/(((http|https):\/\/)(.+)|\w+@(.+):(.+))/">
</div>
<div class="col-lg-5 col-md-5 hidden-sm hidden-xs help-col">
<p>Custom git triggers support any externally accessible git repository, via either the normal git protocol or HTTP.</p>
<p><b>It is the responsibility of the git repository to invoke a webhook to tell <span class="registry-name" short="true"></span> that a commit has been added.</b></p>
</div>
</linear-workflow-section><!-- /Section: Repository -->
<!-- Section: Build context -->
<linear-workflow-section class="row"
section-id="dockerfile"
section-title="Build context"
section-valid="$ctrl.config.context">
<div class="col-lg-7 col-md-7 col-sm-12 main-col">
<h3>Select build context directory</h3>
<strong>Please select the build context directory under the git repository:</strong>
<input class="form-control" type="text" placeholder="/"
ng-model="$ctrl.config.context"
ng-pattern="/^($|\/|\/.+)/">
</div>
<div class="col-lg-5 col-md-5 hidden-sm hidden-xs help-col">
<p>The build context directory is the path of the directory containing the Dockerfile and any other files to be made available when the build is triggered.</p>
<p>If the Dockerfile is located at the root of the git repository, enter <code>/</code> as the build context directory.</p>
</div>
</linear-workflow-section><!-- /Section: Build context -->
<!-- Section: Dockerfile Location -->
<linear-workflow-section class="row"
section-id="dockerfilelocation"
section-title="Select Dockerfile"
section-valid="$ctrl.config.dockerfile_path">
<div class="col-lg-7 col-md-7 col-sm-12 main-col">
<h3>Select dockerfile path</h3>
<strong>Please select the build context directory under the git repository:</strong>
<input class="form-control" type="text" placeholder="{{ $ctrl.config.context }}"
ng-model="$ctrl.config.dockerfile_path"
ng-pattern="/^($|\/|\/.+)/">
</div>
<div class="col-lg-5 col-md-5 hidden-sm hidden-xs help-col">
<p>The dockerfile path stars with the context and ends with the path to the dockefile that you would like to build</p>
<p>If the Dockerfile is located at the root of the git repository and named Dockerfile, enter <code>/Dockerfile</code> as the dockerfile path.</p>
</div>
</linear-workflow-section><!-- /Section: Dockerfile Location -->
</linear-workflow>
</div>

View file

@ -1,14 +0,0 @@
import { ManageTriggerCustomGitComponent } from './manage-trigger-custom-git.component';
describe("ManageTriggerCustomGitComponent", () => {
var component: ManageTriggerCustomGitComponent;
beforeEach(() => {
component = new ManageTriggerCustomGitComponent();
});
describe("ngOnChanges", () => {
});
});

View file

@ -1,24 +0,0 @@
import { Input, Output, Component, EventEmitter, OnChanges, SimpleChanges } from 'ng-metadata/core';
/**
* A component that lets the user set up a build trigger for a custom Git repository.
*/
@Component({
selector: 'manage-trigger-custom-git',
templateUrl: '/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.html'
})
export class ManageTriggerCustomGitComponent implements OnChanges {
// FIXME: Use one-way data binding
@Input('=') public trigger: {config: any};
@Output() public activateTrigger: EventEmitter<{config: any, pull_robot?: any}> = new EventEmitter();
private config: any = {};
private currentState: any | null;
public ngOnChanges(changes: SimpleChanges): void {
if (changes['trigger'] !== undefined) {
this.config = Object.assign({}, changes['trigger'].currentValue.config);
}
}
}

View file

@ -1,413 +0,0 @@
<div class="manage-trigger-githost-element manage-trigger-control">
<linear-workflow done-title="Create Trigger"
(on-workflow-complete)="$ctrl.createTrigger($event)">
<!-- Section: Namespace -->
<linear-workflow-section class="row"
section-id="namespace"
section-title="{{ 'Select ' + $ctrl.namespaceTitle }}"
section-valid="$ctrl.local.selectedNamespace">
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.namespaces">
<h3>Select {{ $ctrl.namespaceTitle }}</h3>
<strong>Please select the {{ $ctrl.namespaceTitle }} under which the repository lives</strong>
<div class="co-top-bar">
<div class="co-filter-box">
<span class="page-controls"
total-count="$ctrl.local.orderedNamespaces.entries.length"
current-page="$ctrl.local.namespaceOptions.page"
page-size="$ctrl.namespacesPerPage"></span>
<input class="form-control" type="text"
ng-model="$ctrl.local.namespaceOptions.filter"
placeholder="Filter {{ $ctrl.namespaceTitle }}s...">
</div>
</div>
<table class="co-table">
<thead>
<td class="checkbox-col"></td>
<td ng-class="$ctrl.TableService.tablePredicateClass('id', $ctrl.local.namespaceOptions.predicate, $ctrl.local.namespaceOptions.reverse)">
<a ng-click="$ctrl.TableService.orderBy('id', $ctrl.local.namespaceOptions)">{{ $ctrl.namespaceTitle }}</a>
</td>
<td ng-class="$ctrl.TableService.tablePredicateClass('score', $ctrl.local.namespaceOptions.predicate, $ctrl.local.namespaceOptions.reverse)"
class="importance-col hidden-xs">
<a ng-click="$ctrl.TableService.orderBy('score', $ctrl.local.namespaceOptions)">Importance</a>
</td>
</thead>
<tr class="co-checkable-row"
ng-repeat="namespace in $ctrl.local.orderedNamespaces.visibleEntries | slice:($ctrl.namespacesPerPage * $ctrl.local.namespaceOptions.page):($ctrl.namespacesPerPage * ($ctrl.local.namespaceOptions.page + 1))"
ng-class="$ctrl.local.selectedNamespace == namespace ? 'checked' : ''"
bindonce>
<td>
<input type="radio"
ng-model="$ctrl.local.selectedNamespace"
ng-value="namespace">
</td>
<td>
<img class="namespace-avatar" ng-src="{{ namespace.avatar_url }}" ng-if="namespace.avatar_url">
<span class="anchor"
href="{{ namespace.url }}"
is-text-only="!namespace.url">{{ namespace.id }}</span>
</td>
<td class="importance-col hidden-xs">
<span class="strength-indicator" value="::namespace.score" maximum="::$ctrl.local.maxScore"
log-base="10"></span>
</td>
</tr>
</table>
<div class="empty"
ng-if="$ctrl.local.namespaces.length && !$ctrl.local.orderedNamespaces.entries.length"
style="margin-top: 20px;">
<div class="empty-primary-msg">No matching {{ $ctrl.namespaceTitle }} found.</div>
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
</div>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col" ng-if="!$ctrl.local.namespaces">
<span class="cor-loader-inline"></span> Retrieving {{ $ctrl.namespaceTitle }}s
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col" ng-if="$ctrl.local.namespaces">
<p>
<span class="registry-name"></span> has been granted access to read and view these {{ $ctrl.namespaceTitle }}s.
</p>
<p>
Don't see an expected {{ $ctrl.namespaceTitle }} here? Please make sure third-party access is enabled for <span class="registry-name"></span> under that {{ $ctrl.namespaceTitle }}.
</p>
</div>
</linear-workflow-section><!-- /Section: Namespace -->
<!-- Section: Repository -->
<linear-workflow-section class="row"
section-id="repo"
section-title="Select Repository"
section-valid="$ctrl.local.selectedRepository">
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.repositories">
<h3>Select Repository</h3>
<strong>
Select a repository in
<img class="namespace-avatar" ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}" ng-if="$ctrl.local.selectedNamespace.avatar_url">
{{ $ctrl.local.selectedNamespace.id }}
</strong>
<div class="co-top-bar">
<div class="co-filter-box">
<span class="page-controls"
total-count="$ctrl.local.orderedRepositories.entries.length"
current-page="$ctrl.local.repositoryOptions.page"
page-size="$ctrl.repositoriesPerPage"></span>
<input class="form-control" type="text"
ng-model="$ctrl.local.repositoryOptions.filter"
placeholder="Filter repositories...">
<div class="filter-options">
<label>
<input type="checkbox"
ng-model="$ctrl.local.repositoryOptions.hideStale">
Hide stale repositories
</label>
</div>
</div>
</div>
<table class="co-table" style="margin-top: 20px;">
<thead>
<td class="checkbox-col"></td>
<td ng-class="$ctrl.TableService.tablePredicateClass('name', $ctrl.local.repositoryOptions.predicate, $ctrl.local.repositoryOptions.reverse)" class="nowrap-col">
<a ng-click="$ctrl.TableService.orderBy('name', $ctrl.local.repositoryOptions)">Repository Name</a>
</td>
<td ng-class="$ctrl.TableService.tablePredicateClass('last_updated_datetime', $ctrl.local.repositoryOptions.predicate, $ctrl.local.repositoryOptions.reverse)"
class="last-updated-col nowrap-col">
<a ng-click="$ctrl.TableService.orderBy('last_updated_datetime', $ctrl.local.namespaceOptions)">Last Updated</a>
</td>
<td class="hidden-xs">Description</td>
</thead>
<tr class="co-checkable-row"
ng-repeat="repository in $ctrl.local.orderedRepositories.visibleEntries | slice:($ctrl.repositoriesPerPage * $ctrl.local.repositoryOptions.page):($ctrl.repositoriesPerPage * ($ctrl.local.repositoryOptions.page + 1))"
ng-class="$ctrl.local.selectedRepository == repository ? 'checked' : ''"
bindonce>
<td>
<span ng-if="!repository.has_admin_permissions">
<i class="fa fa-exclamation-triangle"
data-title="Admin access is required to add the webhook trigger to this repository" bs-tooltip></i>
</span>
<input type="radio"
ng-model="$ctrl.local.selectedRepository"
ng-value="repository"
ng-if="repository.has_admin_permissions">
</td>
<td class="nowrap-col">
<i class="service-icon fa {{ $ctrl.getTriggerIcon() }}"></i>
<span class="anchor"
href="{{ repository.url }}"
is-text-only="!repository.url">{{ repository.name }}</span>
</td>
<td class="last-updated-col nowrap-col">
<span am-time-ago="repository.last_updated_datetime"></span>
</td>
<td class="hidden-xs">
<span ng-if="repository.description">{{ repository.description }}</span>
<span class="empty-description" ng-if="!repository.description">(None)</span>
</td>
</tr>
</table>
<div class="empty"
ng-if="$ctrl.local.repositories.length && !$ctrl.local.orderedRepositories.entries.length"
style="margin-top: 20px;">
<div class="empty-primary-msg">No matching repositories found.</div>
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
</div>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.repositories">
<span class="cor-loader-inline"></span> Retrieving repositories
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col"
ng-if="$ctrl.local.repositories">
<p>
A webhook will be added to the selected repository in order to detect when new commits are made.
</p>
<p>
Don't see an expected repository here? Please make sure you have admin access on that repository.
</p>
</div>
</linear-workflow-section><!-- /Section: Repository -->
<!-- Section: Trigger Options -->
<linear-workflow-section class="row"
section-id="triggeroptions"
section-title="Configure Trigger"
section-valid="$ctrl.local.triggerOptions">
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.repositoryRefs">
<h3>Configure Trigger</h3>
<strong>
Configure trigger options for
<img class="namespace-avatar" ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}" ng-if="$ctrl.local.selectedNamespace.avatar_url">
{{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }}
</strong>
<div class="radio" style="margin-top: 20px;">
<label>
<input type="radio" name="optionRadio"
ng-model="$ctrl.local.triggerOptions.hasBranchTagFilter"
ng-value="false">
<div class="title">Trigger for all branches and tags <span class="weak">(default)</span></div>
<div class="description">Build a container image for each commit across all branches and tags</div>
</label>
</div>
<div class="radio">
<label>
<input type="radio"
name="optionRadio"
ng-model="$ctrl.local.triggerOptions.hasBranchTagFilter"
ng-value="true">
<div class="title">Trigger only on branches and tags matching a regular expression</div>
<div class="description">Only build container images for a subset of branches and/or tags.</div>
<div class="extended" ng-if="$ctrl.local.triggerOptions.hasBranchTagFilter">
<table>
<tr>
<td style="white-space: nowrap;">Regular Expression:</td>
<td>
<input type="text" class="form-control"
ng-model="$ctrl.local.triggerOptions.branchTagFilter"
required>
<div class="description">Examples: heads/master, tags/tagname, heads/.+</div>
<regex-match-view items="$ctrl.local.repositoryFullRefs"
regex="$ctrl.local.triggerOptions.branchTagFilter"
ng-if="$ctrl.local.triggerOptions.branchTagFilter"></regex-match-view>
</td>
</tr>
</table>
</div>
</label>
</div>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.repositoryRefs">
<span class="cor-loader-inline"></span> Retrieving repository refs
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
<p>Do you want to build a new container image for commits across all branches and tags, or limit to a subset?</p>
<p>For example, if you use release branches instead of <code>master</code> for building versions of your software, you can configure the trigger to only build images for these branches.</p>
<p>All images built will be tagged with the name of the branch or tag whose change invoked the trigger</p>
</div>
</linear-workflow-section><!-- /Section: Trigger Options -->
<!-- Section: Dockerfile Location -->
<linear-workflow-section class="row"
section-id="dockerfilelocation"
section-title="Select Dockerfile"
section-valid="$ctrl.local.hasValidDockerfilePath">
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.dockerfileLocations.status == 'error'">
<div class="co-alert co-alert-warning">
{{ $ctrl.local.dockerfileLocations.message }}
</div>
</div>
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.dockerfileLocations.status == 'success'">
<h3>Select Dockerfile</h3>
<strong>
Please select the location of the Dockerfile to build when this trigger is invoked
</strong>
<dockerfile-path-select current-path="$ctrl.local.dockerfilePath"
paths="$ctrl.local.dockerfileLocations.dockerfile_paths"
supports-full-listing="true"
is-valid-path="$ctrl.local.hasValidDockerfilePath"></dockerfile-path-select>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.dockerfileLocations">
<span class="cor-loader-inline"></span> Retrieving Dockerfile locations
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
<p>Please select the location containing the Dockerfile to be built.</p>
</div>
</linear-workflow-section><!-- /Section: Dockerfile Location -->
<!-- Section: Context Location -->
<linear-workflow-section class="row"
section-id="contextlocation"
section-title="Select Docker Context"
section-valid="$ctrl.local.hasValidContextLocation">
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.dockerfileLocations.status == 'error'">
<div class="co-alert co-alert-warning">
{{ $ctrl.local.dockerfileLocations.message }}
</div>
</div>
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.dockerfileLocations.status == 'success'">
<h3>Select Context</h3>
<strong>
Please select the context for the docker build
</strong>
<context-path-select current-context="$ctrl.local.dockerContext"
current-path="$ctrl.local.dockerfilePath"
contexts="$ctrl.local.contexts"
is-valid-context="$ctrl.local.hasValidContextLocation"></context-path-select>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.dockerfileLocations">
<span class="cor-loader-inline"></span> Retrieving Dockerfile locations
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
<p>Please select a docker context.</p>
</div>
</linear-workflow-section><!-- /Section: Context Location -->
<!-- Section: Robot Account -->
<linear-workflow-section class="row"
section-id="verification"
section-title="Robot Account"
section-valid="$ctrl.local.triggerAnalysis.status != 'error' &&
($ctrl.local.triggerAnalysis.status != 'requiresrobot' || $ctrl.local.robotAccount != null)">
<!-- Error -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.triggerAnalysis.status == 'error'">
<h3 class="error"><i class="fa fa-exclamation-circle"></i> Verification Error</h3>
<strong>
There was an error when verifying the state of <img class="namespace-avatar" ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}" ng-if="$ctrl.local.selectedNamespace.avatar_url">
{{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }}
</strong>
{{ $ctrl.local.triggerAnalysis.message }}
</div>
<!-- Robot display for non-error cases -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.triggerAnalysis.status != 'error'">
<!-- Warning -->
<div ng-if="$ctrl.local.triggerAnalysis.status == 'warning'">
<h3 class="warning"><i class="fa fa-exclamation-triangle"></i> Verification Warning</h3>
{{ $ctrl.local.triggerAnalysis.message }}
</div>
<!-- Public base -->
<div ng-if="$ctrl.local.triggerAnalysis.status == 'publicbase'">
<h3 class="success"><i class="fa fa-check-circle"></i> Ready to go!</h3>
<strong>
<span ng-if="$ctrl.local.triggerAnalysis.is_admin">Choose an optional robot account below or click "Continue" to complete setup of this build trigger</span>
<span ng-if="!$ctrl.local.triggerAnalysis.is_admin">Click "Continue" to complete setup of this build trigger</span>
</strong>
</div>
<!-- Requires robot and is not admin -->
<div ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' && !$ctrl.local.triggerAnalysis.is_admin">
<h3>Robot Account Required</h3>
<p>The selected Dockerfile in the selected repository depends upon a private base image</p>
<p>A robot account with access to the base image is required to setup this trigger, but you are not the administrator of this namespace.</p>
<p>Administrative access is required to continue to ensure security of the robot credentials.</p>
</div>
<!-- Robots view -->
<div ng-if="$ctrl.local.triggerAnalysis.is_admin">
<div class="co-top-bar">
<div class="co-filter-box">
<span class="page-controls"
total-count="$ctrl.local.orderedRobotAccounts.entries.length"
current-page="$ctrl.local.robotOptions.page"
page-size="$ctrl.robotsPerPage"></span>
<input class="form-control"
type="text"
ng-model="$ctrl.local.robotOptions.filter"
placeholder="Filter robot accounts...">
</div>
</div>
<table class="co-table" style="margin-top: 20px;">
<thead>
<td class="checkbox-col"></td>
<td ng-class="$ctrl.TableService.tablePredicateClass('name', $ctrl.local.robotOptions.predicate, $ctrl.local.robotOptions.reverse)">
<a ng-click="$ctrl.TableService.orderBy('name', $ctrl.local.robotOptions)">Robot Account</a>
</td>
<td ng-class="$ctrl.TableService.tablePredicateClass('can_read', $ctrl.local.robotOptions.predicate, $ctrl.local.robotOptions.reverse)"
ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot'">
<a ng-click="$ctrl.TableService.orderBy('can_read', $ctrl.local.robotOptions)">Has Read Access</a>
</td>
</thead>
<tr class="co-checkable-row"
ng-repeat="robot in $ctrl.local.orderedRobotAccounts.visibleEntries | slice:($ctrl.robotsPerPage * $ctrl.local.namespaceOptions.page):($ctrl.robotsPerPage * ($ctrl.local.robotOptions.page + 1))"
ng-class="$ctrl.local.robotAccount == robot ? 'checked' : ''"
bindonce>
<td>
<input type="radio"
ng-model="$ctrl.local.robotAccount"
ng-value="robot">
</td>
<td>
<span class="entity-reference" entity="robot"></span>
</td>
<td ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot'">
<span ng-if="robot.can_read" class="success">Can Read</span>
<span ng-if="!robot.can_read">Read access will be added if selected</span>
</td>
</tr>
</table>
<div class="empty" style="margin-top: 20px;"
ng-if="$ctrl.local.triggerAnalysis.robots.length && !$ctrl.local.orderedRobotAccounts.entries.length">
<div class="empty-primary-msg">No matching robot accounts found.</div>
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
</div>
</div> <!-- /Robots view -->
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col"
ng-if="$ctrl.local.triggerAnalysis.is_admin">
<p>In order for the <span class="registry-name"></span> to pull a <b>private base image</b> during the build process, a robot account with access must be selected.</p>
<p ng-if="$ctrl.local.triggerAnalysis.status != 'requiresrobot'">If you know that a private base image is not used, you can skip this step.</p>
<p ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot'">Robot accounts that already have access to this base image are listed first. If you select a robot account that does not currently have access, read permission will be granted to that robot account on trigger creation.</p>
</div>
</linear-workflow-section><!-- /Section: Robot Account -->
</linear-workflow>
</div>

View file

@ -1,113 +0,0 @@
import { ManageTriggerGithostComponent } from './manage-trigger-githost.component';
import { Local, Trigger, Repository } from '../../../types/common.types';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("ManageTriggerGithostComponent", () => {
var component: ManageTriggerGithostComponent;
var apiServiceMock: Mock<any>;
var tableServiceMock: Mock<any>;
var triggerServiceMock: Mock<any>;
var rolesServiceMock: Mock<any>;
var repository: any;
var trigger: Trigger;
var $scopeMock: Mock<ng.IScope>;
beforeEach(inject(($injector: ng.auto.IInjectorService) => {
apiServiceMock = new Mock<any>();
tableServiceMock = new Mock<any>();
triggerServiceMock = new Mock<any>();
rolesServiceMock = new Mock<any>();
$scopeMock = new Mock<ng.IScope>();
component = new ManageTriggerGithostComponent(apiServiceMock.Object,
tableServiceMock.Object,
triggerServiceMock.Object,
rolesServiceMock.Object,
$scopeMock.Object);
trigger = {service: "serviceMock", id: 1};
component.trigger = trigger;
}));
describe("constructor", () => {
// TODO
});
describe("$onInit", () => {
// TODO
});
describe("getTriggerIcon", () => {
beforeEach(() => {
triggerServiceMock.setup(mock => mock.getIcon).is((service: any) => null);
});
it("calls trigger service to get icon", () => {
const icon: any = component.getTriggerIcon();
expect((<Spy>triggerServiceMock.Object.getIcon).calls.argsFor(0)[0]).toEqual(component.trigger.service);
});
});
describe("createTrigger", () => {
beforeEach(() => {
component.local.selectedRepository = new Mock<Repository>().Object;
component.local.selectedRepository.full_name = "someorg/some-repository";
component.local.dockerfilePath = "/Dockerfile";
component.local.dockerContext = "/";
component.local.triggerOptions = {};
component.local.triggerAnalysis = {};
rolesServiceMock.setup(mock => mock.setRepositoryRole).is((repo, role, entityKind, entityName, callback) => {
callback();
});
});
it("calls roles service to grant read access to selected robot if robot is required and cannot read", (done) => {
component.local.triggerAnalysis = {status: 'requiresrobot', name: 'privatebase', namespace: 'someorg'};
component.local.robotAccount = {can_read: false, is_robot: true, kind: 'user', name: 'test-robot'};
component.activateTrigger.subscribe((event: {config: any, pull_robot: any}) => {
expect((<Spy>rolesServiceMock.Object.setRepositoryRole).calls.argsFor(0)[0]).toEqual({
name: component.local.triggerAnalysis.name,
namespace: component.local.triggerAnalysis.namespace,
});
expect((<Spy>rolesServiceMock.Object.setRepositoryRole).calls.argsFor(0)[1]).toEqual('read');
expect((<Spy>rolesServiceMock.Object.setRepositoryRole).calls.argsFor(0)[2]).toEqual('robot');
done();
});
component.createTrigger();
});
it("does not call roles service if robot is required but already has read access", (done) => {
component.local.robotAccount = {can_read: true, is_robot: true, kind: 'user', name: 'test-robot'};
component.activateTrigger.subscribe((event: {config: any, pull_robot: any}) => {
expect((<Spy>rolesServiceMock.Object.setRepositoryRole)).not.toHaveBeenCalled();
done();
});
component.createTrigger();
});
it("does not call roles service if robot is not required", (done) => {
component.activateTrigger.subscribe((event: {config: any, pull_robot: any}) => {
expect((<Spy>rolesServiceMock.Object.setRepositoryRole)).not.toHaveBeenCalled();
done();
});
component.createTrigger();
});
it("emits output event with config and pull robot", (done) => {
component.activateTrigger.subscribe((event: {config: any, pull_robot: any}) => {
expect(event.config.build_source).toEqual(component.local.selectedRepository.full_name);
expect(event.config.dockerfile_path).toEqual(component.local.dockerfilePath);
expect(event.config.context).toEqual(component.local.dockerContext);
done();
});
component.createTrigger();
});
});
});

View file

@ -1,327 +0,0 @@
import { Input, Output, Component, Inject, EventEmitter, OnInit } from 'ng-metadata/core';
import * as moment from 'moment';
import { Local, Trigger, Repository, Namespace } from '../../../types/common.types';
/**
* A component that lets the user set up a build trigger for a public Git repository host service.
*/
@Component({
selector: 'manage-trigger-githost',
templateUrl: '/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.html'
})
export class ManageTriggerGithostComponent implements OnInit {
// FIXME: Use one-way data binding
@Input('=') public repository: Repository;
@Input('=') public trigger: Trigger;
@Output() public activateTrigger: EventEmitter<{config: any, pull_robot?: any}> = new EventEmitter();
public config: any;
public local: Local = {
namespaceOptions: {filter: '', predicate: 'score', reverse: false, page: 0},
repositoryOptions: {filter: '', predicate: 'score', reverse: false, page: 0, hideStale: true},
robotOptions: {filter: '', predicate: 'score', reverse: false, page: 0},
};
private currentState: any | null;
private namespacesPerPage: number = 10;
private repositoriesPerPage: number = 10;
private robotsPerPage: number = 10;
private namespaceTitle: string;
private namespace: any;
constructor(@Inject('ApiService') private ApiService: any,
@Inject('TableService') private TableService: any,
@Inject('TriggerService') private TriggerService: any,
@Inject('RolesService') private RolesService: any,
@Inject('$scope') private $scope: ng.IScope) {
// FIXME: Here binding methods to class context in order to pass them as arguments to $scope.$watch
this.buildOrderedNamespaces = this.buildOrderedNamespaces.bind(this);
this.loadNamespaces = this.loadNamespaces.bind(this);
this.buildOrderedRepositories = this.buildOrderedRepositories.bind(this);
this.loadRepositories = this.loadRepositories.bind(this);
this.loadRepositoryRefs = this.loadRepositoryRefs.bind(this);
this.buildOrderedRobotAccounts = this.buildOrderedRobotAccounts.bind(this);
this.loadDockerfileLocations = this.loadDockerfileLocations.bind(this);
this.checkDockerfilePath = this.checkDockerfilePath.bind(this);
}
public ngOnInit(): void {
// TODO: Replace $scope.$watch with @Output methods for child component mutations or $onChanges for parent mutations
this.$scope.$watch(() => this.trigger, this.initialSetup.bind(this));
this.$scope.$watch(() => this.repository, this.initialSetup.bind(this));
this.$scope.$watch(() => this.local.selectedNamespace, (namespace) => {
if (namespace) {
this.loadRepositories(namespace);
}
});
this.$scope.$watch(() => this.local.selectedRepository, (repository) => {
if (repository) {
this.loadRepositoryRefs(repository);
this.loadDockerfileLocations(repository);
}
});
this.$scope.$watch(() => this.local.dockerfilePath, (path) => {
if (path && this.local.selectedRepository) {
this.setPossibleContexts(path);
this.checkDockerfilePath(this.local.selectedRepository, path, this.local.dockerContext);
}
});
this.$scope.$watch(() => this.local.dockerContext, (context) => {
if (context && this.local.selectedRepository) {
this.checkDockerfilePath(this.local.selectedRepository, this.local.dockerfilePath, context);
}
});
this.$scope.$watch(() => this.local.namespaceOptions.predicate, this.buildOrderedNamespaces);
this.$scope.$watch(() => this.local.namespaceOptions.reverse, this.buildOrderedNamespaces);
this.$scope.$watch(() => this.local.namespaceOptions.filter, this.buildOrderedNamespaces);
this.$scope.$watch(() => this.local.repositoryOptions.predicate, this.buildOrderedRepositories);
this.$scope.$watch(() => this.local.repositoryOptions.reverse, this.buildOrderedRepositories);
this.$scope.$watch(() => this.local.repositoryOptions.filter, this.buildOrderedRepositories);
this.$scope.$watch(() => this.local.repositoryOptions.hideStale, this.buildOrderedRepositories);
this.$scope.$watch(() => this.local.robotOptions.predicate, this.buildOrderedRobotAccounts);
this.$scope.$watch(() => this.local.robotOptions.reverse, this.buildOrderedRobotAccounts);
this.$scope.$watch(() => this.local.robotOptions.filter, this.buildOrderedRobotAccounts);
}
private initialSetup(): void {
if (!this.repository || !this.trigger) { return; }
if (this.namespaceTitle) {
// Already setup.
return;
}
this.config = this.trigger['config'] || {};
this.namespaceTitle = 'organization';
this.local.selectedNamespace = null;
this.loadNamespaces();
}
public getTriggerIcon(): any {
return this.TriggerService.getIcon(this.trigger.service);
}
public createTrigger(): void {
var config: any = {
build_source: this.local.selectedRepository.full_name,
dockerfile_path: this.local.dockerfilePath,
context: this.local.dockerContext
};
if (this.local.triggerOptions['hasBranchTagFilter'] && this.local.triggerOptions['branchTagFilter']) {
config['branchtag_regex'] = this.local.triggerOptions['branchTagFilter'];
}
const activate = () => {
this.activateTrigger.emit({config: config, pull_robot: this.local.robotAccount});
};
if (this.local.triggerAnalysis.status == 'requiresrobot' && this.local.robotAccount) {
if (this.local.robotAccount.can_read) {
activate();
} else {
// Add read permission onto the base repository for the robot and then activate the trigger.
const baseRepo: any = {name: this.local.triggerAnalysis.name, namespace: this.local.triggerAnalysis.namespace};
this.RolesService.setRepositoryRole(baseRepo, 'read', 'robot', this.local.robotAccount.name, activate);
}
} else {
activate();
}
}
private buildOrderedNamespaces(): void {
if (!this.local.namespaces) {
return;
}
var namespaces: Namespace[] = this.local.namespaces || [];
this.local.orderedNamespaces = this.TableService.buildOrderedItems(namespaces,
this.local.namespaceOptions,
['id'],
['score']);
this.local.maxScore = 0;
namespaces.forEach((namespace) => {
this.local.maxScore = Math.max(namespace.score, this.local.maxScore);
});
}
private loadNamespaces(): void {
this.local.namespaces = null;
this.local.selectedNamespace = null;
this.local.orderedNamespaces = null;
this.local.selectedRepository = null;
this.local.orderedRepositories = null;
var params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id
};
this.ApiService.listTriggerBuildSourceNamespaces(null, params)
.then((resp) => {
this.local.namespaces = resp['namespaces'];
this.local.repositories = null;
this.buildOrderedNamespaces();
}, this.ApiService.errorDisplay('Could not retrieve the list of ' + this.namespaceTitle));
}
private buildOrderedRepositories(): void {
if (!this.local.repositories) {
return;
}
var repositories = this.local.repositories || [];
repositories.forEach((repository) => {
repository['last_updated_datetime'] = new Date(repository['last_updated'] * 1000);
});
if (this.local.repositoryOptions.hideStale) {
var existingRepositories = repositories;
repositories = repositories.filter((repository) => {
var older_date = moment(repository['last_updated_datetime']).add(1, 'months');
return !moment().isAfter(older_date);
});
if (existingRepositories.length > 0 && repositories.length == 0) {
repositories = existingRepositories;
}
}
this.local.orderedRepositories = this.TableService.buildOrderedItems(repositories,
this.local.repositoryOptions,
['name', 'description'],
[]);
}
private loadRepositories(namespace: any): void {
this.local.repositories = null;
this.local.selectedRepository = null;
this.local.repositoryRefs = null;
this.local.triggerOptions = {
'hasBranchTagFilter': false
};
this.local.orderedRepositories = null;
var params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id
};
var data = {
'namespace': namespace.id
};
this.ApiService.listTriggerBuildSources(data, params).then((resp) => {
if (namespace == this.local.selectedNamespace) {
this.local.repositories = resp['sources'];
this.buildOrderedRepositories();
}
}, this.ApiService.errorDisplay('Could not retrieve repositories'));
}
private loadRepositoryRefs(repository: any): void {
this.local.repositoryRefs = null;
this.local.triggerOptions = {
'hasBranchTagFilter': false
};
var params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id,
'field_name': 'refs'
};
var config = {
'build_source': repository.full_name
};
this.ApiService.listTriggerFieldValues(config, params).then((resp) => {
if (repository == this.local.selectedRepository) {
this.local.repositoryRefs = resp['values'];
this.local.repositoryFullRefs = resp['values'].map((ref) => {
var kind = ref.kind == 'branch' ? 'heads' : 'tags';
var icon = ref.kind == 'branch' ? 'fa-code-fork' : 'fa-tag';
return {
'value': kind + '/' + ref.name,
'icon': icon,
'title': ref.name
};
});
}
}, this.ApiService.errorDisplay('Could not retrieve repository refs'));
}
private loadDockerfileLocations(repository: any): void {
this.local.dockerfilePath = null;
this.local.dockerContext = null;
var params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id
};
var config = {
'build_source': repository.full_name
};
this.ApiService.listBuildTriggerSubdirs(config, params)
.then((resp) => {
if (repository == this.local.selectedRepository) {
this.local.dockerfileLocations = resp;
}
}, this.ApiService.errorDisplay('Could not retrieve Dockerfile locations'));
}
private buildOrderedRobotAccounts(): void {
if (!this.local.triggerAnalysis || !this.local.triggerAnalysis.robots) {
return;
}
var robots = this.local.triggerAnalysis.robots;
this.local.orderedRobotAccounts = this.TableService.buildOrderedItems(robots,
this.local.robotOptions,
['name'],
[]);
}
private checkDockerfilePath(repository: any, path: string, context: string): void {
this.local.triggerAnalysis = null;
this.local.robotAccount = null;
var params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id
};
var config = {
'build_source': repository.full_name,
'dockerfile_path': path.substr(1),
'context': context
};
var data = {
'config': config
};
this.ApiService.analyzeBuildTrigger(data, params)
.then((resp) => {
this.local.triggerAnalysis = resp;
this.buildOrderedRobotAccounts();
}, this.ApiService.errorDisplay('Could not analyze trigger'));
}
private setPossibleContexts(path){
if(this.local.dockerfileLocations.contextMap){
this.local.contexts = this.local.dockerfileLocations.contextMap[path] || [];
}
}
}

View file

@ -0,0 +1,437 @@
<div class="manage-trigger-githost-element manage-trigger-control">
<linear-workflow done-title="Create Trigger"
(on-workflow-complete)="$ctrl.createTrigger($event)">
<!-- Section: Namespace -->
<linear-workflow-section class="row"
section-id="namespace"
section-title="::{{ 'Select ' + $ctrl.namespaceTitle }}"
section-valid="$ctrl.local.selectedNamespace"
skip-section="$ctrl.githost == 'custom-git'">
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.namespaces">
<h3>Select {{ ::$ctrl.namespaceTitle }}</h3>
<strong>Please select the {{ ::$ctrl.namespaceTitle }} under which the repository lives</strong>
<cor-table table-data="$ctrl.local.namespaces"
table-item-title="namespaces"
max-display-count="$ctrl.namespacesPerPage"
filter-fields="::['title', 'id']">
<cor-table-col datafield="title"
style="width: 30px;"
sortfield="title"
bind-model="$ctrl.local.selectedNamespace"
templateurl="/static/js/directives/ui/manage-trigger-githost/namespace-radio-input.html">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-githost/namespace-radio-input.html">
<input type="radio"
ng-model="col.bindModel" ng-value="item"
ng-dblclick="col.bindModel = null">
</script>
</cor-table-col>
<cor-table-col title="{{ ::$ctrl.namespaceTitle }}"
datafield="id"
sortfield="id"
templateurl="/static/js/directives/ui/manage-trigger-githost/namespace-name.html">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-githost/namespace-name.html">
<img class="namespace-avatar" ng-src="{{ ::item.avatar_url }}" ng-if="::item.avatar_url">
<span class="anchor"
href="{{ ::item.url }}"
is-text-only="::!item.url">{{ ::item.id }}</span>
</script>
</cor-table-col>
<cor-table-col title="Importance"
datafield="score"
bind-model="$ctrl.local.maxScore"
style="display: flex; justify-content: flex-end"
templateurl="/static/js/directives/ui/manage-trigger-githost/namespace-score.html">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-githost/namespace-score.html">
<div style="padding-right: 40px;">
<span class="strength-indicator"
value="::item.score" maximum="::col.bindModel" log-base="10"></span>
</div>
</script>
</cor-table-col>
</cor-table>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.namespaces">
<span class="cor-loader-inline"></span> Retrieving {{ $ctrl.namespaceTitle }}s
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col" ng-if="$ctrl.local.namespaces">
<p>
<span class="registry-name"></span> has been granted access to read and view these {{ $ctrl.namespaceTitle }}s.
</p>
<p>
Don't see an expected {{ $ctrl.namespaceTitle }} here? Please make sure third-party access is enabled for <span class="registry-name"></span> under that {{ $ctrl.namespaceTitle }}.
</p>
</div>
</linear-workflow-section><!-- /Section: Namespace -->
<!-- Section: Githost Repository -->
<linear-workflow-section class="row"
section-id="repo"
section-title="Select Repository"
section-valid="$ctrl.local.selectedRepository.full_name"
skip-section="$ctrl.githost == 'custom-git'">
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.repositories">
<h3>Select Repository</h3>
<strong>
Select a repository in
<img class="namespace-avatar"
ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}"
ng-if="$ctrl.local.selectedNamespace.avatar_url">
{{ $ctrl.local.selectedNamespace.id }}
</strong>
<div style="display: flex; justify-content: flex-end;">
<div class="filter-options">
<label>
<input type="checkbox"
ng-model="$ctrl.local.repositoryOptions.hideStale"
ng-change="$ctrl.buildOrderedRepositories()">
Hide stale repositories
</label>
</div>
</div>
<cor-table table-data="$ctrl.local.orderedRepositories.entries"
table-item-title="repositories"
max-display-count="$ctrl.repositoriesPerPage"
filter-fields="['name', 'description']">
<cor-table-col bind-model="$ctrl.local.selectedRepository"
templateurl="/static/js/directives/ui/manage-trigger-githost/repository-radio-input.html"
style="width: 30px;">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-githost/repository-radio-input.html">
<span ng-if="!item.has_admin_permissions">
<i class="fa fa-exclamation-triangle"
data-title="Admin access is required to add the webhook trigger to this repository" bs-tooltip></i>
</span>
<div ng-if="item.has_admin_permissions">
<input type="radio"
ng-model="col.bindModel" ng-value="item"
ng-dblclick="col.bindModel = null">
</div>
</script>
</cor-table-col>
<cor-table-col title="Repository Name"
datafield="name"
sortfield="name"
bind-model="$ctrl.getTriggerIcon()"
templateurl="/static/js/directives/ui/manage-trigger-githost/repository-name.html">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-githost/repository-name.html">
<i class="service-icon fa {{ ::col.bindModel }}"></i>
<span class="anchor"
href="{{ item.url }}"
is-text-only="!item.url">{{ item.name }}</span>
</script>
</cor-table-col>
<cor-table-col title="Updated"
datafield="last_updated_datetime"
templateurl="/static/js/directives/ui/manage-trigger-githost/repository-last-updated.html">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-githost/repository-last-updated.html">
<span am-time-ago="item.last_updated_datetime"></span>
</script>
</cor-table-col>
<cor-table-col title="Description"
datafield="description"
sortfield="description"
templateurl="/static/js/directives/ui/manage-trigger-githost/repository-description.html"
class="co-flowing-col">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-githost/repository-description.html">
<span ng-if="item.description" class="repo-description">
{{ item.description }}
</span>
<span ng-if="!item.description" class="empty-description">(None)</span>
</script>
</cor-table-col>
</cor-table>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.repositories">
<span class="cor-loader-inline"></span> Retrieving repositories
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col"
ng-if="$ctrl.local.repositories">
<p>
A webhook will be added to the selected repository in order to detect when new commits are made.
</p>
<p>
Don't see an expected repository here? Please make sure you have admin access on that repository.
</p>
</div>
</linear-workflow-section><!-- /Section: Githost Repository -->
<!-- Section: Custom Git Repository -->
<linear-workflow-section class="row"
section-id="repo"
section-title="Git Repository"
section-valid="$ctrl.local.selectedRepository.full_name"
skip-section="$ctrl.githost != 'custom-git'">
<div class="col-lg-7 col-md-7 col-sm-12 main-col">
<h3>Enter repository</h3>
<strong>Please enter the HTTP or SSH style URL used to clone your git repository</strong>
<input class="form-control" type="text" placeholder="git@example.com:namespace/repository.git"
ng-model="$ctrl.buildSource"
ng-change="$ctrl.checkBuildSource($ctrl.buildSource)"
ng-pattern="/(((http|https):\/\/)(.+)|\w+@(.+):(.+))/">
</div>
<div class="col-lg-5 col-md-5 hidden-sm hidden-xs help-col">
<p>Custom git triggers support any externally accessible git repository, via either the normal git protocol or HTTP.</p>
<p><b>It is the responsibility of the git repository to invoke a webhook to tell <span class="registry-name" short="true"></span> that a commit has been added.</b></p>
</div>
</linear-workflow-section><!-- /Section: Repository -->
<!-- Section: Trigger Options -->
<linear-workflow-section class="row"
section-id="triggeroptions"
section-title="Configure Trigger"
section-valid="$ctrl.local.triggerOptions"
skip-section="$ctrl.githost == 'custom-git'">
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.repositoryRefs">
<h3>Configure Trigger</h3>
<strong>
Configure trigger options for
<img class="namespace-avatar"
ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}"
ng-if="$ctrl.local.selectedNamespace.avatar_url">
{{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }}
</strong>
<div class="radio" style="margin-top: 20px;">
<label>
<input type="radio" name="optionRadio"
ng-model="$ctrl.local.triggerOptions.hasBranchTagFilter"
ng-value="false">
<div class="title">Trigger for all branches and tags <span class="weak">(default)</span></div>
<div class="description">Build a container image for each commit across all branches and tags</div>
</label>
</div>
<div class="radio">
<label>
<input type="radio"
name="optionRadio"
ng-model="$ctrl.local.triggerOptions.hasBranchTagFilter"
ng-value="true">
<div class="title">Trigger only on branches and tags matching a regular expression</div>
<div class="description">Only build container images for a subset of branches and/or tags.</div>
<div class="extended"
ng-if="$ctrl.local.triggerOptions.hasBranchTagFilter">
<table>
<tr>
<td style="white-space: nowrap;">Regular Expression:</td>
<td>
<input type="text" class="form-control"
ng-model="$ctrl.local.triggerOptions.branchTagFilter"
required>
<div class="description">Examples: heads/master, tags/tagname, heads/.+</div>
<regex-match-view items="$ctrl.local.repositoryFullRefs"
regex="$ctrl.local.triggerOptions.branchTagFilter"
ng-if="$ctrl.local.triggerOptions.branchTagFilter"></regex-match-view>
</td>
</tr>
</table>
</div>
</label>
</div>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.repositoryRefs">
<span class="cor-loader-inline"></span> Retrieving repository refs
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
<p>Do you want to build a new container image for commits across all branches and tags, or limit to a subset?</p>
<p>For example, if you use release branches instead of <code>master</code> for building versions of your software, you can configure the trigger to only build images for these branches.</p>
<p>All images built will be tagged with the name of the branch or tag whose change invoked the trigger</p>
</div>
</linear-workflow-section><!-- /Section: Trigger Options -->
<!-- Section: Dockerfile Location -->
<linear-workflow-section class="row"
section-id="dockerfilelocation"
section-title="Select Dockerfile"
section-valid="$ctrl.local.hasValidDockerfilePath">
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.dockerfileLocations.status == 'error'">
<div class="co-alert co-alert-warning">
{{ $ctrl.local.dockerfileLocations.message }}
</div>
</div>
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.githost == 'custom-git' || $ctrl.local.dockerfileLocations.status == 'success'">
<h3>Select Dockerfile</h3>
<strong>Please select the location of the Dockerfile to build when this trigger is invoked</strong>
<dockerfile-path-select current-path="$ctrl.local.dockerfilePath"
paths="$ctrl.local.dockerfileLocations.dockerfile_paths"
supports-full-listing="true"
(path-changed)="$ctrl.checkDockerfilePath($event)"></dockerfile-path-select>
<span ng-if="$ctrl.local.dockerfilePath.split('/').splice(-1)[0] == ''"
style="color: #D64456;">
Dockerfile path must end with a file, probably named <code>Dockerfile</code>
</span>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="$ctrl.githost != 'custom-git' && !$ctrl.local.dockerfileLocations">
<span class="cor-loader-inline"></span> Retrieving Dockerfile locations
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
<p>Please select the location containing the Dockerfile to be built.</p>
<p>The Dockerfile path starts with the context and ends with the path to the Dockefile that you would like to build</p>
<p>If the Dockerfile is located at the root of the git repository and named Dockerfile, enter <code>/Dockerfile</code> as the Dockerfile path.</p>
</div>
</linear-workflow-section><!-- /Section: Dockerfile Location -->
<!-- Section: Context Location -->
<linear-workflow-section class="row"
section-id="contextlocation"
section-title="Select Docker Context"
section-valid="$ctrl.local.hasValidContextLocation">
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.dockerfileLocations.status == 'error'">
<div class="co-alert co-alert-warning">
{{ $ctrl.local.dockerfileLocations.message }}
</div>
</div>
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.githost == 'custom-git' || $ctrl.local.dockerfileLocations.status == 'success'">
<h3>Select Context</h3>
<strong>Please select the context for the Docker build</strong>
<context-path-select current-context="$ctrl.local.dockerContext"
contexts="$ctrl.local.contexts"
(context-changed)="$ctrl.checkBuildContext($event)"></context-path-select>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="$ctrl.githost != 'custom-git' && !$ctrl.local.dockerfileLocations">
<span class="cor-loader-inline"></span> Retrieving Dockerfile locations
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
<p>Please select a Docker context.</p>
<p>The build context directory is the path of the directory containing the Dockerfile and any other files to be made available when the build is triggered.</p>
<p>If the Dockerfile is located at the root of the git repository, enter <code>/</code> as the build context directory.</p>
</div>
</linear-workflow-section><!-- /Section: Context Location -->
<!-- Section: Robot Account -->
<linear-workflow-section class="row"
section-id="robot"
section-title="Robot Account"
section-valid="$ctrl.local.triggerAnalysis.status != 'error' &&
($ctrl.local.triggerAnalysis.status != 'requiresrobot' || $ctrl.local.robotAccount)">
<!-- Error -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.triggerAnalysis.status == 'error'">
<h3 class="error"><i class="fa fa-exclamation-circle"></i> Verification Error</h3>
<strong>
There was an error when verifying the state of <img class="namespace-avatar"
ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}"
ng-if="$ctrl.local.selectedNamespace.avatar_url">
{{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }}
</strong>
{{ $ctrl.local.triggerAnalysis.message }}
</div>
<!-- Robot display for non-error cases -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.triggerAnalysis.status != 'error'">
<!-- Warning -->
<div ng-if="$ctrl.local.triggerAnalysis.status == 'warning'">
<h3 class="warning"><i class="fa fa-exclamation-triangle"></i> Verification Warning</h3>
{{ $ctrl.local.triggerAnalysis.message }}
</div>
<!-- Public base -->
<div ng-if="$ctrl.local.triggerAnalysis.status == 'publicbase'">
<h3>Optional Robot Account</h3>
<strong>
<span ng-if="$ctrl.local.triggerAnalysis.is_admin">Choose an optional robot account below or click "Continue" to complete setup of this build trigger.</span>
</strong>
</div>
<!-- Requires robot and is not admin -->
<div ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' && !$ctrl.local.triggerAnalysis.is_admin">
<h3>Robot Account Required</h3>
<p>The selected Dockerfile in the selected repository depends upon a private base image.</p>
<p>A robot account with access to the base image is required to setup this trigger, but you are not the administrator of this namespace.</p>
<p>Administrative access is required to continue to ensure security of the robot credentials.</p>
</div>
<!-- Requires robot and is admin -->
<div ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' && $ctrl.local.triggerAnalysis.is_admin">
<h3>Robot Account Required</h3>
<p>The selected Dockerfile in the selected repository depends upon a private base image.</p>
<p>A robot account with access to the base image is required to setup this trigger.</p>
</div>
<!-- Robots view -->
<div ng-if="$ctrl.local.triggerAnalysis.is_admin">
<cor-table table-data="$ctrl.local.triggerAnalysis.robots"
table-item-title="robot accounts"
filter-fields="['name']"
max-display-count="$ctrl.robotsPerPage">
<cor-table-col datafield="name"
bind-model="$ctrl.local.robotAccount"
style="width: 30px;"
templateurl="/static/js/directives/ui/manage-trigger-custom-git/robot-radio-input.html">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-custom-git/robot-radio-input.html">
<input type="radio"
ng-model="col.bindModel" ng-value="item"
ng-dblclick="col.bindModel = null">
</script>
</cor-table-col>
<cor-table-col title="Robot Account"
sortfield="name"
templateurl="/static/js/directives/ui/manage-trigger-custom-git/robot-name.html">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-custom-git/robot-name.html">
<span class="entity-reference" entity="item"></span>
</script>
</cor-table-col>
<cor-table-col ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' || true"
datafield="can_read"
templateurl="/static/js/directives/ui/manage-trigger-custom-git/can-read.html">
<script type="text/ng-template" id="/static/js/directives/ui/manage-trigger-custom-git/can-read.html">
<span ng-if="item.can_read" class="success">Can Read</span>
<span ng-if="!item.can_read">Read access will be added if selected</span>
</script>
</cor-table-col>
</cor-table>
</div> <!-- /Robots view -->
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col"
ng-if="$ctrl.local.triggerAnalysis.is_admin">
<p>
In order for the <span class="registry-name"></span> to pull a <b>private base image</b> during the build
process, a robot account with access must be selected.
</p>
<p ng-if="$ctrl.local.triggerAnalysis.status != 'requiresrobot'">
If you know that a private base image is not used, you can skip this step.
</p>
<p ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot'">
Robot accounts that already have access to this base image are listed first. If you select a robot account
that does not currently have access, read permission will be granted to that robot account on trigger creation.
</p>
</div>
</linear-workflow-section><!-- /Section: Verification and Robot Account -->
<!-- Verification -->
<linear-workflow-section class="row"
section-id="verification"
section-title="Verification"
section-valid="true">
<span>
<h3 class="success"><i class="fa fa-check-circle"></i> Ready to go!</h3>
Click "Continue" to complete setup of this build trigger.
</span>
</linear-workflow-section><!-- /Section: Verification -->
</linear-workflow>
</div>

View file

@ -0,0 +1,283 @@
import { ManageTriggerComponent } from './manage-trigger.component';
import { Local, Trigger, Repository } from '../../../types/common.types';
import { ViewArray } from '../../../services/view-array/view-array';
import { ContextChangeEvent } from '../context-path-select/context-path-select.component';
import { PathChangeEvent } from '../dockerfile-path-select/dockerfile-path-select.component';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("ManageTriggerComponent", () => {
var component: ManageTriggerComponent;
var apiServiceMock: Mock<any>;
var tableServiceMock: Mock<any>;
var triggerServiceMock: Mock<any>;
var rolesServiceMock: Mock<any>;
var repository: any;
var $scopeMock: Mock<ng.IScope>;
beforeEach(() => {
apiServiceMock = new Mock<any>();
tableServiceMock = new Mock<any>();
triggerServiceMock = new Mock<any>();
rolesServiceMock = new Mock<any>();
$scopeMock = new Mock<ng.IScope>();
component = new ManageTriggerComponent(apiServiceMock.Object,
tableServiceMock.Object,
triggerServiceMock.Object,
rolesServiceMock.Object,
$scopeMock.Object);
component.repository = {namespace: "someuser", name: "somerepo"};
component.trigger = {id: "2cac6317-754e-47d4-88d3-2a50b3f09ee3", service: "github"};
});
describe("ngOnChanges", () => {
beforeEach(() => {
apiServiceMock.setup(mock => mock.listTriggerBuildSourceNamespaces).is(() => Promise.resolve({}));
apiServiceMock.setup(mock => mock.errorDisplay).is((message) => null);
$scopeMock.setup(mock => mock.$watch).is((val, callback) => null);
});
it("sets default values for config and selected namespace", () => {
component.ngOnChanges({});
expect(component.config).toEqual({});
expect(component.local.selectedNamespace).toBe(null);
});
});
describe("checkBuildSource", () => {
it("sets selected repository full name if given build source matches regex pattern", () => {
const buildSource: string = "git@somegithost.net:user/repo.git";
component.checkBuildSource(buildSource);
expect(component.local.selectedRepository.full_name).toEqual(buildSource);
});
it("sets selected repository full name to null if given build source does not match regex pattern", () => {
const buildSource: string = "a_randomstring";
component.checkBuildSource(buildSource);
expect(component.local.selectedRepository.full_name).toBe(null);
});
});
describe("getTriggerIcon", () => {
beforeEach(() => {
triggerServiceMock.setup(mock => mock.getIcon).is((service: any) => null);
});
it("calls trigger service to get icon", () => {
const icon: any = component.getTriggerIcon();
expect((<Spy>triggerServiceMock.Object.getIcon).calls.argsFor(0)[0]).toEqual(component.trigger.service);
});
});
describe("checkDockerfilePath", () => {
var event: PathChangeEvent;
beforeEach(() => {
event = {path: '/Dockerfile', isValid: true};
component.local.selectedRepository = {name: "", full_name: "someorg/somerepo"};
component.local.dockerContext = '/';
component.local.dockerfileLocations = {contextMap: {}};
spyOn(component, "analyzeDockerfilePath").and.returnValue(null);
});
it("sets local Dockerfile path and validity to given event values", () => {
component.checkDockerfilePath(event);
expect(component.local.hasValidDockerfilePath).toEqual(event.isValid);
expect(component.local.dockerfilePath).toEqual(event.path);
});
it("sets local Dockerfile contexts if present in local Dockerfile locations", () => {
component.local.dockerfileLocations.contextMap[event.path] = ['/', '/dir'];
component.checkDockerfilePath(event);
expect(component.local.contexts).toEqual(component.local.dockerfileLocations.contextMap[event.path]);
});
it("sets local Dockerfile contexts to empty array if given path not present in local Dockerfile locations", () => {
component.checkDockerfilePath(event);
expect(component.local.contexts).toEqual([]);
});
it("calls component method to analyze new Dockerfile path", () => {
component.checkDockerfilePath(event);
expect((<Spy>component.analyzeDockerfilePath).calls.argsFor(0)[0]).toEqual(component.local.selectedRepository);
expect((<Spy>component.analyzeDockerfilePath).calls.argsFor(0)[1]).toEqual(event.path);
expect((<Spy>component.analyzeDockerfilePath).calls.argsFor(0)[2]).toEqual(component.local.dockerContext);
});
});
describe("checkBuildContext", () => {
var event: ContextChangeEvent;
beforeEach(() => {
event = {contextDir: '/', isValid: true};
});
});
describe("analyzeDockerfilePath", () => {
var selectedRepository: Repository;
var path: string;
var context: string;
var robots: {robots: {[key: string]: any}[]};
var analysis: {[key: string]: any};
var orderedRobots: Mock<ViewArray>;
beforeEach(() => {
selectedRepository = {name: "", full_name: "someorg/somerepo"};
path = "/Dockerfile";
context = "/";
robots = {robots: [{name: 'robot'}]};
analysis = {'publicbase': true, robots: robots.robots};
orderedRobots = new Mock<ViewArray>();
apiServiceMock.setup(mock => mock.analyzeBuildTrigger).is((data, params) => Promise.resolve(analysis));
apiServiceMock.setup(mock => mock.getRobots).is((user, arg, params) => Promise.resolve(robots));
apiServiceMock.setup(mock => mock.errorDisplay).is((message) => null);
tableServiceMock.setup(mock => mock.buildOrderedItems).is((items, options, filterFields, numericFields) => orderedRobots.Object);
});
it("does nothing if given invalid Git repository", (done) => {
const invalidRepositories: Repository[] = [null];
invalidRepositories.forEach((repo, index) => {
component.analyzeDockerfilePath(repo, path, context);
expect((<Spy>apiServiceMock.Object.analyzeBuildTrigger)).not.toHaveBeenCalled();
if (index == invalidRepositories.length - 1) {
done();
}
});
});
it("uses default values for Dockerfile path and context if not given", (done) => {
const spy: Spy = <Spy>apiServiceMock.Object.analyzeBuildTrigger;
component.analyzeDockerfilePath(selectedRepository);
setTimeout(() => {
expect(spy.calls.argsFor(0)[0]['config']['build_source']).toEqual(selectedRepository.full_name);
expect(spy.calls.argsFor(0)[0]['config']['dockerfile_path']).toEqual('Dockerfile');
expect(spy.calls.argsFor(0)[0]['config']['context']).toEqual('/');
expect(spy.calls.argsFor(0)[1]['repository']).toEqual(`${component.repository.namespace}/${component.repository.name}`);
expect(spy.calls.argsFor(0)[1]['trigger_uuid']).toEqual(component.trigger.id);
done();
}, 10);
});
it("calls API service to analyze build trigger config with given values", (done) => {
const spy: Spy = <Spy>apiServiceMock.Object.analyzeBuildTrigger;
component.analyzeDockerfilePath(selectedRepository, path, context);
setTimeout(() => {
expect(spy.calls.argsFor(0)[0]['config']['build_source']).toEqual(selectedRepository.full_name);
expect(spy.calls.argsFor(0)[0]['config']['dockerfile_path']).toEqual(path.substr(1));
expect(spy.calls.argsFor(0)[0]['config']['context']).toEqual(context);
expect(spy.calls.argsFor(0)[1]['repository']).toEqual(`${component.repository.namespace}/${component.repository.name}`);
expect(spy.calls.argsFor(0)[1]['trigger_uuid']).toEqual(component.trigger.id);
done();
}, 10);
});
it("calls API service to display error if API service's trigger analysis fails", (done) => {
apiServiceMock.setup(mock => mock.analyzeBuildTrigger).is((data, params) => Promise.reject("Error"));
component.analyzeDockerfilePath(selectedRepository, path, context);
setTimeout(() => {
expect((<Spy>apiServiceMock.Object.errorDisplay).calls.argsFor(0)[0]).toEqual('Could not analyze trigger');
done();
}, 10);
});
it("updates component trigger analysis with successful trigger analysis response", (done) => {
component.analyzeDockerfilePath(selectedRepository, path, context);
setTimeout(() => {
expect(component.local.triggerAnalysis).toEqual(analysis);
done();
}, 10);
});
});
describe("createTrigger", () => {
beforeEach(() => {
component.local.selectedRepository = new Mock<Repository>().Object;
component.local.selectedRepository.full_name = "someorg/some-repository";
component.local.dockerfilePath = "/Dockerfile";
component.local.dockerContext = "/";
component.local.triggerOptions = {};
component.local.triggerAnalysis = {};
rolesServiceMock.setup(mock => mock.setRepositoryRole).is((repo, role, entityKind, entityName, callback) => {
callback();
});
});
it("does not call roles service if robot is required but robot is not selected", (done) => {
component.local.triggerAnalysis = {status: 'requiresrobot', name: 'privatebase', namespace: 'someorg'};
component.local.robotAccount = null;
component.activateTrigger.subscribe((event: {config: any, pull_robot: any}) => {
expect((<Spy>rolesServiceMock.Object.setRepositoryRole)).not.toHaveBeenCalled();
done();
});
component.createTrigger();
});
it("calls roles service to grant read access to selected robot if robot is required and cannot read", (done) => {
component.local.triggerAnalysis = {status: 'requiresrobot', name: 'privatebase', namespace: 'someorg'};
component.local.robotAccount = {can_read: false, is_robot: true, kind: 'user', name: 'test-robot'};
component.activateTrigger.subscribe((event: {config: any, pull_robot: any}) => {
expect((<Spy>rolesServiceMock.Object.setRepositoryRole).calls.argsFor(0)[0]).toEqual({
name: component.local.triggerAnalysis.name,
namespace: component.local.triggerAnalysis.namespace,
});
expect((<Spy>rolesServiceMock.Object.setRepositoryRole).calls.argsFor(0)[1]).toEqual('read');
expect((<Spy>rolesServiceMock.Object.setRepositoryRole).calls.argsFor(0)[2]).toEqual('robot');
done();
});
component.createTrigger();
});
it("does not call roles service if robot is required but already has read access", (done) => {
component.local.triggerAnalysis = {status: 'requiresrobot', name: 'privatebase', namespace: 'someorg'};
component.local.robotAccount = {can_read: true, is_robot: true, kind: 'user', name: 'test-robot'};
component.activateTrigger.subscribe((event: {config: any, pull_robot: any}) => {
expect((<Spy>rolesServiceMock.Object.setRepositoryRole)).not.toHaveBeenCalled();
done();
});
component.createTrigger();
});
it("does not call roles service if robot is not required", (done) => {
component.local.triggerAnalysis = {status: 'publicbase', name: 'publicrepo', namespace: 'someorg'};
component.activateTrigger.subscribe((event: {config: any, pull_robot: any}) => {
expect((<Spy>rolesServiceMock.Object.setRepositoryRole)).not.toHaveBeenCalled();
done();
});
component.createTrigger();
});
it("emits output event with config and pull robot", (done) => {
component.activateTrigger.subscribe((event: {config: any, pull_robot: any}) => {
expect(event.config.build_source).toEqual(component.local.selectedRepository.full_name);
expect(event.config.dockerfile_path).toEqual(component.local.dockerfilePath);
expect(event.config.context).toEqual(component.local.dockerContext);
done();
});
component.createTrigger();
});
});
});

View file

@ -0,0 +1,338 @@
import { Input, Output, Component, Inject, EventEmitter, OnChanges, SimpleChanges } from 'ng-metadata/core';
import * as moment from 'moment';
import { Local, Trigger, TriggerConfig, Repository, Namespace } from '../../../types/common.types';
import { ContextChangeEvent } from '../context-path-select/context-path-select.component';
import { PathChangeEvent } from '../dockerfile-path-select/dockerfile-path-select.component';
/**
* A component that lets the user set up a build trigger for a public Git repository host service.
*/
@Component({
selector: 'manage-trigger',
templateUrl: '/static/js/directives/ui/manage-trigger/manage-trigger.component.html'
})
export class ManageTriggerComponent implements OnChanges {
@Input('<') public githost: string = 'custom-git';
@Input('<') public repository: Repository;
@Input('<') public trigger: Trigger;
@Output() public activateTrigger: EventEmitter<{config: TriggerConfig, pull_robot?: any}> = new EventEmitter();
public config: TriggerConfig;
public local: Local = {
selectedRepository: {name: ''},
hasValidDockerfilePath: false,
dockerfileLocations: [],
triggerOptions: {},
namespaceOptions: {filter: '', predicate: 'score', reverse: false, page: 0},
repositoryOptions: {filter: '', predicate: 'score', reverse: false, page: 0, hideStale: true},
robotOptions: {filter: '', predicate: 'score', reverse: false, page: 0},
};
private namespacesPerPage: number = 10;
private repositoriesPerPage: number = 10;
private robotsPerPage: number = 10;
private namespaceTitle: string;
private namespace: any;
private buildSource: string;
constructor(@Inject('ApiService') private apiService: any,
@Inject('TableService') private tableService: any,
@Inject('TriggerService') private triggerService: any,
@Inject('RolesService') private rolesService: any,
@Inject('$scope') private $scope: ng.IScope) {
}
public ngOnChanges(changes: SimpleChanges): void {
if (this.githost && this.repository && this.trigger) {
this.config = this.trigger.config || {};
this.namespaceTitle = 'organization';
this.local.selectedNamespace = null;
if (this.githost != 'custom-git') {
this.loadNamespaces();
}
// FIXME (Alec 5/26/17): Need to have watchers here because cor-table doesn't have ng-change functionality yet
this.$scope.$watch(() => this.local.selectedNamespace, (namespace: Namespace) => {
if (namespace) {
this.loadRepositories(namespace);
}
});
this.$scope.$watch(() => this.local.selectedRepository, (selectedRepository: Repository) => {
if (selectedRepository && this.githost != 'custom-git') {
this.loadRepositoryRefs(selectedRepository);
this.loadDockerfileLocations(selectedRepository);
}
});
}
}
public getTriggerIcon(): any {
return this.triggerService.getIcon(this.trigger.service);
}
public checkBuildSource(buildSource: string): void {
const buildSourceRegExp = new RegExp(/(((http|https):\/\/)(.+)|\w+@(.+):(.+))/, 'i');
try {
this.local.selectedRepository.full_name = buildSourceRegExp.test(buildSource) ? buildSource : null;
} catch (error) {
this.local.selectedRepository.full_name = null;
}
}
public checkDockerfilePath(event: PathChangeEvent): void {
this.local.hasValidDockerfilePath = event.isValid && event.path.split('/')[event.path.split('/').length - 1] != '';
this.local.dockerfilePath = event.path;
if (event.path && this.local.selectedRepository) {
this.setPossibleContexts(event.path);
this.analyzeDockerfilePath(this.local.selectedRepository, this.local.dockerfilePath, this.local.dockerContext);
}
}
public checkBuildContext(event: ContextChangeEvent): void {
this.local.hasValidContextLocation = event.isValid;
this.local.dockerContext = event.contextDir;
if (event.contextDir && this.local.selectedRepository) {
this.analyzeDockerfilePath(this.local.selectedRepository, this.local.dockerfilePath, this.local.dockerContext);
}
}
public analyzeDockerfilePath(selectedRepo: Repository, path: string = '/Dockerfile', context: string = '/'): void {
if (selectedRepo != undefined && selectedRepo.full_name) {
this.local.triggerAnalysis = null;
this.local.robotAccount = null;
const params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id
};
const config: TriggerConfig = {
build_source: selectedRepo.full_name,
dockerfile_path: path.substr(1),
context: context
};
const data = {config: config};
// Try to analyze git repository, fall back to retrieving all namespace's robots
this.apiService.analyzeBuildTrigger(data, params)
.then((resp) => {
if (resp['status'] === 'notimplemented') {
return this.apiService.getRobots(this.repository.namespace, null, {'permissions': true});
} else {
this.local.triggerAnalysis = Object.assign({}, resp);
}
})
.catch((error) => {
this.apiService.errorDisplay('Could not analyze trigger');
})
.then((resp) => {
if (resp) {
this.local.triggerAnalysis = {
status: 'publicbase',
is_admin: true,
robots: resp.robots,
name: this.repository.name,
namespace: this.repository.namespace
};
}
this.buildOrderedRobotAccounts();
})
.catch((error) => {
this.apiService.errorDisplay('Could not retrieve robot accounts');
});
}
}
public createTrigger(): void {
var config: TriggerConfig = {
build_source: this.local.selectedRepository.full_name,
dockerfile_path: this.local.dockerfilePath,
context: this.local.dockerContext
};
if (this.local.triggerOptions['hasBranchTagFilter'] && this.local.triggerOptions['branchTagFilter']) {
config.branchtag_regex = this.local.triggerOptions['branchTagFilter'];
}
const activate = () => {
this.activateTrigger.emit({config: config, pull_robot: Object.assign({}, this.local.robotAccount)});
};
if (this.local.triggerAnalysis.status == 'requiresrobot' && this.local.robotAccount) {
if (this.local.robotAccount.can_read) {
activate();
} else {
// Add read permission onto the base repository for the robot and then activate the trigger.
const baseRepo: any = {name: this.local.triggerAnalysis.name, namespace: this.local.triggerAnalysis.namespace};
this.rolesService.setRepositoryRole(baseRepo, 'read', 'robot', this.local.robotAccount.name, activate);
}
} else {
activate();
}
}
private setPossibleContexts(path: string) {
if (this.local.dockerfileLocations.contextMap) {
this.local.contexts = this.local.dockerfileLocations.contextMap[path] || [];
} else {
this.local.contexts = [path.split('/').slice(0, -1).join('/').concat('/')];
}
}
private buildOrderedNamespaces(): void {
if (this.local.namespaces) {
this.local.maxScore = 0;
this.local.namespaces.forEach((namespace) => {
this.local.maxScore = Math.max(namespace.score, this.local.maxScore);
});
}
}
private loadNamespaces(): void {
this.local.namespaces = null;
this.local.selectedNamespace = null;
this.local.orderedNamespaces = null;
this.local.selectedRepository = null;
this.local.orderedRepositories = null;
var params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id
};
this.apiService.listTriggerBuildSourceNamespaces(null, params)
.then((resp) => {
this.local.namespaces = resp['namespaces'];
this.local.repositories = null;
this.buildOrderedNamespaces();
}, this.apiService.errorDisplay('Could not retrieve the list of ' + this.namespaceTitle));
}
private buildOrderedRepositories(): void {
if (this.local.repositories) {
var repositories = this.local.repositories || [];
repositories.forEach((repository) => {
repository['last_updated_datetime'] = new Date(repository['last_updated'] * 1000);
});
if (this.local.repositoryOptions.hideStale) {
var existingRepositories = repositories;
repositories = repositories.filter((repository) => {
var older_date = moment(repository['last_updated_datetime']).add(1, 'months');
return !moment().isAfter(older_date);
});
if (existingRepositories.length > 0 && repositories.length == 0) {
repositories = existingRepositories;
}
}
this.local.orderedRepositories = this.tableService.buildOrderedItems(repositories,
this.local.repositoryOptions,
['name', 'description'],
[]);
}
}
private loadRepositories(namespace: any): void {
this.local.repositories = null;
this.local.selectedRepository = null;
this.local.repositoryRefs = null;
this.local.triggerOptions = {
'hasBranchTagFilter': false
};
this.local.orderedRepositories = null;
const params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id
};
const data = {
'namespace': namespace.id
};
this.apiService.listTriggerBuildSources(data, params).then((resp) => {
if (namespace == this.local.selectedNamespace) {
this.local.repositories = resp['sources'];
this.buildOrderedRepositories();
}
}, this.apiService.errorDisplay('Could not retrieve repositories'));
}
private loadRepositoryRefs(repository: any): void {
this.local.repositoryRefs = null;
this.local.triggerOptions = {
'hasBranchTagFilter': false
};
const params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id,
'field_name': 'refs'
};
const config = {
'build_source': repository.full_name
};
this.apiService.listTriggerFieldValues(config, params).then((resp) => {
if (repository == this.local.selectedRepository) {
this.local.repositoryRefs = resp['values'];
this.local.repositoryFullRefs = resp['values'].map((ref) => {
const kind = ref.kind == 'branch' ? 'heads' : 'tags';
const icon = ref.kind == 'branch' ? 'fa-code-fork' : 'fa-tag';
return {
'value': `${kind}/${ref.name}`,
'icon': icon,
'title': ref.name
};
});
}
}, this.apiService.errorDisplay('Could not retrieve repository refs'));
}
private loadDockerfileLocations(repository: any): void {
this.local.dockerfilePath = null;
this.local.dockerContext = null;
const params = {
'repository': this.repository.namespace + '/' + this.repository.name,
'trigger_uuid': this.trigger.id
};
const config: TriggerConfig = {build_source: repository.full_name};
this.apiService.listBuildTriggerSubdirs(config, params)
.then((resp) => {
if (repository == this.local.selectedRepository) {
this.local.dockerfileLocations = resp;
}
})
.catch((error) => {
this.apiService.errorDisplay('Could not retrieve Dockerfile locations');
});
}
private buildOrderedRobotAccounts(): void {
if (this.local.triggerAnalysis && this.local.triggerAnalysis.robots) {
this.local.triggerAnalysis.robots = this.local.triggerAnalysis.robots.map((robot) => {
robot.kind = robot.kind || 'user';
robot.is_robot = robot.is_robot || true;
return robot;
});
this.local.orderedRobotAccounts = this.tableService.buildOrderedItems(this.local.triggerAnalysis.robots,
this.local.robotOptions,
['name'],
[]);
}
}
}

View file

@ -0,0 +1,65 @@
import { element, by, browser, $, ElementFinder, ExpectedConditions as until } from 'protractor';
export class ManageTriggerViewObject {
public sections: {[name: string]: ElementFinder} = {
namespace: $('linear-workflow-section[section-id=namespace]'),
githostrepo: $('linear-workflow-section[section-id=repo][section-title="Select Repository"]'),
customrepo: $('linear-workflow-section[section-id=repo][section-title="Git Repository"]'),
triggeroptions: $('linear-workflow-section[section-id=triggeroptions]'),
dockerfilelocation: $('linear-workflow-section[section-id=dockerfilelocation]'),
contextlocation: $('linear-workflow-section[section-id=contextlocation]'),
robot: $('linear-workflow-section[section-id=robot]'),
verification: $('linear-workflow-section[section-id=verification]'),
};
private customGitRepoInput: ElementFinder = element(by.model('$ctrl.buildSource'));
private dockerfileLocationInput: ElementFinder = this.sections['dockerfilelocation'].$('input');
private dockerfileLocationDropdownButton: ElementFinder = this.sections['dockerfilelocation']
.$('button[data-toggle=dropdown');
private dockerContextInput: ElementFinder = this.sections['contextlocation'].$('input');
private dockerContextDropdownButton: ElementFinder = this.sections['contextlocation']
.$('button[data-toggle=dropdown');
private robotAccountOptions: ElementFinder = this.sections['robot']
.element(by.repeater('$ctrl.orderedData.visibleEntries'));
public continue(): Promise<void> {
return Promise.resolve(element(by.buttonText('Continue')).click());
}
public enterRepositoryURL(url: string): Promise<void> {
browser.wait(until.presenceOf(this.customGitRepoInput));
this.customGitRepoInput.clear();
return Promise.resolve(this.customGitRepoInput.sendKeys(url));
}
public enterDockerfileLocation(path: string): Promise<void> {
browser.wait(until.presenceOf(this.dockerfileLocationInput));
this.dockerfileLocationInput.clear();
return Promise.resolve(this.dockerfileLocationInput.sendKeys(path));
}
public getDockerfileSuggestions(): Promise<string[]> {
return Promise.resolve(this.dockerfileLocationDropdownButton.click())
.then(() => element.all(by.repeater('$ctrl.paths')).map(result => result.getText()));
}
public enterDockerContext(path: string): Promise<void> {
browser.wait(until.presenceOf(this.dockerContextInput));
this.dockerContextInput.clear();
return Promise.resolve(this.dockerContextInput.sendKeys(path));
}
public getDockerContextSuggestions(): Promise<string[]> {
return Promise.resolve(this.dockerContextDropdownButton.click())
.then(() => element.all(by.repeater('$ctrl.contexts')).map(result => result.getText()));
}
public selectRobotAccount(index: number): Promise<void> {
return Promise.resolve(element.all(by.css('input[type=radio]')).get(index).click());
}
}

View file

@ -34,10 +34,6 @@ angular.module('quay').directive('manualTriggerBuildDialog', function () {
}, ApiService.errorDisplay('Could not start build'));
};
$scope.getPattern = function(field) {
return new RegExp(field.regex);
};
$scope.show = function() {
$scope.parameters = {};
$scope.fieldOptions = {};

View file

@ -1,32 +0,0 @@
/**
* An element which display an inline editor for writing and previewing markdown text.
*/
angular.module('quay').directive('markdownEditor', function () {
var counter = 0;
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/markdown-editor.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'content': '=content',
},
controller: function($scope, $element, $timeout) {
$scope.id = (counter++);
$scope.previewing = false;
$timeout(function() {
var converter = Markdown.getSanitizingConverter();
var editor = new Markdown.Editor(converter, '-' + $scope.id);
editor.run();
});
$scope.togglePreview = function() {
$scope.previewing = !$scope.previewing;
};
}
};
return directiveDefinitionObject;
});

View file

@ -1,49 +0,0 @@
/**
* An element which allows for entry of markdown content and previewing its rendering.
*/
angular.module('quay').directive('markdownInput', function () {
var counter = 0;
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/markdown-input.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'content': '=content',
'canWrite': '=canWrite',
'contentChanged': '=contentChanged',
'fieldTitle': '=fieldTitle'
},
controller: function($scope, $element) {
var elm = $element[0];
$scope.id = (counter++);
$scope.editContent = function() {
if (!$scope.canWrite) { return; }
if (!$scope.markdownDescriptionEditor) {
var converter = Markdown.getSanitizingConverter();
var editor = new Markdown.Editor(converter, '-description-' + $scope.id);
editor.run();
$scope.markdownDescriptionEditor = editor;
}
$('#wmd-input-description-' + $scope.id)[0].value = $scope.content;
$(elm).find('.modal').modal({});
};
$scope.saveContent = function() {
$scope.content = $('#wmd-input-description-' + $scope.id)[0].value;
$(elm).find('.modal').modal('hide');
if ($scope.contentChanged) {
$scope.contentChanged($scope.content);
}
};
}
};
return directiveDefinitionObject;
});

View file

@ -1,26 +0,0 @@
/**
* An element which displays its content processed as markdown.
*/
angular.module('quay').directive('markdownView', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/markdown-view.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'content': '=content',
'firstLineOnly': '=firstLineOnly',
'placeholderNeeded': '=placeholderNeeded'
},
controller: function($scope, $element, $sce, UtilService) {
$scope.getMarkedDown = function(content, firstLineOnly) {
if (firstLineOnly) {
return $sce.trustAsHtml(UtilService.getFirstMarkdownLineAsText(content, $scope.placeholderNeeded));
}
return $sce.trustAsHtml(UtilService.getMarkedDown(content));
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,24 @@
.markdown-editor-element textarea {
height: 300px;
resize: vertical;
width: 100%;
}
.markdown-editor-actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.markdown-editor-buttons {
display: flex;
justify-content: space-between;
}
.markdown-editor-buttons button {
margin: 0 10px;
}
.markdown-editor-buttons button:last-child {
margin: 0;
}

View file

@ -0,0 +1,43 @@
<div class="markdown-editor-element">
<!-- Write/preview tabs -->
<ul class="nav nav-tabs" style="width: 100%;">
<li role="presentation" ng-class="$ctrl.editMode == 'write' ? 'active': ''"
ng-click="$ctrl.changeEditMode('write')">
<a href="#">Write</a>
</li>
<li role="presentation" ng-class="$ctrl.editMode == 'preview' ? 'active': ''"
ng-click="$ctrl.changeEditMode('preview')">
<a href="#">Preview</a>
</li>
<!-- Editing toolbar -->
<li style="float: right;">
<markdown-toolbar ng-if="$ctrl.editMode == 'write'"
(insert-symbol)="$ctrl.insertSymbol($event)"></markdown-toolbar>
</li>
</ul>
<div class="tab-content" style="padding: 10px 0 0 0;">
<div ng-show="$ctrl.editMode == 'write'">
<textarea id="markdown-textarea"
placeholder="Enter {{ ::$ctrl.fieldTitle }}"
ng-model="$ctrl.content"></textarea>
</div>
<div class="markdown-editor-preview"
ng-if="$ctrl.editMode == 'preview'">
<markdown-view content="$ctrl.content"></markdown-view>
</div>
</div>
<div class="markdown-editor-actions">
<div class="markdown-editor-buttons">
<button type="button" class="btn btn-default"
ng-click="$ctrl.discardChanges()">
Close
</button>
<button type="button" class="btn btn-primary"
ng-click="$ctrl.saveChanges()">
Save changes
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,147 @@
import { MarkdownEditorComponent, EditMode } from './markdown-editor.component';
import { MarkdownSymbol } from '../../../types/common.types';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("MarkdownEditorComponent", () => {
var component: MarkdownEditorComponent;
var textarea: Mock<ng.IAugmentedJQuery | any>;
var documentMock: Mock<HTMLElement & Document>;
beforeEach(() => {
textarea = new Mock<ng.IAugmentedJQuery | any>();
documentMock = new Mock<HTMLElement & Document>();
const $documentMock: any = [documentMock.Object];
component = new MarkdownEditorComponent($documentMock, 'chrome');
component.textarea = textarea.Object;
});
describe("changeEditMode", () => {
it("sets component's edit mode to given mode", () => {
const editMode: EditMode = "preview";
component.changeEditMode(editMode);
expect(component.currentEditMode).toEqual(editMode);
});
});
describe("insertSymbol", () => {
var event: {symbol: MarkdownSymbol};
var markdownSymbols: {type: MarkdownSymbol, characters: string, shiftBy: number}[];
var innerText: string;
beforeEach(() => {
event = {symbol: 'heading1'};
innerText = "Here is some text";
markdownSymbols = [
{type: 'heading1', characters: '# ', shiftBy: 2},
{type: 'heading2', characters: '## ', shiftBy: 3},
{type: 'heading3', characters: '### ', shiftBy: 4},
{type: 'bold', characters: '****', shiftBy: 2},
{type: 'italics', characters: '__', shiftBy: 1},
{type: 'bulleted-list', characters: '- ', shiftBy: 2},
{type: 'numbered-list', characters: '1. ', shiftBy: 3},
{type: 'quote', characters: '> ', shiftBy: 2},
{type: 'link', characters: '[](url)', shiftBy: 1},
{type: 'code', characters: '``', shiftBy: 1},
];
textarea.setup(mock => mock.focus);
textarea.setup(mock => mock.substr).is((start, end) => '');
textarea.setup(mock => mock.val).is((value?) => innerText);
textarea.setup(mock => mock.prop).is((prop) => {
switch (prop) {
case "selectionStart":
return 0;
case "selectionEnd":
return 0;
}
});
documentMock.setup(mock => mock.execCommand).is((commandID, showUI, value) => false);
});
it("focuses on markdown textarea", () => {
component.insertSymbol(event);
expect(<Spy>textarea.Object.focus).toHaveBeenCalled();
});
it("inserts correct characters for given symbol at cursor position", () => {
markdownSymbols.forEach((symbol) => {
event.symbol = symbol.type;
component.insertSymbol(event);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(symbol.characters);
(<Spy>documentMock.Object.execCommand).calls.reset();
});
});
it("splices highlighted selection between inserted characters instead of deleting them", () => {
markdownSymbols.slice(0, 1).forEach((symbol) => {
textarea.setup(mock => mock.prop).is((prop) => {
switch (prop) {
case "selectionStart":
return 0;
case "selectionEnd":
return innerText.length;
}
});
event.symbol = symbol.type;
component.insertSymbol(event);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(`${symbol.characters.slice(0, symbol.shiftBy)}${innerText}${symbol.characters.slice(symbol.shiftBy, symbol.characters.length)}`);
(<Spy>documentMock.Object.execCommand).calls.reset();
});
});
it("moves cursor to correct position for given symbol", () => {
markdownSymbols.forEach((symbol) => {
event.symbol = symbol.type;
component.insertSymbol(event);
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[0]).toEqual('selectionStart');
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[1]).toEqual(symbol.shiftBy);
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[0]).toEqual('selectionEnd');
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[1]).toEqual(symbol.shiftBy);
(<Spy>textarea.Object.prop).calls.reset();
});
});
});
describe("saveChanges", () => {
beforeEach(() => {
component.content = "# Some markdown content";
});
it("emits output event with changed content", (done) => {
component.save.subscribe((event: {editedContent: string}) => {
expect(event.editedContent).toEqual(component.content);
done();
});
component.saveChanges();
});
});
describe("discardChanges", () => {
it("emits output event with no content", (done) => {
component.discard.subscribe((event: {}) => {
expect(event).toEqual({});
done();
});
component.discardChanges();
});
});
});

View file

@ -0,0 +1,135 @@
import { Component, Inject, Input, Output, EventEmitter, ViewChild } from 'ng-metadata/core';
import { MarkdownSymbol } from '../../../types/common.types';
import { BrowserPlatform } from '../../../constants/platform.constant';
import './markdown-editor.component.css';
/**
* An editing interface for Markdown content.
*/
@Component({
selector: 'markdown-editor',
templateUrl: '/static/js/directives/ui/markdown/markdown-editor.component.html'
})
export class MarkdownEditorComponent {
@Input('<') public content: string;
@Output() public save: EventEmitter<{editedContent: string}> = new EventEmitter();
@Output() public discard: EventEmitter<any> = new EventEmitter();
// Textarea is public for testability, should not be directly accessed
@ViewChild('#markdown-textarea') public textarea: ng.IAugmentedJQuery;
private editMode: EditMode = "write";
constructor(@Inject('$document') private $document: ng.IDocumentService,
@Inject('BrowserPlatform') private browserPlatform: BrowserPlatform) {
}
public changeEditMode(newMode: EditMode): void {
this.editMode = newMode;
}
public insertSymbol(event: {symbol: MarkdownSymbol}): void {
this.textarea.focus();
const startPos: number = this.textarea.prop('selectionStart');
const endPos: number = this.textarea.prop('selectionEnd');
const innerText: string = this.textarea.val().slice(startPos, endPos);
var shiftBy: number = 0;
var characters: string = '';
switch (event.symbol) {
case 'heading1':
characters = '# ';
shiftBy = 2;
break;
case 'heading2':
characters = '## ';
shiftBy = 3;
break;
case 'heading3':
characters = '### ';
shiftBy = 4;
break;
case 'bold':
characters = '****';
shiftBy = 2;
break;
case 'italics':
characters = '__';
shiftBy = 1;
break;
case 'bulleted-list':
characters = '- ';
shiftBy = 2;
break;
case 'numbered-list':
characters = '1. ';
shiftBy = 3;
break;
case 'quote':
characters = '> ';
shiftBy = 2;
break;
case 'link':
characters = '[](url)';
shiftBy = 1;
break;
case 'code':
characters = '``';
shiftBy = 1;
break;
}
const cursorPos: number = startPos + shiftBy;
if (startPos != endPos) {
this.insertText(`${characters.slice(0, shiftBy)}${innerText}${characters.slice(shiftBy, characters.length)}`,
startPos,
endPos);
}
else {
this.insertText(characters, startPos, endPos);
}
this.textarea.prop('selectionStart', cursorPos);
this.textarea.prop('selectionEnd', cursorPos);
}
public saveChanges(): void {
this.save.emit({editedContent: this.content});
}
public discardChanges(): void {
this.discard.emit({});
}
public get currentEditMode(): EditMode {
return this.editMode;
}
/**
* Insert text in such a way that the browser adds it to the 'undo' stack. This has different feature support
* depending on the platform.
*/
private insertText(text: string, startPos: number, endPos: number): void {
if (this.browserPlatform === 'firefox') {
// FIXME: Ctrl-Z highlights previous text
this.textarea.val(<string>this.textarea.val().substr(0, startPos) +
text +
<string>this.textarea.val().substr(endPos, this.textarea.val().length));
}
else {
// TODO: Test other platforms (IE...)
this.$document[0].execCommand('insertText', false, text);
}
}
}
/**
* Type representing the current editing mode.
*/
export type EditMode = "write" | "preview";

View file

@ -0,0 +1,14 @@
.markdown-input-container .glyphicon-edit {
float: right;
color: #ddd;
transition: color 0.5s ease-in-out;
}
.markdown-input-container .glyphicon-edit:hover {
cursor: pointer;
color: #444;
}
.markdown-input-placeholder-editable:hover {
cursor: pointer;
}

View file

@ -0,0 +1,29 @@
<div class="markdown-input-container">
<div>
<span class="glyphicon glyphicon-edit"
ng-if="$ctrl.canWrite && !$ctrl.isEditing"
ng-click="$ctrl.editContent()"
data-title="Edit {{ ::$ctrl.fieldTitle }}" data-placement="left" bs-tooltip></span>
<div ng-if="$ctrl.content && !$ctrl.isEditing">
<markdown-view content="$ctrl.content"></markdown-view>
</div>
<!-- Not set and can write -->
<span class="markdown-input-placeholder-editable"
ng-if="!$ctrl.content && $ctrl.canWrite"
ng-click="$ctrl.editContent()">
<i>Click to set {{ ::$ctrl.fieldTitle }}</i>
</span>
<!-- Not set and cannot write -->
<span class="markdown-input-placeholder"
ng-if="!$ctrl.content && !$ctrl.canWrite">
<i>No {{ ::$ctrl.fieldTitle }} has been set</i>
</span>
</div>
<!-- Inline editor -->
<div ng-if="$ctrl.isEditing" style="margin-top: 20px;">
<markdown-editor content="$ctrl.content"
(save)="$ctrl.saveContent($event)"
(discard)="$ctrl.discardContent($event)"></markdown-editor>
</div>
</div>

View file

@ -0,0 +1,34 @@
import { MarkdownInputComponent } from './markdown-input.component';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("MarkdownInputComponent", () => {
var component: MarkdownInputComponent;
beforeEach(() => {
component = new MarkdownInputComponent();
});
describe("editContent", () => {
});
describe("saveContent", () => {
var editedContent: string;
it("emits output event with changed content", (done) => {
editedContent = "# Some markdown here";
component.contentChanged.subscribe((event: {content: string}) => {
expect(event.content).toEqual(editedContent);
done();
});
component.saveContent({editedContent: editedContent});
});
});
describe("discardContent", () => {
});
});

View file

@ -0,0 +1,32 @@
import { Component, Input, Output, EventEmitter } from 'ng-metadata/core';
import './markdown-input.component.css';
/**
* Displays editable Markdown content.
*/
@Component({
selector: 'markdown-input',
templateUrl: '/static/js/directives/ui/markdown/markdown-input.component.html'
})
export class MarkdownInputComponent {
@Input('<') public content: string;
@Input('<') public canWrite: boolean;
@Input('@') public fieldTitle: string;
@Output() public contentChanged: EventEmitter<{content: string}> = new EventEmitter();
private isEditing: boolean = false;
public editContent(): void {
this.isEditing = true;
}
public saveContent(event: {editedContent: string}): void {
this.contentChanged.emit({content: event.editedContent});
this.isEditing = false;
}
public discardContent(event: any): void {
this.isEditing = false;
}
}

View file

@ -0,0 +1,8 @@
.markdown-toolbar-element .dropdown-menu li > * {
margin: 0 4px;
}
.markdown-toolbar-element .dropdown-menu li:hover {
cursor: pointer;
background-color: #e6e6e6;
}

View file

@ -0,0 +1,61 @@
<div class="markdown-toolbar-element">
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<div class="btn-group">
<button type="button" class="btn btn-default btn-sm dropdown-toggle"
data-toggle="dropdown"
data-title="Add header" data-container="body" bs-tooltip>
<span class="glyphicon glyphicon-text-size"></span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading1'})"><h2>Heading</h2></li>
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading2'})"><h3>Heading</h3></li>
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading3'})"><h4>Heading</h4></li>
</ul>
</div>
<button type="button" class="btn btn-default btn-sm"
data-title="Bold" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'bold'})">
<span class="glyphicon glyphicon-bold"></span>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="Italics" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'italics'})">
<span class="glyphicon glyphicon-italic"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default btn-sm"
data-title="Block quote" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'quote'})">
<i class="fa fa-quote-left" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="Code snippet" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'code'})">
<span class="glyphicon glyphicon-menu-left" style="margin-right: -6px;"></span>
<span class="glyphicon glyphicon-menu-right"></span>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="URL" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'link'})">
<span class="glyphicon glyphicon-link"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default btn-sm"
data-title="Bulleted list" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'bulleted-list'})">
<span class="glyphicon glyphicon-list"></span>
</button>
<button type="button" class="btn btn-default btn-sm"
data-title="Numbered list" data-container="body" data-container="body" bs-tooltip
ng-click="$ctrl.insertSymbol.emit({symbol: 'numbered-list'})">
<i class="fa fa-list-ol" aria-hidden="true"></i>
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
import { MarkdownToolbarComponent } from './markdown-toolbar.component';
import { MarkdownSymbol } from '../../../types/common.types';
describe("MarkdownToolbarComponent", () => {
var component: MarkdownToolbarComponent;
beforeEach(() => {
component = new MarkdownToolbarComponent();
});
});

View file

@ -0,0 +1,17 @@
import { Component, Input, Output, EventEmitter } from 'ng-metadata/core';
import { MarkdownSymbol } from '../../../types/common.types';
import './markdown-toolbar.component.css';
/**
* Toolbar containing Markdown symbol shortcuts.
*/
@Component({
selector: 'markdown-toolbar',
templateUrl: '/static/js/directives/ui/markdown/markdown-toolbar.component.html'
})
export class MarkdownToolbarComponent {
@Input('<') public allowUndo: boolean = true;
@Output() public insertSymbol: EventEmitter<{symbol: MarkdownSymbol}> = new EventEmitter();
}

View file

@ -0,0 +1,12 @@
.markdown-view-content {
word-wrap: break-word;
overflow: hidden;
}
.markdown-view-content p {
margin: 0;
}
code * {
font-family: "Lucida Console", Monaco, monospace;
}

View file

@ -0,0 +1,2 @@
<div class="markdown-view-content"
ng-bind-html="$ctrl.convertedHTML"></div>

View file

@ -0,0 +1,81 @@
import { MarkdownViewComponent } from './markdown-view.component';
import { SimpleChanges } from 'ng-metadata/core';
import { Converter, ConverterOptions } from 'showdown';
import { Mock } from 'ts-mocks';
import Spy = jasmine.Spy;
describe("MarkdownViewComponent", () => {
var component: MarkdownViewComponent;
var markdownConverterMock: Mock<Converter>;
var $sceMock: Mock<ng.ISCEService>;
var $sanitizeMock: ng.sanitize.ISanitizeService;
beforeEach(() => {
markdownConverterMock = new Mock<Converter>();
$sceMock = new Mock<ng.ISCEService>();
$sanitizeMock = jasmine.createSpy('$sanitizeSpy').and.callFake((html: string) => html);
component = new MarkdownViewComponent((options: ConverterOptions) => markdownConverterMock.Object,
$sceMock.Object,
$sanitizeMock);
});
describe("ngOnChanges", () => {
var changes: SimpleChanges;
var markdown: string;
var expectedPlaceholder: string;
var markdownChars: string[];
beforeEach(() => {
changes = {};
markdown = `## Heading\n Code line\n\n- Item\n> Quote\`code snippet\`\n\nThis is my project!`;
expectedPlaceholder = `<p style="visibility:hidden">placeholder</p>`;
markdownChars = ['#', '-', '>', '`'];
markdownConverterMock.setup(mock => mock.makeHtml).is((text) => text);
$sceMock.setup(mock => mock.trustAsHtml).is((html) => html);
});
it("calls markdown converter to convert content to HTML when content is changed", () => {
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
});
it("only converts first line of content to HTML if flag is set when content is changed", () => {
component.firstLineOnly = true;
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
const expectedHtml: string = markdown.split('\n')
.filter(line => line.indexOf(' ') != 0)
.filter(line => line.trim().length != 0)
.filter(line => markdownChars.indexOf(line.trim()[0]) == -1)[0];
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(expectedHtml);
});
it("sets converted HTML to be a placeholder if flag is set and content is empty", () => {
component.placeholderNeeded = true;
changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
expect((<Spy>markdownConverterMock.Object.makeHtml)).not.toHaveBeenCalled();
expect((<Spy>$sceMock.Object.trustAsHtml).calls.argsFor(0)[0]).toEqual(expectedPlaceholder);
});
it("sets converted HTML to empty string if placeholder flag is false and content is empty", () => {
changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
});
it("calls $sanitize service to sanitize changed HTML content", () => {
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
component.ngOnChanges(changes);
expect((<Spy>$sanitizeMock).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
});
});
});

View file

@ -0,0 +1,51 @@
import { Component, Input, Inject, OnChanges, SimpleChanges } from 'ng-metadata/core';
import { Converter, ConverterOptions } from 'showdown';
import 'showdown-highlightjs-extension';
import 'highlightjs/styles/vs.css';
import './markdown-view.component.css';
/**
* Renders Markdown content to HTML.
*/
@Component({
selector: 'markdown-view',
templateUrl: '/static/js/directives/ui/markdown/markdown-view.component.html'
})
export class MarkdownViewComponent implements OnChanges {
@Input('<') public content: string;
@Input('<') public firstLineOnly: boolean = false;
@Input('<') public placeholderNeeded: boolean = false;
private convertedHTML: string = '';
private readonly placeholder: string = `<p style="visibility:hidden">placeholder</p>`;
private readonly markdownChars: string[] = ['#', '-', '>', '`'];
private markdownConverter: Converter;
constructor(@Inject('markdownConverterFactory') private makeConverter: (options?: ConverterOptions) => Converter,
@Inject('$sce') private $sce: ng.ISCEService,
@Inject('$sanitize') private $sanitize: ng.sanitize.ISanitizeService) {
this.markdownConverter = makeConverter({extensions: ['highlightjs']});
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes['content']) {
if (!changes['content'].currentValue && this.placeholderNeeded) {
this.convertedHTML = this.$sce.trustAsHtml(this.placeholder);
} else if (this.firstLineOnly) {
const firstLine: string = changes['content'].currentValue.split('\n')
// Skip code lines
.filter(line => line.indexOf(' ') != 0)
// Skip empty lines
.filter(line => line.trim().length != 0)
// Skip control lines
.filter(line => this.markdownChars.indexOf(line.trim()[0]) == -1)[0];
this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(firstLine));
} else {
this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(changes['content'].currentValue));
}
}
}
}

View file

@ -93,6 +93,20 @@ angular.module('quay').directive('repositoryEventsTable', function () {
}, ApiService.errorDisplay('Cannot delete notification'));
};
$scope.reenableNotification = function(notification) {
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'uuid': notification.uuid
};
ApiService.resetRepositoryNotificationFailures(null, params).then(function() {
var index = $.inArray(notification, $scope.notifications);
if (index < 0) { return; }
$scope.notifications[index].number_of_failures = 0
}, ApiService.errorDisplay('Cannot re-enable notification'));
};
$scope.showNotifyInfo = function(notification, field) {
var dom = document.createElement('input');
dom.setAttribute('type', 'text');

View file

@ -13,22 +13,26 @@
</td>
<td>
<div ng-if="$ctrl.repository.trust_enabled">
<h4>Content Trust Enabled</h4>
<h4>Trust Enabled</h4>
<p>
Content Trust and Signing is enabled on this repository and all tag operations must be signed via Docker Content Trust.
Signing is enabled on this repository and all tag operations must be signed via Docker Content Trust.
</p>
<p>
Note that due to this feature being enabled, all UI-based tag operations and all build support is <strong>disabled on this repository</strong>.
When this feature is enabled, it will be possible to use the UI or client tools to change tag data without
signing.
This can make a signed tag point to a different image than the actual tag, and the underlying data could
be garbage collected. It is important to have a strict separation between tags that are signed and tags
that are not.
</p>
<button class="btn btn-danger" ng-click="$ctrl.askChangeTrust(false)">Disable Content Trust</button>
<button class="btn btn-danger" ng-click="$ctrl.askChangeTrust(false)">Disable Trust</button>
</div>
<div ng-if="!$ctrl.repository.trust_enabled">
<h4>Content Trust Disabled</h4>
<h4>Trust Disabled</h4>
<p>
Content Trust and Signing is disabled on this repository.
Signing is disabled on this repository.
</p>
<button class="btn btn-default" ng-click="$ctrl.askChangeTrust(true)">Enable Content Trust</button>
<button class="btn btn-default" ng-click="$ctrl.askChangeTrust(true)">Enable Trust</button>
</div>
</td>
</tr>
@ -40,22 +44,24 @@
<div class="cor-confirm-dialog"
dialog-context="$ctrl.enableTrustInfo"
dialog-action="$ctrl.changeTrust(true, callback)"
dialog-title="Enable Content Trust"
dialog-title="Enable Trust"
dialog-action-title="Enable Trust">
<p>Click "Enable Trust" to enable content trust on this repository.</p>
<p>Please note that at this time, having content trust will <strong>disable</strong> the following
features under the repository:
<p>Please note that this will not prevent users from overwriting signed tags without updating signatures.
This means that:
<ul>
<li>Any tag operations in the UI (Add Tag, Delete Tag, Restore Tag)
<li>All build triggers and ability to invoke builds
<li>Any tag operations in the UI or client can cause inconsistency
<li>Builds should not push to signed tags
</ul>
We recommend you maintain a strict separation between signed and unsigned tags to avoid any issues with garbage
collection.
</p>
</div>
<div class="cor-confirm-dialog"
dialog-context="$ctrl.disableTrustInfo"
dialog-action="$ctrl.changeTrust(false, callback)"
dialog-title="Disable Content Trust"
dialog-title="Disable Trust"
dialog-action-title="Disable Trust and Delete Data">
<div class="co-alert co-alert-warning">
<strong>Warning:</strong> Disabling content trust will prevent users from pushing signed

View file

@ -1,6 +1,7 @@
import { Input, Component, Inject } from 'ng-metadata/core';
import { Repository } from '../../../types/common.types';
/**
* A component that displays the configuration and options for repository signing.
*/
@ -9,12 +10,13 @@ import { Repository } from '../../../types/common.types';
templateUrl: '/static/js/directives/ui/repository-signing-config/repository-signing-config.component.html',
})
export class RepositorySigningConfigComponent {
@Input('<') public repository: Repository;
private enableTrustInfo: {[key: string]: string} = null;
private disableTrustInfo: {[key: string]: string} = null;
constructor (@Inject("ApiService") private ApiService: any) {
constructor(@Inject("ApiService") private ApiService: any) {
}
@ -41,4 +43,4 @@ export class RepositorySigningConfigComponent {
callback(true);
}, errorDisplay);
}
}
}

View file

@ -88,7 +88,7 @@ angular.module('quay').directive('requestServiceKeyDialog', function () {
$scope.downloadPrivateKey = function(key) {
var blob = new Blob([key.private_key]);
saveAs(blob, key.service + '.pem');
FileSaver.saveAs(blob, key.service + '.pem');
};
$scope.createPresharedKey = function() {
@ -108,6 +108,10 @@ angular.module('quay').directive('requestServiceKeyDialog', function () {
$scope.keyCreated({'key': resp});
}, ApiService.errorDisplay('Could not create service key'));
};
$scope.updateNotes = function(content) {
$scope.preshared.notes = content;
};
$scope.$watch('requestKeyInfo', function(info) {
if (info && info.service) {

View file

@ -31,16 +31,22 @@
<span class="avatar" data="result.namespace.avatar" size="16"></span>
<span class="result-name">{{ result.namespace.name }}/{{ result.name }}</span>
<div class="result-description" ng-if="result.description">
<div class="description markdown-view" content="result.description"
first-line-only="true" placeholder-needed="false"></div>
<div class="description">
<markdown-view content="result.description"
first-line-only="true"
placeholder-needed="false"></markdown-view>
</div>
</div>
</span>
<span ng-switch-when="application">
<span class="avatar" data="result.namespace.avatar" size="16"></span>
<span class="result-name">{{ result.namespace.name }}/{{ result.name }}</span>
<div class="result-description" ng-if="result.description">
<div class="description markdown-view" content="result.description"
first-line-only="true" placeholder-needed="false"></div>
<div class="description">
<markdown-view content="result.description"
first-line-only="true"
placeholder-needed="false"></markdown-view>
</div>
</div>
</span>
</span>

Some files were not shown because too many files have changed in this diff Show more