summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2017-06-12 09:20:19 +0000
committerPhil Hughes <me@iamphill.com>2017-06-12 09:20:19 +0000
commit452202e36d3e20755b099a718a92d3f7b80fabb8 (patch)
tree5cc8bd9c5d810a645f44f515c4310731fa01785f
parentd25f6fcf629bd773ccac49a799393479c48f4673 (diff)
downloadgitlab-ce-452202e36d3e20755b099a718a92d3f7b80fabb8.tar.gz
Improve Job detail view to make it refreshed in real-time instead of reloading
-rw-r--r--app/assets/javascripts/build.js31
-rw-r--r--app/assets/javascripts/dispatcher.js4
-rw-r--r--app/assets/javascripts/jobs/components/header.vue83
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_detail_row.vue31
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_details_block.vue150
-rw-r--r--app/assets/javascripts/jobs/job_details_bundle.js68
-rw-r--r--app/assets/javascripts/jobs/job_details_mediator.js67
-rw-r--r--app/assets/javascripts/jobs/services/job_service.js14
-rw-r--r--app/assets/javascripts/jobs/stores/job_store.js11
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js21
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue32
-rw-r--r--app/assets/stylesheets/pages/builds.scss103
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss9
-rw-r--r--app/models/commit_status.rb5
-rw-r--r--app/serializers/build_details_entity.rb6
-rw-r--r--app/serializers/build_entity.rb14
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml80
-rw-r--r--app/views/projects/jobs/show.html.haml76
-rw-r--r--changelogs/unreleased/31397-job-detail-real-time.yml4
-rw-r--r--config/webpack.config.js2
-rw-r--r--features/project/builds/permissions.feature1
-rw-r--r--features/project/builds/summary.feature3
-rw-r--r--features/steps/project/builds/summary.rb2
-rw-r--r--spec/features/projects/jobs_spec.rb115
-rw-r--r--spec/javascripts/build_spec.js17
-rw-r--r--spec/javascripts/datetime_utility_spec.js11
-rw-r--r--spec/javascripts/jobs/header_spec.js63
-rw-r--r--spec/javascripts/jobs/job_details_mediator_spec.js43
-rw-r--r--spec/javascripts/jobs/job_store_spec.js26
-rw-r--r--spec/javascripts/jobs/mock_data.js123
-rw-r--r--spec/javascripts/jobs/sidebar_detail_row_spec.js40
-rw-r--r--spec/javascripts/jobs/sidebar_details_block_spec.js111
-rw-r--r--spec/javascripts/vue_shared/components/header_ci_component_spec.js5
-rw-r--r--spec/serializers/build_entity_spec.rb32
-rw-r--r--spec/views/projects/jobs/show.html.haml_spec.rb79
36 files changed, 1171 insertions, 313 deletions
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index d80b7f5bd42..c28f6e151a0 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -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 () {
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index ca90729c791..5f87a05067b 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -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')) {
diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue
new file mode 100644
index 00000000000..5b9cf577189
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/header.vue
@@ -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>
diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
new file mode 100644
index 00000000000..ab2bcd728a8
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
@@ -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>
diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue
new file mode 100644
index 00000000000..4223a8fea49
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue
@@ -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>
diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js
new file mode 100644
index 00000000000..939d17129de
--- /dev/null
+++ b/app/assets/javascripts/jobs/job_details_bundle.js
@@ -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,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/jobs/job_details_mediator.js b/app/assets/javascripts/jobs/job_details_mediator.js
new file mode 100644
index 00000000000..063c52fac74
--- /dev/null
+++ b/app/assets/javascripts/jobs/job_details_mediator.js
@@ -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.');
+ }
+}
diff --git a/app/assets/javascripts/jobs/services/job_service.js b/app/assets/javascripts/jobs/services/job_service.js
new file mode 100644
index 00000000000..eaf1c6e500a
--- /dev/null
+++ b/app/assets/javascripts/jobs/services/job_service.js
@@ -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();
+ }
+}
diff --git a/app/assets/javascripts/jobs/stores/job_store.js b/app/assets/javascripts/jobs/stores/job_store.js
new file mode 100644
index 00000000000..766194b8387
--- /dev/null
+++ b/app/assets/javascripts/jobs/stores/job_store.js
@@ -0,0 +1,11 @@
+export default class JobStore {
+ constructor() {
+ this.state = {
+ job: {},
+ };
+ }
+
+ storeJob(job = {}) {
+ this.state.job = job;
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 40eadd9396c..54c0da3fc9c 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -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;
+}
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 4f6c5c177cf..2a1ecac3707 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -91,7 +91,7 @@ export default {
@actionClicked="postAction"
/>
<loading-icon
- v-else
+ v-if="isLoading"
size="2"/>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index fe6d6a792e7..1d4d90f75b6 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -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>
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index d931a78e112..203fd6d07e4 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -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;
+.build-header {
+ .ci-header-container,
+ .header-action-buttons {
+ display: flex;
}
- .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;
- }
+ .ci-header-container {
+ min-height: 54px;
}
- &:hover .status-text {
- opacity: 1;
+ .page-content-header {
+ padding: 10px 0 9px;
}
-}
-
-.build-header {
- position: relative;
- padding: 0;
- display: flex;
- min-height: 58px;
- align-items: center;
-
- @media (max-width: $screen-sm-max) {
- padding-right: 40px;
- margin-top: 6px;
- .btn-inverted {
- display: none;
+ .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 {
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 71b02002235..cd9382e8de5 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -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;
+ }
+ }
}
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 55c16f7e1fd..36c87eb0d0c 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -132,6 +132,11 @@ class CommitStatus < ActiveRecord::Base
false
end
+ # To be overriden when inherrited from
+ def cancelable?
+ false
+ end
+
def stuck?
false
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 0063920e603..514c4c2e35f 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -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
diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb
index c01efa9dd5c..67001f4547d 100644
--- a/app/serializers/build_entity.rb
+++ b/app/serializers/build_entity.rb
@@ -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
diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml
index 09d4ddc243b..8b9e6e57ec4 100644
--- a/app/views/projects/jobs/_sidebar.html.haml
+++ b/app/views/projects/jobs/_sidebar.html.haml
@@ -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')
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 987068dc18e..c73bae0a2c9 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -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')
diff --git a/changelogs/unreleased/31397-job-detail-real-time.yml b/changelogs/unreleased/31397-job-detail-real-time.yml
new file mode 100644
index 00000000000..90487a1e75a
--- /dev/null
+++ b/changelogs/unreleased/31397-job-detail-real-time.yml
@@ -0,0 +1,4 @@
+---
+title: Adds realtime feature to job show view header and sidebar info. Updates UX.
+merge_request:
+author:
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 7501acb7633..04f5a2beb2a 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -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',
diff --git a/features/project/builds/permissions.feature b/features/project/builds/permissions.feature
index 3c7f72335d9..db15968db06 100644
--- a/features/project/builds/permissions.feature
+++ b/features/project/builds/permissions.feature
@@ -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
diff --git a/features/project/builds/summary.feature b/features/project/builds/summary.feature
index 550ebccf0d7..3bf15b0cf87 100644
--- a/features/project/builds/summary.feature
+++ b/features/project/builds/summary.feature
@@ -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
diff --git a/features/steps/project/builds/summary.rb b/features/steps/project/builds/summary.rb
index 229e5d7cdf4..20a5c873ecd 100644
--- a/features/steps/project/builds/summary.rb
+++ b/features/steps/project/builds/summary.rb
@@ -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
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index 0eda46649db..727ae7081b0 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -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
diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js
index 4c8a48580d7..be90dbdd88a 100644
--- a/spec/javascripts/build_spec.js
+++ b/spec/javascripts/build_spec.js
@@ -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', () => {
diff --git a/spec/javascripts/datetime_utility_spec.js b/spec/javascripts/datetime_utility_spec.js
index c82ad0bea48..e54ea11b08c 100644
--- a/spec/javascripts/datetime_utility_spec.js
+++ b/spec/javascripts/datetime_utility_spec.js
@@ -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');
+ });
+ });
})();
diff --git a/spec/javascripts/jobs/header_spec.js b/spec/javascripts/jobs/header_spec.js
new file mode 100644
index 00000000000..c7179b3e03d
--- /dev/null
+++ b/spec/javascripts/jobs/header_spec.js
@@ -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);
+ });
+});
diff --git a/spec/javascripts/jobs/job_details_mediator_spec.js b/spec/javascripts/jobs/job_details_mediator_spec.js
new file mode 100644
index 00000000000..1d7fa7e12fc
--- /dev/null
+++ b/spec/javascripts/jobs/job_details_mediator_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/javascripts/jobs/job_store_spec.js b/spec/javascripts/jobs/job_store_spec.js
new file mode 100644
index 00000000000..d00faf29d1e
--- /dev/null
+++ b/spec/javascripts/jobs/job_store_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/javascripts/jobs/mock_data.js b/spec/javascripts/jobs/mock_data.js
new file mode 100644
index 00000000000..17e4ef26b2c
--- /dev/null
+++ b/spec/javascripts/jobs/mock_data.js
@@ -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',
+};
diff --git a/spec/javascripts/jobs/sidebar_detail_row_spec.js b/spec/javascripts/jobs/sidebar_detail_row_spec.js
new file mode 100644
index 00000000000..3ac65709c4a
--- /dev/null
+++ b/spec/javascripts/jobs/sidebar_detail_row_spec.js
@@ -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');
+ });
+});
diff --git a/spec/javascripts/jobs/sidebar_details_block_spec.js b/spec/javascripts/jobs/sidebar_details_block_spec.js
new file mode 100644
index 00000000000..95532ef5382
--- /dev/null
+++ b/spec/javascripts/jobs/sidebar_details_block_spec.js
@@ -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');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js
index 2b51c89f311..e28639f12f3 100644
--- a/spec/javascripts/vue_shared/components/header_ci_component_spec.js
+++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js
@@ -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();
+ });
});
diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb
index 46d43a80ef7..e51ff9fc709 100644
--- a/spec/serializers/build_entity_spec.rb
+++ b/spec/serializers/build_entity_spec.rb
@@ -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
diff --git a/spec/views/projects/jobs/show.html.haml_spec.rb b/spec/views/projects/jobs/show.html.haml_spec.rb
index 8f2822f5dc5..d9a7ba265f8 100644
--- a/spec/views/projects/jobs/show.html.haml_spec.rb
+++ b/spec/views/projects/jobs/show.html.haml_spec.rb
@@ -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