Merge branch 'master' into no-signing-whitelist
This commit is contained in:
commit
45bf7efc84
434 changed files with 10877 additions and 11061 deletions
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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});
|
||||
}
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -50,4 +50,3 @@ angular.module('quay').directive('repoPanelInfo', function () {
|
|||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
|
|
@ -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};
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -38,4 +38,4 @@ export class QuayRequireDirective implements AfterContentInit {
|
|||
this.$transclude
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -44,4 +44,4 @@ export class ChannelIconComponent {
|
|||
var num: number = parseInt(hash.substr(0, 4));
|
||||
return this.colors[num % this.colors.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import { ClipboardCopyDirective } from './clipboard-copy.directive';
|
||||
import * as Clipboard from 'clipboard';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("ClipboardCopyDirective", () => {
|
||||
var directive: ClipboardCopyDirective;
|
||||
var $elementMock: any;
|
||||
var $timeoutMock: any;
|
||||
var $documentMock: any;
|
||||
var clipboardFactory: any;
|
||||
var clipboardMock: Mock<Clipboard>;
|
||||
|
||||
beforeEach(() => {
|
||||
$elementMock = new Mock<ng.IAugmentedJQuery>();
|
||||
$timeoutMock = jasmine.createSpy('$timeoutSpy').and.callFake((fn: () => void, delay) => fn());
|
||||
$documentMock = new Mock<ng.IDocumentService>();
|
||||
clipboardMock = new Mock<Clipboard>();
|
||||
clipboardMock.setup(mock => mock.on).is((eventName: string, callback: (event) => void) => {});
|
||||
clipboardFactory = jasmine.createSpy('clipboardFactory').and.returnValue(clipboardMock.Object);
|
||||
directive = new ClipboardCopyDirective(<any>[$elementMock.Object],
|
||||
$timeoutMock,
|
||||
<any>[$documentMock.Object],
|
||||
clipboardFactory);
|
||||
directive.copyTargetSelector = "#copy-input-box-0";
|
||||
});
|
||||
|
||||
describe("ngAfterContentInit", () => {
|
||||
|
||||
it("initializes new Clipboard instance", () => {
|
||||
const target = new Mock<ng.IAugmentedJQuery>();
|
||||
$documentMock.setup(mock => mock.querySelector).is(selector => target.Object);
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect(clipboardFactory).toHaveBeenCalled();
|
||||
expect((<Spy>clipboardFactory.calls.argsFor(0)[0])).toEqual($elementMock.Object);
|
||||
expect((<Spy>clipboardFactory.calls.argsFor(0)[1]['target']())).toEqual(target.Object);
|
||||
});
|
||||
|
||||
it("sets error callback for Clipboard instance", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>clipboardMock.Object.on.calls.argsFor(0)[0])).toEqual('error');
|
||||
expect((<Spy>clipboardMock.Object.on.calls.argsFor(0)[1])).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets success callback for Clipboard instance", (done) => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>clipboardMock.Object.on.calls.argsFor(1)[0])).toEqual('success');
|
||||
expect((<Spy>clipboardMock.Object.on.calls.argsFor(1)[1])).toBeDefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngOnDestroy", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
clipboardMock.setup(mock => mock.destroy).is(() => null);
|
||||
});
|
||||
|
||||
it("calls method to destroy Clipboard instance if set", (done) => {
|
||||
directive.ngAfterContentInit();
|
||||
directive.ngOnDestroy();
|
||||
|
||||
expect((<Spy>clipboardMock.Object.destroy)).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
|
||||
it("does not call method to destroy Clipboard instance if not set", () => {
|
||||
directive.ngOnDestroy();
|
||||
|
||||
expect((<Spy>clipboardMock.Object.destroy)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,63 @@
|
|||
import { Directive, Inject, Input, AfterContentInit, OnDestroy } from 'ng-metadata/core';
|
||||
import * as Clipboard from 'clipboard';
|
||||
|
||||
|
||||
@Directive({
|
||||
selector: '[clipboardCopy]'
|
||||
})
|
||||
export class ClipboardCopyDirective implements AfterContentInit, OnDestroy {
|
||||
|
||||
@Input('@clipboardCopy') public copyTargetSelector: string;
|
||||
|
||||
private clipboard: Clipboard;
|
||||
|
||||
constructor(@Inject('$element') private $element: ng.IAugmentedJQuery,
|
||||
@Inject('$timeout') private $timeout: ng.ITimeoutService,
|
||||
@Inject('$document') private $document: ng.IDocumentService,
|
||||
@Inject('clipboardFactory') private clipboardFactory: (elem, options) => Clipboard) {
|
||||
|
||||
}
|
||||
|
||||
public ngAfterContentInit(): void {
|
||||
// FIXME: Need to wait for DOM to render to find target element
|
||||
this.$timeout(() => {
|
||||
this.clipboard = this.clipboardFactory(this.$element[0], {target: (trigger) => {
|
||||
return this.$document[0].querySelector(this.copyTargetSelector);
|
||||
}});
|
||||
|
||||
this.clipboard.on("error", (e) => {
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
this.clipboard.on('success', (e) => {
|
||||
const container = e.trigger.parentNode.parentNode.parentNode;
|
||||
const messageElem = container.querySelector('.clipboard-copied-message');
|
||||
if (!messageElem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resets the animation.
|
||||
var elem = messageElem;
|
||||
elem.style.display = 'none';
|
||||
elem.classList.remove('animated');
|
||||
|
||||
// Show the notification.
|
||||
setTimeout(() => {
|
||||
elem.style.display = 'inline-block';
|
||||
elem.classList.add('animated');
|
||||
}, 10);
|
||||
|
||||
// Reset the notification.
|
||||
setTimeout(() => {
|
||||
elem.style.display = 'none';
|
||||
}, 5000);
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
if (this.clipboard) {
|
||||
this.clipboard.destroy();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
.cor-table-element .co-top-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
}
|
|
@ -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>
|
||||
|
|
126
static/js/directives/ui/cor-table/cor-table.component.spec.ts
Normal file
126
static/js/directives/ui/cor-table/cor-table.component.spec.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
import { Mock } from 'ts-mocks';
|
||||
import { CorTableComponent, CorTableOptions } from './cor-table.component';
|
||||
import { CorTableColumn } from './cor-table-col.component';
|
||||
import { SimpleChanges } from 'ng-metadata/core';
|
||||
import { ViewArray } from '../../../services/view-array/view-array';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTableComponent", () => {
|
||||
var component: CorTableComponent;
|
||||
var tableServiceMock: Mock<any>;
|
||||
var tableData: any[];
|
||||
var columnMocks: Mock<CorTableColumn>[];
|
||||
var orderedDataMock: Mock<ViewArray>;
|
||||
|
||||
beforeEach(() => {
|
||||
orderedDataMock = new Mock<ViewArray>();
|
||||
orderedDataMock.setup(mock => mock.visibleEntries).is([]);
|
||||
tableServiceMock = new Mock<any>();
|
||||
tableServiceMock.setup(mock => mock.buildOrderedItems)
|
||||
.is((items, options, filterFields, numericFields, extrafilter?) => orderedDataMock.Object);
|
||||
|
||||
tableData = [
|
||||
{name: "apple", last_modified: 1496068383000, version: "1.0.0"},
|
||||
{name: "pear", last_modified: 1496068383001, version: "1.1.0"},
|
||||
{name: "orange", last_modified: 1496068383002, version: "1.0.0"},
|
||||
{name: "banana", last_modified: 1496068383000, version: "2.0.0"},
|
||||
];
|
||||
|
||||
columnMocks = Object.keys(tableData[0])
|
||||
.map((key, index) => {
|
||||
const col = new Mock<CorTableColumn>();
|
||||
col.setup(mock => mock.isNumeric).is(() => index == 1 ? true : false);
|
||||
col.setup(mock => mock.processColumnForOrdered).is((value) => "dummy");
|
||||
col.setup(mock => mock.datafield).is(key);
|
||||
|
||||
return col;
|
||||
});
|
||||
|
||||
component = new CorTableComponent(tableServiceMock.Object);
|
||||
component.tableData = tableData;
|
||||
component.filterFields = ['name', 'version'];
|
||||
component.compact = false;
|
||||
component.tableItemTitle = "fruits";
|
||||
component.maxDisplayCount = 10;
|
||||
// Add columns
|
||||
columnMocks.forEach(colMock => component.addColumn(colMock.Object));
|
||||
(<Spy>tableServiceMock.Object.buildOrderedItems).calls.reset();
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
|
||||
it("sets table options", () => {
|
||||
expect(component.options.filter).toEqual('');
|
||||
expect(component.options.reverse).toBe(false);
|
||||
expect(component.options.predicate).toEqual('');
|
||||
expect(component.options.page).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngOnChanges", () => {
|
||||
var changes: SimpleChanges;
|
||||
|
||||
it("calls table service to build ordered items if table data is changed", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes processed table data to table service", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.tableData = changes['tableData'].currentValue;
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[0]).not.toEqual(tableData);
|
||||
});
|
||||
|
||||
it("passes options to table service", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[1]).toEqual(component.options);
|
||||
});
|
||||
|
||||
it("passes filter fields to table service", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[2]).toEqual(component.filterFields);
|
||||
});
|
||||
|
||||
it("passes numeric fields to table service", () => {
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
const expectedNumericCols: string[] = columnMocks.filter(colMock => colMock.Object.isNumeric())
|
||||
.map(colMock => colMock.Object.datafield);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems).calls.argsFor(0)[3]).toEqual(expectedNumericCols);
|
||||
});
|
||||
|
||||
it("resets to first page if table data is changed", () => {
|
||||
component.options.page = 1;
|
||||
changes = {tableData: {currentValue: [], previousValue: [], isFirstChange: () => false}};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect(component.options.page).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addColumn", () => {
|
||||
var columnMock: Mock<CorTableColumn>;
|
||||
|
||||
beforeEach(() => {
|
||||
columnMock = new Mock<CorTableColumn>();
|
||||
columnMock.setup(mock => mock.isNumeric).is(() => false);
|
||||
});
|
||||
|
||||
it("calls table service to build ordered items with new column", () => {
|
||||
component.addColumn(columnMock.Object);
|
||||
|
||||
expect((<Spy>tableServiceMock.Object.buildOrderedItems)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
import { CorCookieTabsDirective } from './cor-cookie-tabs.directive';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorCookieTabsDirective", () => {
|
||||
var directive: CorCookieTabsDirective;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var cookieServiceMock: Mock<any>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "subscribe").and.returnValue(null);
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
cookieServiceMock = new Mock<any>();
|
||||
cookieServiceMock.setup(mock => mock.putPermanent).is((cookieName, value) => null);
|
||||
|
||||
directive = new CorCookieTabsDirective(panelMock.Object, cookieServiceMock.Object);
|
||||
directive.cookieName = "quay.credentialsTab";
|
||||
});
|
||||
|
||||
describe("ngAfterContentInit", () => {
|
||||
const tabId: string = "description";
|
||||
|
||||
beforeEach(() => {
|
||||
cookieServiceMock.setup(mock => mock.get).is((name) => tabId);
|
||||
spyOn(activeTab, "next").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("calls cookie service to retrieve initial tab id", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>cookieServiceMock.Object.get).calls.argsFor(0)[0]).toEqual(directive.cookieName);
|
||||
});
|
||||
|
||||
it("emits retrieved tab id as next active tab", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls cookie service to put new permanent cookie on active tab changes", () => {
|
||||
directive.ngAfterContentInit();
|
||||
const tabId: string = "description";
|
||||
(<Spy>panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](tabId);
|
||||
|
||||
expect((<Spy>cookieServiceMock.Object.putPermanent).calls.argsFor(0)[0]).toEqual(directive.cookieName);
|
||||
expect((<Spy>cookieServiceMock.Object.putPermanent).calls.argsFor(0)[1]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import { CorNavTabsDirective } from './cor-nav-tabs.directive';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorNavTabsDirective", () => {
|
||||
var directive: CorNavTabsDirective;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var $locationMock: Mock<ng.ILocationService>;
|
||||
var $rootScopeMock: Mock<ng.IRootScopeService>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
const tabId: string = "description";
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "next").and.returnValue(null);
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
$locationMock = new Mock<ng.ILocationService>();
|
||||
$locationMock.setup(mock => mock.search).is(() => <any>{tab: tabId});
|
||||
$rootScopeMock = new Mock<ng.IRootScopeService>();
|
||||
$rootScopeMock.setup(mock => mock.$on);
|
||||
|
||||
directive = new CorNavTabsDirective(panelMock.Object, $locationMock.Object, $rootScopeMock.Object);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
|
||||
it("subscribes to $routeUpdate event on the root scope", () => {
|
||||
expect((<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[0]).toEqual("$routeUpdate");
|
||||
});
|
||||
|
||||
it("calls location service to retrieve tab id from URL query parameters on route update", () => {
|
||||
(<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[1]();
|
||||
|
||||
expect(<Spy>$locationMock.Object.search).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits retrieved tab id as next active tab on route update", () => {
|
||||
(<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[1]();
|
||||
|
||||
expect((<Spy>activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngAfterContentInit", () => {
|
||||
const path: string = "quay.io/repository/devtable/simple";
|
||||
|
||||
beforeEach(() => {
|
||||
$locationMock.setup(mock => mock.path).is(() => <any>path);
|
||||
});
|
||||
|
||||
it("calls location service to retrieve the current URL path and sets panel's base path", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect(panelMock.Object.basePath).toEqual(path);
|
||||
});
|
||||
|
||||
it("calls location service to retrieve tab id from URL query parameters", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect(<Spy>$locationMock.Object.search).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits retrieved tab id as next active tab", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import { Directive, Inject, Host, AfterContentInit, Input } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* Adds routing capabilities to cor-tab-panel, either using URL query parameters, or browser cookie.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[corNavTabs]'
|
||||
})
|
||||
export class CorNavTabsDirective implements AfterContentInit {
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent,
|
||||
@Inject('$location') private $location: ng.ILocationService,
|
||||
@Inject('$rootScope') private $rootScope: ng.IRootScopeService) {
|
||||
this.$rootScope.$on('$routeUpdate', () => {
|
||||
const tabId: string = this.$location.search()['tab'];
|
||||
this.panel.activeTab.next(tabId);
|
||||
});
|
||||
}
|
||||
|
||||
public ngAfterContentInit(): void {
|
||||
this.panel.basePath = this.$location.path();
|
||||
|
||||
// Set initial tab
|
||||
const tabId: string = this.$location.search()['tab'];
|
||||
this.panel.activeTab.next(tabId);
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
||||
}
|
|
@ -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 {}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import { CorTabPaneComponent } from './cor-tab-pane.component';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTabPaneComponent", () => {
|
||||
var component: CorTabPaneComponent;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "subscribe").and.callThrough();
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
|
||||
component = new CorTabPaneComponent(panelMock.Object);
|
||||
component.id = 'description';
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
panelMock.setup(mock => mock.addTabPane);
|
||||
});
|
||||
|
||||
it("adds self as tab pane to panel", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.addTabPane).calls.argsFor(0)[0]).toBe(component);
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing if active tab ID is undefined", () => {
|
||||
component.ngOnInit();
|
||||
component.isActiveTab = true;
|
||||
panelMock.Object.activeTab.next(null);
|
||||
|
||||
expect(component.isActiveTab).toEqual(true);
|
||||
});
|
||||
|
||||
it("sets self as active if active tab ID matches tab ID", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(component.id);
|
||||
|
||||
expect(component.isActiveTab).toEqual(true);
|
||||
});
|
||||
|
||||
it("sets self as inactive if active tab ID does not match tab ID", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(component.id.split('').reverse().join(''));
|
||||
|
||||
expect(component.isActiveTab).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
import { Component, Input, Inject, Host, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import 'rxjs/add/operator/filter';
|
||||
|
||||
|
||||
/**
|
||||
* A component that creates a single tab pane under a cor-tabs component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-pane',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
}
|
||||
})
|
||||
export class CorTabPaneComponent implements OnInit {
|
||||
|
||||
@Input('@') public id: string;
|
||||
|
||||
public isActiveTab: boolean = false;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent) {
|
||||
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.panel.addTabPane(this);
|
||||
|
||||
this.panel.activeTab
|
||||
.filter(tabId => tabId != undefined)
|
||||
.subscribe((tabId: string) => {
|
||||
this.isActiveTab = (this.id === tabId);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<div class="co-main-content-panel co-tab-panel co-fx-box-shadow-heavy">
|
||||
<div class="co-tab-container" ng-class="$ctrl.isVertical() ? 'vertical': 'horizontal'" ng-transclude></div>
|
||||
</div>
|
|
@ -0,0 +1,132 @@
|
|||
import { CorTabPanelComponent } from './cor-tab-panel.component';
|
||||
import { CorTabComponent } from '../cor-tab/cor-tab.component';
|
||||
import { SimpleChanges } from 'ng-metadata/core';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTabPanelComponent", () => {
|
||||
var component: CorTabPanelComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new CorTabPanelComponent();
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
var tabs: CorTabComponent[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Add tabs to panel
|
||||
tabs.push(new CorTabComponent(component));
|
||||
tabs[0].tabId = "info";
|
||||
tabs.forEach((tab) => component.addTab(tab));
|
||||
|
||||
spyOn(component.activeTab, "subscribe").and.callThrough();
|
||||
spyOn(component.activeTab, "next").and.callThrough();
|
||||
spyOn(component.tabChange, "emit").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect(<Spy>component.activeTab.subscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits next active tab with tab ID of first registered tab if given tab ID is null", () => {
|
||||
component.ngOnInit();
|
||||
component.activeTab.next(null);
|
||||
|
||||
expect((<Spy>component.activeTab.next).calls.argsFor(1)[0]).toEqual(tabs[0].tabId);
|
||||
});
|
||||
|
||||
it("does not emit output event for tab change if tab ID is null", () => {
|
||||
component.ngOnInit();
|
||||
component.activeTab.next(null);
|
||||
|
||||
expect((<Spy>component.tabChange.emit).calls.allArgs).not.toContain(null);
|
||||
});
|
||||
|
||||
it("emits output event for tab change when tab ID is not null", () => {
|
||||
component.ngOnInit();
|
||||
const tabId: string = "description";
|
||||
component.activeTab.next(tabId);
|
||||
|
||||
expect((<Spy>component.tabChange.emit).calls.argsFor(1)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngOnChanges", () => {
|
||||
var changes: SimpleChanges;
|
||||
var tabs: CorTabComponent[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Add tabs to panel
|
||||
tabs.push(new CorTabComponent(component));
|
||||
tabs.forEach((tab) => component.addTab(tab));
|
||||
|
||||
changes = {
|
||||
'selectedIndex': {
|
||||
currentValue: 0,
|
||||
previousValue: null,
|
||||
isFirstChange: () => false
|
||||
},
|
||||
};
|
||||
|
||||
spyOn(component.activeTab, "next").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("emits next active tab if 'selectedIndex' input changes and is valid", () => {
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>component.activeTab.next).calls.argsFor(0)[0]).toEqual(tabs[changes['selectedIndex'].currentValue].tabId);
|
||||
});
|
||||
|
||||
it("does nothing if 'selectedIndex' input changed to invalid value", () => {
|
||||
changes['selectedIndex'].currentValue = 100;
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect(<Spy>component.activeTab.next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTab", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(component.activeTab, "next").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("emits next active tab if it is not set", () => {
|
||||
const tab: CorTabComponent = new CorTabComponent(component);
|
||||
component.addTab(tab);
|
||||
|
||||
expect((<Spy>component.activeTab.next).calls.argsFor(0)[0]).toEqual(tab.tabId);
|
||||
});
|
||||
|
||||
it("does not emit next active tab if it is already set", () => {
|
||||
spyOn(component.activeTab, "getValue").and.returnValue("description");
|
||||
const tab: CorTabComponent = new CorTabComponent(component);
|
||||
component.addTab(tab);
|
||||
|
||||
expect(<Spy>component.activeTab.next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTabPane", () => {
|
||||
|
||||
});
|
||||
|
||||
describe("isVertical", () => {
|
||||
|
||||
it("returns true if orientation is 'vertical'", () => {
|
||||
component.orientation = 'vertical';
|
||||
const isVertical: boolean = component.isVertical();
|
||||
|
||||
expect(isVertical).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if orientation is not 'vertical'", () => {
|
||||
const isVertical: boolean = component.isVertical();
|
||||
|
||||
expect(isVertical).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,65 @@
|
|||
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabComponent } from '../cor-tab/cor-tab.component';
|
||||
import { CorTabPaneComponent } from '../cor-tab-pane/cor-tab-pane.component';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
|
||||
|
||||
/**
|
||||
* A component that contains a cor-tabs and handles all of its logic.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-panel',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.html',
|
||||
legacy: {
|
||||
transclude: true
|
||||
}
|
||||
})
|
||||
export class CorTabPanelComponent implements OnInit, OnChanges {
|
||||
|
||||
@Input('@') public orientation: 'horizontal' | 'vertical' = 'horizontal';
|
||||
|
||||
@Output() public tabChange: EventEmitter<string> = new EventEmitter();
|
||||
|
||||
public basePath: string;
|
||||
public activeTab: 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';
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
<li class="cor-tab-itself" ng-class="{'active': $ctrl.isActive, 'co-top-tab': !$ctrl.parent.isVertical()}">
|
||||
<a href="{{ $ctrl.panel.basePath ? $ctrl.panel.basePath + '?tab=' + $ctrl.tabId : '' }}"
|
||||
ng-click="$ctrl.tabClicked($event)">
|
||||
<span class="cor-tab-icon"
|
||||
data-title="{{ ::($ctrl.panel.isVertical() ? $ctrl.tabTitle : '') }}"
|
||||
data-placement="right"
|
||||
data-container="body"
|
||||
style="display: inline-block"
|
||||
bs-tooltip>
|
||||
<span ng-transclude /><span class="horizontal-label">{{ ::$ctrl.tabTitle }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
|
@ -0,0 +1,85 @@
|
|||
import { CorTabComponent } from './cor-tab.component';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTabComponent", () => {
|
||||
var component: CorTabComponent;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "subscribe").and.callThrough();
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
|
||||
component = new CorTabComponent(panelMock.Object);
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
panelMock.setup(mock => mock.addTab);
|
||||
spyOn(component.tabInit, "emit").and.returnValue(null);
|
||||
spyOn(component.tabShow, "emit").and.returnValue(null);
|
||||
spyOn(component.tabHide, "emit").and.returnValue(null);
|
||||
component.tabId = "description";
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing if active tab ID is undefined", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(null);
|
||||
|
||||
expect(<Spy>component.tabInit.emit).not.toHaveBeenCalled();
|
||||
expect(<Spy>component.tabShow.emit).not.toHaveBeenCalled();
|
||||
expect(<Spy>component.tabHide.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab init if it is new active tab", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(component.tabId);
|
||||
|
||||
expect(<Spy>component.tabInit.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab show if it is new active tab", () => {
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(component.tabId);
|
||||
|
||||
expect(<Spy>component.tabShow.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab hide if active tab changes to different tab", () => {
|
||||
const newTabId: string = component.tabId.split('').reverse().join('');
|
||||
component.ngOnInit();
|
||||
// Call twice, first time to set 'isActive' to true
|
||||
panelMock.Object.activeTab.next(component.tabId);
|
||||
panelMock.Object.activeTab.next(newTabId);
|
||||
|
||||
expect(<Spy>component.tabHide.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit output event for tab hide if was not previously active tab", () => {
|
||||
const newTabId: string = component.tabId.split('').reverse().join('');
|
||||
component.ngOnInit();
|
||||
panelMock.Object.activeTab.next(newTabId);
|
||||
|
||||
expect(<Spy>component.tabHide.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds self as tab to panel", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.addTab).calls.argsFor(0)[0]).toBe(component);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
33
static/js/directives/ui/cor-tabs/cor-tabs.module.ts
Normal file
33
static/js/directives/ui/cor-tabs/cor-tabs.module.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { NgModule } from 'ng-metadata/core';
|
||||
import { CorTabsComponent } from './cor-tabs.component';
|
||||
import { CorTabComponent } from './cor-tab/cor-tab.component';
|
||||
import { CorNavTabsDirective } from './cor-nav-tabs/cor-nav-tabs.directive';
|
||||
import { CorTabContentComponent } from './cor-tab-content/cor-tab-content.component';
|
||||
import { CorTabPaneComponent } from './cor-tab-pane/cor-tab-pane.component';
|
||||
import { CorTabPanelComponent } from './cor-tab-panel/cor-tab-panel.component';
|
||||
import { CorCookieTabsDirective } from './cor-cookie-tabs/cor-cookie-tabs.directive';
|
||||
|
||||
|
||||
/**
|
||||
* Module containing everything needed for cor-tabs.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
||||
],
|
||||
declarations: [
|
||||
CorNavTabsDirective,
|
||||
CorTabComponent,
|
||||
CorTabContentComponent,
|
||||
CorTabPaneComponent,
|
||||
CorTabPanelComponent,
|
||||
CorTabsComponent,
|
||||
CorCookieTabsDirective,
|
||||
],
|
||||
providers: [
|
||||
|
||||
]
|
||||
})
|
||||
export class CorTabsModule {
|
||||
|
||||
}
|
13
static/js/directives/ui/cor-tabs/cor-tabs.view-object.ts
Normal file
13
static/js/directives/ui/cor-tabs/cor-tabs.view-object.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { element, by, browser, $, ElementFinder, ExpectedConditions as until } from 'protractor';
|
||||
|
||||
|
||||
export class CorTabsViewObject {
|
||||
|
||||
public selectTabByTitle(title: string): 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());
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -125,7 +125,6 @@ angular.module('quay').directive('fetchTagDialog', function () {
|
|||
|
||||
updateFormats();
|
||||
|
||||
$element.find('#copyClipboard').clipboardCopy();
|
||||
$element.find('#fetchTagDialog').modal({});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
|
@ -1,14 +0,0 @@
|
|||
import { ManageTriggerCustomGitComponent } from './manage-trigger-custom-git.component';
|
||||
|
||||
|
||||
describe("ManageTriggerCustomGitComponent", () => {
|
||||
var component: ManageTriggerCustomGitComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new ManageTriggerCustomGitComponent();
|
||||
});
|
||||
|
||||
describe("ngOnChanges", () => {
|
||||
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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] || [];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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'],
|
||||
[]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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 = {};
|
||||
|
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
135
static/js/directives/ui/markdown/markdown-editor.component.ts
Normal file
135
static/js/directives/ui/markdown/markdown-editor.component.ts
Normal 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";
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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", () => {
|
||||
|
||||
});
|
||||
});
|
32
static/js/directives/ui/markdown/markdown-input.component.ts
Normal file
32
static/js/directives/ui/markdown/markdown-input.component.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
12
static/js/directives/ui/markdown/markdown-view.component.css
Normal file
12
static/js/directives/ui/markdown/markdown-view.component.css
Normal 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;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
<div class="markdown-view-content"
|
||||
ng-bind-html="$ctrl.convertedHTML"></div>
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
51
static/js/directives/ui/markdown/markdown-view.component.ts
Normal file
51
static/js/directives/ui/markdown/markdown-view.component.ts
Normal 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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
Reference in a new issue