Improve Job detail view to make it refreshed in real-time instead of reloading
This commit is contained in:
parent
d25f6fcf62
commit
452202e36d
36 changed files with 1174 additions and 316 deletions
|
@ -149,27 +149,34 @@ window.Build = (function () {
|
|||
Build.prototype.verifyTopPosition = function () {
|
||||
const $buildPage = $('.build-page');
|
||||
|
||||
const $flashError = $('.alert-wrapper');
|
||||
const $header = $('.build-header', $buildPage);
|
||||
const $runnersStuck = $('.js-build-stuck', $buildPage);
|
||||
const $startsEnvironment = $('.js-environment-container', $buildPage);
|
||||
const $erased = $('.js-build-erased', $buildPage);
|
||||
const prependTopDefault = 20;
|
||||
|
||||
// header + navigation + margin
|
||||
let topPostion = 168;
|
||||
|
||||
if ($header) {
|
||||
if ($header.length) {
|
||||
topPostion += $header.outerHeight();
|
||||
}
|
||||
|
||||
if ($runnersStuck) {
|
||||
if ($runnersStuck.length) {
|
||||
topPostion += $runnersStuck.outerHeight();
|
||||
}
|
||||
|
||||
if ($startsEnvironment) {
|
||||
topPostion += $startsEnvironment.outerHeight();
|
||||
if ($startsEnvironment.length) {
|
||||
topPostion += $startsEnvironment.outerHeight() + prependTopDefault;
|
||||
}
|
||||
|
||||
if ($erased) {
|
||||
topPostion += $erased.outerHeight() + 10;
|
||||
if ($erased.length) {
|
||||
topPostion += $erased.outerHeight() + prependTopDefault;
|
||||
}
|
||||
|
||||
if ($flashError.length) {
|
||||
topPostion += $flashError.outerHeight();
|
||||
}
|
||||
|
||||
this.$buildTrace.css({
|
||||
|
@ -245,6 +252,7 @@ window.Build = (function () {
|
|||
|
||||
Build.prototype.toggleSidebar = function (shouldHide) {
|
||||
const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
|
||||
const $toggleButton = $('.js-sidebar-build-toggle-header');
|
||||
|
||||
this.$buildTrace
|
||||
.toggleClass('sidebar-expanded', shouldShow)
|
||||
|
@ -252,6 +260,16 @@ window.Build = (function () {
|
|||
this.$sidebar
|
||||
.toggleClass('right-sidebar-expanded', shouldShow)
|
||||
.toggleClass('right-sidebar-collapsed', shouldHide);
|
||||
|
||||
$('.js-build-page')
|
||||
.toggleClass('sidebar-expanded', shouldShow)
|
||||
.toggleClass('sidebar-collapsed', shouldHide);
|
||||
|
||||
if (this.$sidebar.hasClass('right-sidebar-expanded')) {
|
||||
$toggleButton.addClass('hidden');
|
||||
} else {
|
||||
$toggleButton.removeClass('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
Build.prototype.sidebarOnResize = function () {
|
||||
|
@ -266,6 +284,7 @@ window.Build = (function () {
|
|||
|
||||
Build.prototype.sidebarOnClick = function () {
|
||||
if (this.shouldHideSidebarForViewport()) this.toggleSidebar();
|
||||
this.verifyTopPosition();
|
||||
};
|
||||
|
||||
Build.prototype.updateArtifactRemoveDate = function () {
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
/* global UsernameValidator */
|
||||
/* global ActiveTabMemoizer */
|
||||
/* global ShortcutsNavigation */
|
||||
/* global Build */
|
||||
/* global IssuableIndex */
|
||||
/* global ShortcutsIssuable */
|
||||
/* global ZenMode */
|
||||
|
@ -119,9 +118,6 @@ import initSettingsPanels from './settings_panels';
|
|||
shortcut_handler = new ShortcutsNavigation();
|
||||
new UsersSelect();
|
||||
break;
|
||||
case 'projects:jobs:show':
|
||||
new Build();
|
||||
break;
|
||||
case 'projects:merge_requests:index':
|
||||
case 'projects:issues:index':
|
||||
if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) {
|
||||
|
|
83
app/assets/javascripts/jobs/components/header.vue
Normal file
83
app/assets/javascripts/jobs/components/header.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<script>
|
||||
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
|
||||
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
|
||||
|
||||
export default {
|
||||
name: 'jobHeaderSection',
|
||||
props: {
|
||||
job: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ciHeader,
|
||||
loadingIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
actions: this.getActions(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
status() {
|
||||
return this.job && this.job.status;
|
||||
},
|
||||
shouldRenderContent() {
|
||||
return !this.isLoading && Object.keys(this.job).length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getActions() {
|
||||
const actions = [];
|
||||
|
||||
if (this.job.new_issue_path) {
|
||||
actions.push({
|
||||
label: 'New issue',
|
||||
path: this.job.new_issue_path,
|
||||
cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block',
|
||||
type: 'ujs-link',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.job.retry_path) {
|
||||
actions.push({
|
||||
label: 'Retry',
|
||||
path: this.job.retry_path,
|
||||
cssClass: 'js-retry-button btn btn-inverted-secondary visible-md-block visible-lg-block',
|
||||
type: 'ujs-link',
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
job() {
|
||||
this.actions = this.getActions();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="js-build-header build-header top-area">
|
||||
<ci-header
|
||||
v-if="shouldRenderContent"
|
||||
:status="status"
|
||||
item-name="Job"
|
||||
:item-id="job.id"
|
||||
:time="job.created_at"
|
||||
:user="job.user"
|
||||
:actions="actions"
|
||||
:hasSidebarButton="true"
|
||||
/>
|
||||
<loading-icon
|
||||
v-if="isLoading"
|
||||
size="2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,31 @@
|
|||
<script>
|
||||
export default {
|
||||
name: 'SidebarDetailRow',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
hasTitle() {
|
||||
return this.title.length > 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<p class="build-detail-row">
|
||||
<span
|
||||
v-if="hasTitle"
|
||||
class="build-light-text">
|
||||
{{title}}:
|
||||
</span>
|
||||
{{value}}
|
||||
</p>
|
||||
</template>
|
150
app/assets/javascripts/jobs/components/sidebar_details_block.vue
Normal file
150
app/assets/javascripts/jobs/components/sidebar_details_block.vue
Normal file
|
@ -0,0 +1,150 @@
|
|||
<script>
|
||||
import detailRow from './sidebar_detail_row.vue';
|
||||
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
|
||||
import timeagoMixin from '../../vue_shared/mixins/timeago';
|
||||
import { timeIntervalInWords } from '../../lib/utils/datetime_utility';
|
||||
|
||||
export default {
|
||||
name: 'SidebarDetailsBlock',
|
||||
props: {
|
||||
job: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
mixins: [
|
||||
timeagoMixin,
|
||||
],
|
||||
components: {
|
||||
detailRow,
|
||||
loadingIcon,
|
||||
},
|
||||
computed: {
|
||||
shouldRenderContent() {
|
||||
return !this.isLoading && Object.keys(this.job).length > 0;
|
||||
},
|
||||
coverage() {
|
||||
return `${this.job.coverage}%`;
|
||||
},
|
||||
duration() {
|
||||
return timeIntervalInWords(this.job.duration);
|
||||
},
|
||||
queued() {
|
||||
return timeIntervalInWords(this.job.queued);
|
||||
},
|
||||
runnerId() {
|
||||
return `#${this.job.runner.id}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="shouldRenderContent">
|
||||
<div
|
||||
class="block retry-link"
|
||||
v-if="job.retry_path || job.new_issue_path">
|
||||
<a
|
||||
v-if="job.new_issue_path"
|
||||
class="js-new-issue btn btn-new btn-inverted"
|
||||
:href="job.new_issue_path">
|
||||
New issue
|
||||
</a>
|
||||
<a
|
||||
v-if="job.retry_path"
|
||||
class="js-retry-job btn btn-inverted-secondary"
|
||||
:href="job.retry_path"
|
||||
data-method="post"
|
||||
rel="nofollow">
|
||||
Retry
|
||||
</a>
|
||||
</div>
|
||||
<div class="block">
|
||||
<p
|
||||
class="build-detail-row js-job-mr"
|
||||
v-if="job.merge_request">
|
||||
<span
|
||||
class="build-light-text">
|
||||
Merge Request:
|
||||
</span>
|
||||
<a :href="job.merge_request.path">
|
||||
!{{job.merge_request.iid}}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<detail-row
|
||||
class="js-job-duration"
|
||||
v-if="job.duration"
|
||||
title="Duration"
|
||||
:value="duration"
|
||||
/>
|
||||
<detail-row
|
||||
class="js-job-finished"
|
||||
v-if="job.finished_at"
|
||||
title="Finished"
|
||||
:value="timeFormated(job.finished_at)"
|
||||
/>
|
||||
<detail-row
|
||||
class="js-job-erased"
|
||||
v-if="job.erased_at"
|
||||
title="Erased"
|
||||
:value="timeFormated(job.erased_at)"
|
||||
/>
|
||||
<detail-row
|
||||
class="js-job-queued"
|
||||
v-if="job.queued"
|
||||
title="Queued"
|
||||
:value="queued"
|
||||
/>
|
||||
<detail-row
|
||||
class="js-job-runner"
|
||||
v-if="job.runner"
|
||||
title="Runner"
|
||||
:value="runnerId"
|
||||
/>
|
||||
<detail-row
|
||||
class="js-job-coverage"
|
||||
v-if="job.coverage"
|
||||
title="Coverage"
|
||||
:value="coverage"
|
||||
/>
|
||||
<p
|
||||
class="build-detail-row js-job-tags"
|
||||
v-if="job.tags.length">
|
||||
<span
|
||||
class="build-light-text">
|
||||
Tags:
|
||||
</span>
|
||||
<span
|
||||
v-for="tag in job.tags"
|
||||
key="tag"
|
||||
class="label label-primary">
|
||||
{{tag}}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="job.cancel_path"
|
||||
class="btn-group prepend-top-5"
|
||||
role="group">
|
||||
<a
|
||||
class="js-cancel-job btn btn-sm btn-default"
|
||||
:href="job.cancel_path"
|
||||
data-method="post"
|
||||
rel="nofollow">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<loading-icon
|
||||
class="prepend-top-10"
|
||||
v-if="isLoading"
|
||||
size="2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
68
app/assets/javascripts/jobs/job_details_bundle.js
Normal file
68
app/assets/javascripts/jobs/job_details_bundle.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
/* global Flash */
|
||||
|
||||
import Vue from 'vue';
|
||||
import JobMediator from './job_details_mediator';
|
||||
import jobHeader from './components/header.vue';
|
||||
import detailsBlock from './components/sidebar_details_block.vue';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dataset = document.getElementById('js-job-details-vue').dataset;
|
||||
const mediator = new JobMediator({ endpoint: dataset.endpoint });
|
||||
|
||||
mediator.fetchJob();
|
||||
|
||||
// Header
|
||||
// eslint-disable-next-line no-new
|
||||
new Vue({
|
||||
el: '#js-build-header-vue',
|
||||
data() {
|
||||
return {
|
||||
mediator,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
jobHeader,
|
||||
},
|
||||
mounted() {
|
||||
this.mediator.initBuildClass();
|
||||
},
|
||||
updated() {
|
||||
// Wait for flash message to be appended
|
||||
Vue.nextTick(() => {
|
||||
if (this.mediator.build) {
|
||||
this.mediator.build.verifyTopPosition();
|
||||
}
|
||||
});
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement('job-header', {
|
||||
props: {
|
||||
isLoading: this.mediator.state.isLoading,
|
||||
job: this.mediator.store.state.job,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Sidebar information block
|
||||
// eslint-disable-next-line
|
||||
new Vue({
|
||||
el: '#js-details-block-vue',
|
||||
data() {
|
||||
return {
|
||||
mediator,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
detailsBlock,
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement('details-block', {
|
||||
props: {
|
||||
isLoading: this.mediator.state.isLoading,
|
||||
job: this.mediator.store.state.job,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
67
app/assets/javascripts/jobs/job_details_mediator.js
Normal file
67
app/assets/javascripts/jobs/job_details_mediator.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
/* global Flash */
|
||||
/* global Build */
|
||||
|
||||
import Visibility from 'visibilityjs';
|
||||
import Poll from '../lib/utils/poll';
|
||||
import JobStore from './stores/job_store';
|
||||
import JobService from './services/job_service';
|
||||
import '../build';
|
||||
|
||||
export default class JobMediator {
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
|
||||
this.store = new JobStore();
|
||||
this.service = new JobService(options.endpoint);
|
||||
|
||||
this.state = {
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
initBuildClass() {
|
||||
this.build = new Build();
|
||||
}
|
||||
|
||||
fetchJob() {
|
||||
this.poll = new Poll({
|
||||
resource: this.service,
|
||||
method: 'getJob',
|
||||
successCallback: this.successCallback.bind(this),
|
||||
errorCallback: this.errorCallback.bind(this),
|
||||
});
|
||||
|
||||
if (!Visibility.hidden()) {
|
||||
this.state.isLoading = true;
|
||||
this.poll.makeRequest();
|
||||
} else {
|
||||
this.getJob();
|
||||
}
|
||||
|
||||
Visibility.change(() => {
|
||||
if (!Visibility.hidden()) {
|
||||
this.poll.restart();
|
||||
} else {
|
||||
this.poll.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getJob() {
|
||||
return this.service.getJob()
|
||||
.then(response => this.successCallback(response))
|
||||
.catch(() => this.errorCallback());
|
||||
}
|
||||
|
||||
successCallback(response) {
|
||||
const data = response.json();
|
||||
this.state.isLoading = false;
|
||||
this.store.storeJob(data);
|
||||
}
|
||||
|
||||
errorCallback() {
|
||||
this.state.isLoading = false;
|
||||
|
||||
return new Flash('An error occurred while fetching the job.');
|
||||
}
|
||||
}
|
14
app/assets/javascripts/jobs/services/job_service.js
Normal file
14
app/assets/javascripts/jobs/services/job_service.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import Vue from 'vue';
|
||||
import VueResource from 'vue-resource';
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
export default class JobService {
|
||||
constructor(endpoint) {
|
||||
this.job = Vue.resource(endpoint);
|
||||
}
|
||||
|
||||
getJob() {
|
||||
return this.job.get();
|
||||
}
|
||||
}
|
11
app/assets/javascripts/jobs/stores/job_store.js
Normal file
11
app/assets/javascripts/jobs/stores/job_store.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
export default class JobStore {
|
||||
constructor() {
|
||||
this.state = {
|
||||
job: {},
|
||||
};
|
||||
}
|
||||
|
||||
storeJob(job = {}) {
|
||||
this.state.job = job;
|
||||
}
|
||||
}
|
|
@ -146,3 +146,24 @@ window.dateFormat = dateFormat;
|
|||
};
|
||||
})(window);
|
||||
}).call(window);
|
||||
|
||||
/**
|
||||
* Port of ruby helper time_interval_in_words.
|
||||
*
|
||||
* @param {Number} seconds
|
||||
* @return {String}
|
||||
*/
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function timeIntervalInWords(intervalInSeconds) {
|
||||
const secondsInteger = parseInt(intervalInSeconds, 10);
|
||||
const minutes = Math.floor(secondsInteger / 60);
|
||||
const seconds = secondsInteger - (minutes * 60);
|
||||
let text = '';
|
||||
|
||||
if (minutes >= 1) {
|
||||
text = `${minutes} ${gl.text.pluralize('minute', minutes)} ${seconds} ${gl.text.pluralize('second', seconds)}`;
|
||||
} else {
|
||||
text = `${seconds} ${gl.text.pluralize('second', seconds)}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
|
|
@ -91,7 +91,7 @@ export default {
|
|||
@actionClicked="postAction"
|
||||
/>
|
||||
<loading-icon
|
||||
v-else
|
||||
v-if="isLoading"
|
||||
size="2"/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -40,6 +40,11 @@ export default {
|
|||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
hasSidebarButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [
|
||||
|
@ -66,8 +71,9 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-content-header">
|
||||
<header class="page-content-header ci-header-container">
|
||||
<section class="header-main-content">
|
||||
|
||||
<ci-icon-badge :status="status" />
|
||||
|
@ -102,7 +108,7 @@ export default {
|
|||
</section>
|
||||
|
||||
<section
|
||||
class="header-action-button nav-controls"
|
||||
class="header-action-buttons"
|
||||
v-if="actions.length">
|
||||
<template
|
||||
v-for="action in actions">
|
||||
|
@ -113,6 +119,15 @@ export default {
|
|||
{{action.label}}
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="action.type === 'ujs-link'"
|
||||
:href="action.path"
|
||||
data-method="post"
|
||||
rel="nofollow"
|
||||
:class="action.cssClass">
|
||||
{{action.label}}
|
||||
</a>
|
||||
|
||||
<button
|
||||
v-else="action.type === 'button'"
|
||||
@click="onClickAction(action)"
|
||||
|
@ -120,7 +135,6 @@ export default {
|
|||
:class="action.cssClass"
|
||||
type="button">
|
||||
{{action.label}}
|
||||
|
||||
<i
|
||||
v-show="action.isLoading"
|
||||
class="fa fa-spin fa-spinner"
|
||||
|
@ -128,6 +142,18 @@ export default {
|
|||
</i>
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-if="hasSidebarButton"
|
||||
type="button"
|
||||
class="btn btn-default visible-xs-block visible-sm-block sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header"
|
||||
aria-label="Toggle Sidebar"
|
||||
id="toggleSidebar">
|
||||
<i
|
||||
class="fa fa-angle-double-left"
|
||||
aria-hidden="true"
|
||||
aria-labelledby="toggleSidebar">
|
||||
</i>
|
||||
</button>
|
||||
</section>
|
||||
</header>
|
||||
</template>
|
||||
|
|
|
@ -153,15 +153,16 @@
|
|||
}
|
||||
|
||||
.environment-information {
|
||||
background-color: $gray-light;
|
||||
border: 1px solid $border-color;
|
||||
padding: 12px $gl-padding;
|
||||
padding: 8px $gl-padding 12px;
|
||||
border-radius: $border-radius-default;
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
top: 5px;
|
||||
margin-right: 5px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -175,54 +176,31 @@
|
|||
}
|
||||
}
|
||||
|
||||
.status-message {
|
||||
display: inline-block;
|
||||
color: $white-light;
|
||||
|
||||
.status-icon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 33px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
float: left;
|
||||
opacity: 0;
|
||||
margin-right: 10px;
|
||||
font-weight: normal;
|
||||
line-height: 1.8;
|
||||
transition: opacity 1s ease-out;
|
||||
|
||||
&.animate {
|
||||
animation: fade-out-status 2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .status-text {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.build-header {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
.ci-header-container,
|
||||
.header-action-buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (max-width: $screen-sm-max) {
|
||||
padding-right: 40px;
|
||||
margin-top: 6px;
|
||||
.ci-header-container {
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.btn-inverted {
|
||||
display: none;
|
||||
.page-content-header {
|
||||
padding: 10px 0 9px;
|
||||
}
|
||||
|
||||
.header-action-buttons {
|
||||
@media (max-width: $screen-xs-max) {
|
||||
.sidebar-toggle-btn {
|
||||
margin-top: 0;
|
||||
margin-left: 10px;
|
||||
max-height: 34px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
line-height: 1.8;
|
||||
|
||||
a {
|
||||
color: $gl-text-color;
|
||||
|
||||
|
@ -245,7 +223,7 @@
|
|||
}
|
||||
|
||||
.right-sidebar.build-sidebar {
|
||||
padding: $gl-padding 0;
|
||||
padding: 0;
|
||||
|
||||
&.right-sidebar-collapsed {
|
||||
display: none;
|
||||
|
@ -258,6 +236,10 @@
|
|||
.block {
|
||||
width: 100%;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid $border-gray-normal;
|
||||
}
|
||||
|
||||
&.coverage {
|
||||
padding: 0 16px 11px;
|
||||
}
|
||||
|
@ -267,34 +249,39 @@
|
|||
}
|
||||
}
|
||||
|
||||
.js-build-variable {
|
||||
.trigger-build-variable {
|
||||
color: $code-color;
|
||||
}
|
||||
|
||||
.js-build-value {
|
||||
.trigger-build-value {
|
||||
padding: 2px 4px;
|
||||
color: $black;
|
||||
background-color: $white-light;
|
||||
}
|
||||
|
||||
.build-sidebar-header {
|
||||
padding: 0 $gl-padding $gl-padding;
|
||||
|
||||
.gutter-toggle {
|
||||
margin-top: 0;
|
||||
}
|
||||
.label {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.retry-link {
|
||||
color: $gl-link-color;
|
||||
display: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
.btn-inverted-secondary {
|
||||
color: $blue-500;
|
||||
|
||||
&:hover {
|
||||
color: $white-light;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $screen-sm-max) {
|
||||
display: block;
|
||||
|
||||
.btn {
|
||||
i {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -318,6 +305,12 @@
|
|||
left: $gl-padding;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.builds-container {
|
||||
|
@ -379,6 +372,10 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-commit {
|
||||
color: $blue-600;
|
||||
}
|
||||
}
|
||||
|
||||
.build-sidebar {
|
||||
|
|
|
@ -986,10 +986,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
.pipeline-header-container {
|
||||
.ci-header-container {
|
||||
min-height: 55px;
|
||||
|
||||
.text-center {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.header-action-buttons {
|
||||
.btn,
|
||||
a {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -132,6 +132,11 @@ class CommitStatus < ActiveRecord::Base
|
|||
false
|
||||
end
|
||||
|
||||
# To be overriden when inherrited from
|
||||
def cancelable?
|
||||
false
|
||||
end
|
||||
|
||||
def stuck?
|
||||
false
|
||||
end
|
||||
|
|
|
@ -34,10 +34,8 @@ class BuildDetailsEntity < BuildEntity
|
|||
private
|
||||
|
||||
def build_failed_issue_options
|
||||
{
|
||||
title: "Build Failed ##{build.id}",
|
||||
description: namespace_project_job_url(project.namespace, project, build)
|
||||
}
|
||||
{ title: "Build Failed ##{build.id}",
|
||||
description: namespace_project_job_path(project.namespace, project, build) }
|
||||
end
|
||||
|
||||
def current_user
|
||||
|
|
|
@ -8,10 +8,14 @@ class BuildEntity < Grape::Entity
|
|||
path_to(:namespace_project_job, build)
|
||||
end
|
||||
|
||||
expose :retry_path, if: -> (*) { build&.retryable? } do |build|
|
||||
expose :retry_path, if: -> (*) { retryable? } do |build|
|
||||
path_to(:retry_namespace_project_job, build)
|
||||
end
|
||||
|
||||
expose :cancel_path, if: -> (*) { cancelable? } do |build|
|
||||
path_to(:cancel_namespace_project_job, build)
|
||||
end
|
||||
|
||||
expose :play_path, if: -> (*) { playable? } do |build|
|
||||
path_to(:play_namespace_project_job, build)
|
||||
end
|
||||
|
@ -25,6 +29,14 @@ class BuildEntity < Grape::Entity
|
|||
|
||||
alias_method :build, :object
|
||||
|
||||
def cancelable?
|
||||
build.cancelable? && can?(request.current_user, :update_build, build)
|
||||
end
|
||||
|
||||
def retryable?
|
||||
build.retryable? && can?(request.current_user, :update_build, build)
|
||||
end
|
||||
|
||||
def playable?
|
||||
build.playable? && can?(request.current_user, :update_build, build)
|
||||
end
|
||||
|
|
|
@ -1,19 +1,15 @@
|
|||
- builds = @build.pipeline.builds.to_a
|
||||
|
||||
%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } }
|
||||
.block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default
|
||||
Job
|
||||
%strong ##{@build.id}
|
||||
%a.gutter-toggle.pull-right.js-sidebar-build-toggle{ href: "#" }
|
||||
= icon('angle-double-right')
|
||||
- if @build.coverage
|
||||
.block.coverage
|
||||
.title
|
||||
Test coverage
|
||||
%p.build-detail-row
|
||||
#{@build.coverage}%
|
||||
|
||||
.blocks-container
|
||||
.block
|
||||
%strong
|
||||
= @build.name
|
||||
%a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' }
|
||||
= icon('angle-double-right')
|
||||
|
||||
#js-details-block-vue
|
||||
|
||||
- if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
|
||||
.block{ class: ("block-first" if !@build.coverage) }
|
||||
.title
|
||||
|
@ -40,37 +36,6 @@
|
|||
= link_to browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
|
||||
Browse
|
||||
|
||||
.block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) }
|
||||
.title
|
||||
Job details
|
||||
- if can?(current_user, :update_build, @build) && @build.retryable?
|
||||
= link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post
|
||||
- if @build.merge_request
|
||||
%p.build-detail-row
|
||||
%span.build-light-text Merge Request:
|
||||
= link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'bold'
|
||||
- if @build.duration
|
||||
%p.build-detail-row
|
||||
%span.build-light-text Duration:
|
||||
= time_interval_in_words(@build.duration)
|
||||
- if @build.finished_at
|
||||
%p.build-detail-row
|
||||
%span.build-light-text Finished:
|
||||
#{time_ago_with_tooltip(@build.finished_at)}
|
||||
- if @build.erased_at
|
||||
%p.build-detail-row
|
||||
%span.build-light-text Erased:
|
||||
#{time_ago_with_tooltip(@build.erased_at)}
|
||||
%p.build-detail-row
|
||||
%span.build-light-text Runner:
|
||||
- if @build.runner && current_user && current_user.admin
|
||||
= link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id)
|
||||
- elsif @build.runner
|
||||
\##{@build.runner.id}
|
||||
.btn-group.btn-group-justified{ role: :group }
|
||||
- if @build.active?
|
||||
= link_to "Cancel", cancel_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
|
||||
|
||||
- if @build.trigger_request
|
||||
.build-widget
|
||||
%h4.title
|
||||
|
@ -87,26 +52,29 @@
|
|||
|
||||
- @build.trigger_request.variables.each do |key, value|
|
||||
.hide.js-build
|
||||
.js-build-variable= key
|
||||
.js-build-value= value
|
||||
.js-build-variable.trigger-build-variable= key
|
||||
.js-build-value.trigger-build-value= value
|
||||
|
||||
.block
|
||||
.title
|
||||
Commit title
|
||||
%p
|
||||
Commit
|
||||
= link_to @build.pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, @build.pipeline.sha), class: 'commit-sha link-commit'
|
||||
= clipboard_button(text: @build.pipeline.short_sha, title: "Copy commit SHA to clipboard")
|
||||
- if @build.merge_request
|
||||
in
|
||||
= link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'link-commit'
|
||||
|
||||
%p.build-light-text.append-bottom-0
|
||||
#{@build.pipeline.git_commit_title}
|
||||
|
||||
- if @build.tags.any?
|
||||
.block
|
||||
.title
|
||||
Tags
|
||||
- @build.tag_list.each do |tag|
|
||||
%span.label.label-primary
|
||||
= tag
|
||||
|
||||
- if @build.pipeline.stages_count > 1
|
||||
.dropdown.build-dropdown
|
||||
.title Stage
|
||||
.title
|
||||
%span{ class: "ci-status-icon-#{@build.pipeline.status}" }
|
||||
= ci_icon_for_status(@build.pipeline.status)
|
||||
= link_to "##{@build.pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @build.pipeline), class: 'link-commit'
|
||||
from
|
||||
= link_to "#{@build.pipeline.ref}", namespace_project_branch_path(@project.namespace, @project, @build.pipeline.ref), class: 'link-commit'
|
||||
%button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
|
||||
%span.stage-selection More
|
||||
= icon('chevron-down')
|
||||
|
|
|
@ -3,9 +3,8 @@
|
|||
= render "projects/pipelines/head"
|
||||
|
||||
%div{ class: container_class }
|
||||
.build-page
|
||||
= render "header"
|
||||
|
||||
.build-page.js-build-page
|
||||
#js-build-header-vue
|
||||
- if @build.stuck?
|
||||
- unless @build.any_runners_online?
|
||||
.bs-callout.bs-callout-warning.js-build-stuck
|
||||
|
@ -47,47 +46,52 @@
|
|||
- if environment.try(:last_deployment)
|
||||
and will overwrite the #{deployment_link(environment.last_deployment, text: 'latest deployment')}
|
||||
|
||||
.prepend-top-default.js-build-erased
|
||||
- if @build.erased?
|
||||
- if @build.erased?
|
||||
.prepend-top-default.js-build-erased
|
||||
.erased.alert.alert-warning
|
||||
- if @build.erased_by_user?
|
||||
Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)}
|
||||
- else
|
||||
Job has been erased #{time_ago_with_tooltip(@build.erased_at)}
|
||||
|
||||
.prepend-top-default
|
||||
.build-trace-container#build-trace
|
||||
.top-bar.sticky
|
||||
.js-truncated-info.truncated-info.hidden<
|
||||
Showing last
|
||||
%span.js-truncated-info-size.truncated-info-size><
|
||||
KiB of log -
|
||||
%a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw
|
||||
.controllers
|
||||
- if @build.has_trace?
|
||||
= link_to raw_namespace_project_job_path(@project.namespace, @project, @build),
|
||||
title: 'Show complete raw',
|
||||
data: { placement: 'top', container: 'body' },
|
||||
class: 'js-raw-link-controller has-tooltip controllers-buttons' do
|
||||
= icon('file-text-o')
|
||||
.build-trace-container#build-trace
|
||||
.top-bar.sticky
|
||||
.js-truncated-info.truncated-info.hidden<
|
||||
Showing last
|
||||
%span.js-truncated-info-size.truncated-info-size><
|
||||
KiB of log -
|
||||
%a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw
|
||||
.controllers
|
||||
- if @build.has_trace?
|
||||
= link_to raw_namespace_project_job_path(@project.namespace, @project, @build),
|
||||
title: 'Show complete raw',
|
||||
data: { placement: 'top', container: 'body' },
|
||||
class: 'js-raw-link-controller has-tooltip controllers-buttons' do
|
||||
= icon('file-text-o')
|
||||
|
||||
- if can?(current_user, :update_build, @project) && @build.erasable?
|
||||
= link_to erase_namespace_project_job_path(@project.namespace, @project, @build),
|
||||
method: :post,
|
||||
data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' },
|
||||
title: 'Erase job log',
|
||||
class: 'has-tooltip js-erase-link controllers-buttons' do
|
||||
= icon('trash')
|
||||
.has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} }
|
||||
%button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
|
||||
= custom_icon('scroll_up')
|
||||
.has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} }
|
||||
%button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
|
||||
= custom_icon('scroll_down')
|
||||
.bash.sticky.js-scroll-container
|
||||
%code.js-build-output
|
||||
.build-loader-animation.js-build-refresh
|
||||
- if can?(current_user, :update_build, @project) && @build.erasable?
|
||||
= link_to erase_namespace_project_job_path(@project.namespace, @project, @build),
|
||||
method: :post,
|
||||
data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' },
|
||||
title: 'Erase job log',
|
||||
class: 'has-tooltip js-erase-link controllers-buttons' do
|
||||
= icon('trash')
|
||||
.has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} }
|
||||
%button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
|
||||
= custom_icon('scroll_up')
|
||||
.has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} }
|
||||
%button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
|
||||
= custom_icon('scroll_down')
|
||||
.bash.sticky.js-scroll-container
|
||||
%code.js-build-output
|
||||
.build-loader-animation.js-build-refresh
|
||||
|
||||
= render "sidebar"
|
||||
|
||||
.js-build-options{ data: javascript_build_options }
|
||||
|
||||
#js-job-details-vue{ data: { endpoint: namespace_project_job_path(@project.namespace, @project, @build, format: :json) } }
|
||||
|
||||
- content_for :page_specific_javascripts do
|
||||
= webpack_bundle_tag('common_vue')
|
||||
= webpack_bundle_tag('job_details')
|
||||
|
|
4
changelogs/unreleased/31397-job-detail-real-time.yml
Normal file
4
changelogs/unreleased/31397-job-detail-real-time.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Adds realtime feature to job show view header and sidebar info. Updates UX.
|
||||
merge_request:
|
||||
author:
|
|
@ -44,6 +44,7 @@ var config = {
|
|||
groups_list: './groups_list.js',
|
||||
issue_show: './issue_show/index.js',
|
||||
integrations: './integrations',
|
||||
job_details: './jobs/job_details_bundle.js',
|
||||
locale: './locale/index.js',
|
||||
main: './main.js',
|
||||
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
|
||||
|
@ -158,6 +159,7 @@ var config = {
|
|||
'filtered_search',
|
||||
'groups',
|
||||
'issue_show',
|
||||
'job_details',
|
||||
'merge_conflicts',
|
||||
'notebook_viewer',
|
||||
'pdf_viewer',
|
||||
|
|
|
@ -27,6 +27,7 @@ Feature: Project Builds Permissions
|
|||
When I visit project builds page
|
||||
Then page status code should be 404
|
||||
|
||||
@javascript
|
||||
Scenario: I try to visit build details of internal project with access to builds
|
||||
Given The project is internal
|
||||
And public access for builds is enabled
|
||||
|
|
|
@ -6,16 +6,19 @@ Feature: Project Builds Summary
|
|||
And project has coverage enabled
|
||||
And project has a recent build
|
||||
|
||||
@javascript
|
||||
Scenario: I browse build details page
|
||||
When I visit recent build details page
|
||||
Then I see details of a build
|
||||
And I see build trace
|
||||
|
||||
@javascript
|
||||
Scenario: I browse project builds page
|
||||
When I visit project builds page
|
||||
Then I see coverage
|
||||
Then I see button to CI Lint
|
||||
|
||||
@javascript
|
||||
Scenario: I erase a build
|
||||
Given recent build is successful
|
||||
And recent build has a build trace
|
||||
|
|
|
@ -13,7 +13,7 @@ class Spinach::Features::ProjectBuildsSummary < Spinach::FeatureSteps
|
|||
step 'I see button to CI Lint' do
|
||||
page.within('.nav-controls') do
|
||||
ci_lint_tool_link = page.find_link('CI lint')
|
||||
expect(ci_lint_tool_link[:href]).to eq ci_lint_path
|
||||
expect(ci_lint_tool_link[:href]).to end_with(ci_lint_path)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ feature 'Jobs', :feature do
|
|||
let(:user) { create(:user) }
|
||||
let(:user_access_level) { :developer }
|
||||
let(:project) { create(:project) }
|
||||
let(:namespace) { project.namespace }
|
||||
let(:pipeline) { create(:ci_pipeline, project: project) }
|
||||
|
||||
let(:build) { create(:ci_build, :trace, pipeline: pipeline) }
|
||||
|
@ -113,10 +114,16 @@ feature 'Jobs', :feature do
|
|||
|
||||
describe "GET /:project/jobs/:id" do
|
||||
context "Job from project" do
|
||||
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
|
||||
|
||||
before do
|
||||
visit namespace_project_job_path(project.namespace, project, build)
|
||||
end
|
||||
|
||||
it 'shows status name', :js do
|
||||
expect(page).to have_css('.ci-status.ci-success', text: 'passed')
|
||||
end
|
||||
|
||||
it 'shows commit`s data' do
|
||||
expect(page.status_code).to eq(200)
|
||||
expect(page).to have_content pipeline.sha[0..7]
|
||||
|
@ -129,6 +136,48 @@ feature 'Jobs', :feature do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when job is not running', :js do
|
||||
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
|
||||
|
||||
before do
|
||||
visit namespace_project_job_path(project.namespace, project, build)
|
||||
end
|
||||
|
||||
it 'shows retry button' do
|
||||
expect(page).to have_link('Retry')
|
||||
end
|
||||
|
||||
context 'if build passed' do
|
||||
it 'does not show New issue button' do
|
||||
expect(page).not_to have_link('New issue')
|
||||
end
|
||||
end
|
||||
|
||||
context 'if build failed' do
|
||||
let(:build) { create(:ci_build, :failed, pipeline: pipeline) }
|
||||
|
||||
before do
|
||||
visit namespace_project_job_path(namespace, project, build)
|
||||
end
|
||||
|
||||
it 'shows New issue button' do
|
||||
expect(page).to have_link('New issue')
|
||||
end
|
||||
|
||||
it 'links to issues/new with the title and description filled in' do
|
||||
button_title = "Build Failed ##{build.id}"
|
||||
build_path = namespace_project_job_path(namespace, project, build)
|
||||
options = { issue: { title: button_title, description: build_path } }
|
||||
|
||||
href = new_namespace_project_issue_path(namespace, project, options)
|
||||
|
||||
page.within('.header-action-buttons') do
|
||||
expect(find('.js-new-issue')['href']).to include(href)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "Job from other project" do
|
||||
before do
|
||||
visit namespace_project_job_path(project.namespace, project, build2)
|
||||
|
@ -305,63 +354,38 @@ feature 'Jobs', :feature do
|
|||
end
|
||||
end
|
||||
|
||||
describe "POST /:project/jobs/:id/cancel" do
|
||||
describe "POST /:project/jobs/:id/cancel", :js do
|
||||
context "Job from project" do
|
||||
before do
|
||||
build.run!
|
||||
visit namespace_project_job_path(project.namespace, project, build)
|
||||
click_link "Cancel"
|
||||
find('.js-cancel-job').click()
|
||||
end
|
||||
|
||||
it 'loads the page and shows all needed controls' do
|
||||
expect(page.status_code).to eq(200)
|
||||
expect(page).to have_content 'canceled'
|
||||
expect(page).to have_content 'Retry'
|
||||
end
|
||||
end
|
||||
|
||||
context "Job from other project" do
|
||||
before do
|
||||
build.run!
|
||||
visit namespace_project_job_path(project.namespace, project, build)
|
||||
page.driver.post(cancel_namespace_project_job_path(project.namespace, project, build2))
|
||||
end
|
||||
|
||||
it { expect(page.status_code).to eq(404) }
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /:project/jobs/:id/retry" do
|
||||
context "Job from project" do
|
||||
context "Job from project", :js do
|
||||
before do
|
||||
build.run!
|
||||
visit namespace_project_job_path(project.namespace, project, build)
|
||||
click_link 'Cancel'
|
||||
page.within('.build-header') do
|
||||
click_link 'Retry job'
|
||||
end
|
||||
find('.js-cancel-job').click()
|
||||
find('.js-retry-button').trigger('click')
|
||||
end
|
||||
|
||||
it 'shows the right status and buttons' do
|
||||
it 'shows the right status and buttons', :js do
|
||||
expect(page).to have_http_status(200)
|
||||
expect(page).to have_content 'pending'
|
||||
page.within('aside.right-sidebar') do
|
||||
expect(page).to have_content 'Cancel'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "Job from other project" do
|
||||
before do
|
||||
build.run!
|
||||
visit namespace_project_job_path(project.namespace, project, build)
|
||||
click_link 'Cancel'
|
||||
page.driver.post(retry_namespace_project_job_path(project.namespace, project, build2))
|
||||
end
|
||||
|
||||
it { expect(page).to have_http_status(404) }
|
||||
end
|
||||
|
||||
context "Job that current user is not allowed to retry" do
|
||||
before do
|
||||
build.run!
|
||||
|
@ -435,20 +459,17 @@ feature 'Jobs', :feature do
|
|||
Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' }
|
||||
|
||||
build.run!
|
||||
|
||||
allow_any_instance_of(Gitlab::Ci::Trace).to receive(:paths)
|
||||
.and_return(paths)
|
||||
|
||||
visit namespace_project_job_path(project.namespace, project, build)
|
||||
end
|
||||
|
||||
context 'when build has trace in file', :js do
|
||||
let(:paths) do
|
||||
[existing_file]
|
||||
end
|
||||
|
||||
before do
|
||||
find('.js-raw-link-controller').click()
|
||||
allow_any_instance_of(Gitlab::Ci::Trace)
|
||||
.to receive(:paths)
|
||||
.and_return([existing_file])
|
||||
|
||||
visit namespace_project_job_path(namespace, project, build)
|
||||
|
||||
find('.js-raw-link-controller').click
|
||||
end
|
||||
|
||||
it 'sends the right headers' do
|
||||
|
@ -458,11 +479,17 @@ feature 'Jobs', :feature do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when job has trace in DB' do
|
||||
let(:paths) { [] }
|
||||
context 'when job has trace in the database', :js do
|
||||
before do
|
||||
allow_any_instance_of(Gitlab::Ci::Trace)
|
||||
.to receive(:paths)
|
||||
.and_return([])
|
||||
|
||||
visit namespace_project_job_path(namespace, project, build)
|
||||
end
|
||||
|
||||
it 'sends the right headers' do
|
||||
expect(page.status_code).not_to have_selector('.js-raw-link-controller')
|
||||
expect(page).not_to have_selector('.js-raw-link-controller')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -132,23 +132,6 @@ describe('Build', () => {
|
|||
expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/);
|
||||
expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
|
||||
});
|
||||
|
||||
it('reloads the page when the build is done', () => {
|
||||
spyOn(gl.utils, 'visitUrl');
|
||||
const deferred = $.Deferred();
|
||||
|
||||
spyOn($, 'ajax').and.returnValue(deferred.promise());
|
||||
deferred.resolve({
|
||||
html: '<span>Final</span>',
|
||||
status: 'passed',
|
||||
append: true,
|
||||
complete: true,
|
||||
});
|
||||
|
||||
this.build = new Build();
|
||||
|
||||
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BUILD_URL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncated information', () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import '~/lib/utils/datetime_utility';
|
||||
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
|
||||
|
||||
(() => {
|
||||
describe('Date time utils', () => {
|
||||
|
@ -82,4 +82,13 @@ import '~/lib/utils/datetime_utility';
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeIntervalInWords', () => {
|
||||
it('should return string with number of minutes and seconds', () => {
|
||||
expect(timeIntervalInWords(9.54)).toEqual('9 seconds');
|
||||
expect(timeIntervalInWords(1)).toEqual('1 second');
|
||||
expect(timeIntervalInWords(200)).toEqual('3 minutes 20 seconds');
|
||||
expect(timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds');
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
63
spec/javascripts/jobs/header_spec.js
Normal file
63
spec/javascripts/jobs/header_spec.js
Normal file
|
@ -0,0 +1,63 @@
|
|||
import Vue from 'vue';
|
||||
import headerComponent from '~/jobs/components/header.vue';
|
||||
|
||||
describe('Job details header', () => {
|
||||
let HeaderComponent;
|
||||
let vm;
|
||||
let props;
|
||||
|
||||
beforeEach(() => {
|
||||
HeaderComponent = Vue.extend(headerComponent);
|
||||
|
||||
const threeWeeksAgo = new Date();
|
||||
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
|
||||
|
||||
props = {
|
||||
job: {
|
||||
status: {
|
||||
group: 'failed',
|
||||
icon: 'ci-status-failed',
|
||||
label: 'failed',
|
||||
text: 'failed',
|
||||
details_path: 'path',
|
||||
},
|
||||
id: 123,
|
||||
created_at: threeWeeksAgo.toISOString(),
|
||||
user: {
|
||||
web_url: 'path',
|
||||
name: 'Foo',
|
||||
username: 'foobar',
|
||||
email: 'foo@bar.com',
|
||||
avatar_url: 'link',
|
||||
},
|
||||
retry_path: 'path',
|
||||
new_issue_path: 'path',
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
vm = new HeaderComponent({ propsData: props }).$mount();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render provided job information', () => {
|
||||
expect(
|
||||
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
|
||||
).toEqual('failed Job #123 triggered 3 weeks ago by Foo');
|
||||
});
|
||||
|
||||
it('should render retry link', () => {
|
||||
expect(
|
||||
vm.$el.querySelector('.js-retry-button').getAttribute('href'),
|
||||
).toEqual(props.job.retry_path);
|
||||
});
|
||||
|
||||
it('should render new issue link', () => {
|
||||
expect(
|
||||
vm.$el.querySelector('.js-new-issue').getAttribute('href'),
|
||||
).toEqual(props.job.new_issue_path);
|
||||
});
|
||||
});
|
43
spec/javascripts/jobs/job_details_mediator_spec.js
Normal file
43
spec/javascripts/jobs/job_details_mediator_spec.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
import Vue from 'vue';
|
||||
import JobMediator from '~/jobs/job_details_mediator';
|
||||
import job from './mock_data';
|
||||
|
||||
describe('JobMediator', () => {
|
||||
let mediator;
|
||||
|
||||
beforeEach(() => {
|
||||
mediator = new JobMediator({ endpoint: 'foo' });
|
||||
});
|
||||
|
||||
it('should set defaults', () => {
|
||||
expect(mediator.store).toBeDefined();
|
||||
expect(mediator.service).toBeDefined();
|
||||
expect(mediator.options).toEqual({ endpoint: 'foo' });
|
||||
expect(mediator.state.isLoading).toEqual(false);
|
||||
});
|
||||
|
||||
describe('request and store data', () => {
|
||||
const interceptor = (request, next) => {
|
||||
next(request.respondWith(JSON.stringify(job), {
|
||||
status: 200,
|
||||
}));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Vue.http.interceptors.push(interceptor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor);
|
||||
});
|
||||
|
||||
it('should store received data', (done) => {
|
||||
mediator.fetchJob();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(mediator.store.state.job).toEqual(job);
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
});
|
26
spec/javascripts/jobs/job_store_spec.js
Normal file
26
spec/javascripts/jobs/job_store_spec.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import JobStore from '~/jobs/stores/job_store';
|
||||
import job from './mock_data';
|
||||
|
||||
describe('Job Store', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new JobStore();
|
||||
});
|
||||
|
||||
it('should set defaults', () => {
|
||||
expect(store.state.job).toEqual({});
|
||||
});
|
||||
|
||||
describe('storeJob', () => {
|
||||
it('should store empty object if none is provided', () => {
|
||||
store.storeJob();
|
||||
expect(store.state.job).toEqual({});
|
||||
});
|
||||
|
||||
it('should store provided argument', () => {
|
||||
store.storeJob(job);
|
||||
expect(store.state.job).toEqual(job);
|
||||
});
|
||||
});
|
||||
});
|
123
spec/javascripts/jobs/mock_data.js
Normal file
123
spec/javascripts/jobs/mock_data.js
Normal file
|
@ -0,0 +1,123 @@
|
|||
const threeWeeksAgo = new Date();
|
||||
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
|
||||
|
||||
export default {
|
||||
id: 4757,
|
||||
name: 'test',
|
||||
build_path: '/root/ci-mock/-/jobs/4757',
|
||||
retry_path: '/root/ci-mock/-/jobs/4757/retry',
|
||||
cancel_path: '/root/ci-mock/-/jobs/4757/cancel',
|
||||
new_issue_path: '/root/ci-mock/issues/new',
|
||||
playable: false,
|
||||
created_at: threeWeeksAgo.toISOString(),
|
||||
updated_at: threeWeeksAgo.toISOString(),
|
||||
finished_at: threeWeeksAgo.toISOString(),
|
||||
queued: 9.54,
|
||||
status: {
|
||||
icon: 'icon_status_success',
|
||||
text: 'passed',
|
||||
label: 'passed',
|
||||
group: 'success',
|
||||
has_details: true,
|
||||
details_path: '/root/ci-mock/-/jobs/4757',
|
||||
favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
|
||||
action: {
|
||||
icon: 'icon_action_retry',
|
||||
title: 'Retry',
|
||||
path: '/root/ci-mock/-/jobs/4757/retry',
|
||||
method: 'post',
|
||||
},
|
||||
},
|
||||
coverage: 20,
|
||||
erased_at: threeWeeksAgo.toISOString(),
|
||||
duration: 6.785563,
|
||||
tags: ['tag'],
|
||||
user: {
|
||||
name: 'Root',
|
||||
username: 'root',
|
||||
id: 1,
|
||||
state: 'active',
|
||||
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
|
||||
web_url: 'http://localhost:3000/root',
|
||||
},
|
||||
erase_path: '/root/ci-mock/-/jobs/4757/erase',
|
||||
artifacts: [null],
|
||||
runner: {
|
||||
id: 1,
|
||||
description: 'local ci runner',
|
||||
edit_path: '/root/ci-mock/runners/1/edit',
|
||||
},
|
||||
pipeline: {
|
||||
id: 140,
|
||||
user: {
|
||||
name: 'Root',
|
||||
username: 'root',
|
||||
id: 1,
|
||||
state: 'active',
|
||||
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
|
||||
web_url: 'http://localhost:3000/root',
|
||||
},
|
||||
active: false,
|
||||
coverage: null,
|
||||
source: 'unknown',
|
||||
created_at: '2017-05-24T09:59:58.634Z',
|
||||
updated_at: '2017-06-01T17:32:00.062Z',
|
||||
path: '/root/ci-mock/pipelines/140',
|
||||
flags: {
|
||||
latest: true,
|
||||
stuck: false,
|
||||
yaml_errors: false,
|
||||
retryable: false,
|
||||
cancelable: false,
|
||||
},
|
||||
details: {
|
||||
status: {
|
||||
icon: 'icon_status_success',
|
||||
text: 'passed',
|
||||
label: 'passed',
|
||||
group: 'success',
|
||||
has_details: true,
|
||||
details_path: '/root/ci-mock/pipelines/140',
|
||||
favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
|
||||
},
|
||||
duration: 6,
|
||||
finished_at: '2017-06-01T17:32:00.042Z',
|
||||
},
|
||||
ref: {
|
||||
name: 'abc',
|
||||
path: '/root/ci-mock/commits/abc',
|
||||
tag: false,
|
||||
branch: true,
|
||||
},
|
||||
commit: {
|
||||
id: 'c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
|
||||
short_id: 'c5864777',
|
||||
title: 'Add new file',
|
||||
created_at: '2017-05-24T10:59:52.000+01:00',
|
||||
parent_ids: ['798e5f902592192afaba73f4668ae30e56eae492'],
|
||||
message: 'Add new file',
|
||||
author_name: 'Root',
|
||||
author_email: 'admin@example.com',
|
||||
authored_date: '2017-05-24T10:59:52.000+01:00',
|
||||
committer_name: 'Root',
|
||||
committer_email: 'admin@example.com',
|
||||
committed_date: '2017-05-24T10:59:52.000+01:00',
|
||||
author: {
|
||||
name: 'Root',
|
||||
username: 'root',
|
||||
id: 1,
|
||||
state: 'active',
|
||||
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
|
||||
web_url: 'http://localhost:3000/root',
|
||||
},
|
||||
author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
|
||||
commit_url: 'http://localhost:3000/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
|
||||
commit_path: '/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
|
||||
},
|
||||
},
|
||||
merge_request: {
|
||||
iid: 2,
|
||||
path: '/root/ci-mock/merge_requests/2',
|
||||
},
|
||||
raw_path: '/root/ci-mock/builds/4757/raw',
|
||||
};
|
40
spec/javascripts/jobs/sidebar_detail_row_spec.js
Normal file
40
spec/javascripts/jobs/sidebar_detail_row_spec.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
import Vue from 'vue';
|
||||
import sidebarDetailRow from '~/jobs/components/sidebar_detail_row.vue';
|
||||
|
||||
describe('Sidebar detail row', () => {
|
||||
let SidebarDetailRow;
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
SidebarDetailRow = Vue.extend(sidebarDetailRow);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render no title', () => {
|
||||
vm = new SidebarDetailRow({
|
||||
propsData: {
|
||||
value: 'this is the value',
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('this is the value');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vm = new SidebarDetailRow({
|
||||
propsData: {
|
||||
title: 'this is the title',
|
||||
value: 'this is the value',
|
||||
},
|
||||
}).$mount();
|
||||
});
|
||||
|
||||
it('should render provided title and value', () => {
|
||||
expect(
|
||||
vm.$el.textContent.replace(/\s+/g, ' ').trim(),
|
||||
).toEqual('this is the title: this is the value');
|
||||
});
|
||||
});
|
111
spec/javascripts/jobs/sidebar_details_block_spec.js
Normal file
111
spec/javascripts/jobs/sidebar_details_block_spec.js
Normal file
|
@ -0,0 +1,111 @@
|
|||
import Vue from 'vue';
|
||||
import sidebarDetailsBlock from '~/jobs/components/sidebar_details_block.vue';
|
||||
import job from './mock_data';
|
||||
|
||||
describe('Sidebar details block', () => {
|
||||
let SidebarComponent;
|
||||
let vm;
|
||||
|
||||
function trimWhitespace(element) {
|
||||
return element.textContent.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
SidebarComponent = Vue.extend(sidebarDetailsBlock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('when it is loading', () => {
|
||||
it('should render a loading spinner', () => {
|
||||
vm = new SidebarComponent({
|
||||
propsData: {
|
||||
job: {},
|
||||
isLoading: true,
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vm = new SidebarComponent({
|
||||
propsData: {
|
||||
job,
|
||||
isLoading: false,
|
||||
},
|
||||
}).$mount();
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
it('should render link to new issue', () => {
|
||||
expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(job.new_issue_path);
|
||||
expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
|
||||
});
|
||||
|
||||
it('should render link to retry job', () => {
|
||||
expect(vm.$el.querySelector('.js-retry-job').getAttribute('href')).toEqual(job.retry_path);
|
||||
});
|
||||
|
||||
it('should render link to cancel job', () => {
|
||||
expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path);
|
||||
});
|
||||
});
|
||||
|
||||
describe('information', () => {
|
||||
it('should render merge request link', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-mr')),
|
||||
).toEqual('Merge Request: !2');
|
||||
|
||||
expect(
|
||||
vm.$el.querySelector('.js-job-mr a').getAttribute('href'),
|
||||
).toEqual(job.merge_request.path);
|
||||
});
|
||||
|
||||
it('should render job duration', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-duration')),
|
||||
).toEqual('Duration: 6 seconds');
|
||||
});
|
||||
|
||||
it('should render erased date', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-erased')),
|
||||
).toEqual('Erased: 3 weeks ago');
|
||||
});
|
||||
|
||||
it('should render finished date', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-finished')),
|
||||
).toEqual('Finished: 3 weeks ago');
|
||||
});
|
||||
|
||||
it('should render queued date', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-queued')),
|
||||
).toEqual('Queued: 9 seconds');
|
||||
});
|
||||
|
||||
it('should render runner ID', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-runner')),
|
||||
).toEqual('Runner: #1');
|
||||
});
|
||||
|
||||
it('should render coverage', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-coverage')),
|
||||
).toEqual('Coverage: 20%');
|
||||
});
|
||||
|
||||
it('should render tags', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-tags')),
|
||||
).toEqual('Tags: tag');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -43,6 +43,7 @@ describe('Header CI Component', () => {
|
|||
isLoading: false,
|
||||
},
|
||||
],
|
||||
hasSidebarButton: true,
|
||||
};
|
||||
|
||||
vm = new HeaderCi({
|
||||
|
@ -90,4 +91,8 @@ describe('Header CI Component', () => {
|
|||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render sidebar toggle button', () => {
|
||||
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,12 +2,13 @@ require 'spec_helper'
|
|||
|
||||
describe BuildEntity do
|
||||
let(:user) { create(:user) }
|
||||
let(:build) { create(:ci_build, :failed) }
|
||||
let(:build) { create(:ci_build) }
|
||||
let(:project) { build.project }
|
||||
let(:request) { double('request') }
|
||||
|
||||
before do
|
||||
allow(request).to receive(:current_user).and_return(user)
|
||||
project.add_developer(user)
|
||||
end
|
||||
|
||||
let(:entity) do
|
||||
|
@ -16,9 +17,8 @@ describe BuildEntity do
|
|||
|
||||
subject { entity.as_json }
|
||||
|
||||
it 'contains paths to build page and retry action' do
|
||||
expect(subject).to include(:build_path, :retry_path)
|
||||
expect(subject[:retry_path]).not_to be_nil
|
||||
it 'contains paths to build page action' do
|
||||
expect(subject).to include(:build_path)
|
||||
end
|
||||
|
||||
it 'does not contain sensitive information' do
|
||||
|
@ -39,12 +39,32 @@ describe BuildEntity do
|
|||
expect(subject[:status]).to include :icon, :favicon, :text, :label
|
||||
end
|
||||
|
||||
context 'when build is a regular job' do
|
||||
context 'when build is retryable' do
|
||||
before do
|
||||
build.update(status: :failed)
|
||||
end
|
||||
|
||||
it 'contains cancel path' do
|
||||
expect(subject).to include(:retry_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when build is cancelable' do
|
||||
before do
|
||||
build.update(status: :running)
|
||||
end
|
||||
|
||||
it 'contains cancel path' do
|
||||
expect(subject).to include(:cancel_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when build is a regular build' do
|
||||
it 'does not contain path to play action' do
|
||||
expect(subject).not_to include(:play_path)
|
||||
end
|
||||
|
||||
it 'is not a playable job' do
|
||||
it 'is not a playable build' do
|
||||
expect(subject[:playable]).to be false
|
||||
end
|
||||
end
|
||||
|
|
|
@ -15,36 +15,6 @@ describe 'projects/jobs/show', :view do
|
|||
allow(view).to receive(:can?).and_return(true)
|
||||
end
|
||||
|
||||
describe 'job information in header' do
|
||||
let(:build) do
|
||||
create(:ci_build, :success, environment: 'staging')
|
||||
end
|
||||
|
||||
before do
|
||||
render
|
||||
end
|
||||
|
||||
it 'shows status name' do
|
||||
expect(rendered).to have_css('.ci-status.ci-success', text: 'passed')
|
||||
end
|
||||
|
||||
it 'does not render a link to the job' do
|
||||
expect(rendered).not_to have_link('passed')
|
||||
end
|
||||
|
||||
it 'shows job id' do
|
||||
expect(rendered).to have_css('.js-build-id', text: build.id)
|
||||
end
|
||||
|
||||
it 'shows a link to the pipeline' do
|
||||
expect(rendered).to have_link(build.pipeline.id)
|
||||
end
|
||||
|
||||
it 'shows a link to the commit' do
|
||||
expect(rendered).to have_link(build.pipeline.short_sha)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'environment info in job view' do
|
||||
context 'job with latest deployment' do
|
||||
let(:build) do
|
||||
|
@ -215,34 +185,6 @@ describe 'projects/jobs/show', :view do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when job is not running' do
|
||||
before do
|
||||
build.success!
|
||||
render
|
||||
end
|
||||
|
||||
it 'shows retry button' do
|
||||
expect(rendered).to have_link('Retry')
|
||||
end
|
||||
|
||||
context 'if build passed' do
|
||||
it 'does not show New issue button' do
|
||||
expect(rendered).not_to have_link('New issue')
|
||||
end
|
||||
end
|
||||
|
||||
context 'if build failed' do
|
||||
before do
|
||||
build.status = 'failed'
|
||||
render
|
||||
end
|
||||
|
||||
it 'shows New issue button' do
|
||||
expect(rendered).to have_link('New issue')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'commit title in sidebar' do
|
||||
let(:commit_title) { project.commit.title }
|
||||
|
||||
|
@ -269,25 +211,4 @@ describe 'projects/jobs/show', :view do
|
|||
expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'New issue button' do
|
||||
before do
|
||||
build.status = 'failed'
|
||||
render
|
||||
end
|
||||
|
||||
it 'links to issues/new with the title and description filled in' do
|
||||
title = "Build Failed ##{build.id}"
|
||||
build_url = namespace_project_job_url(project.namespace, project, build)
|
||||
href = new_namespace_project_issue_path(
|
||||
project.namespace,
|
||||
project,
|
||||
issue: {
|
||||
title: title,
|
||||
description: build_url
|
||||
}
|
||||
)
|
||||
expect(rendered).to have_link('New issue', href: href)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue