diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2017-06-12 12:20:19 +0300 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2017-06-12 12:20:19 +0300 |
commit | 452202e36d3e20755b099a718a92d3f7b80fabb8 (patch) | |
tree | 5cc8bd9c5d810a645f44f515c4310731fa01785f /app/assets | |
parent | d25f6fcf629bd773ccac49a799393479c48f4673 (diff) |
Improve Job detail view to make it refreshed in real-time instead of reloading
Diffstat (limited to 'app/assets')
-rw-r--r-- | app/assets/javascripts/build.js | 31 | ||||
-rw-r--r-- | app/assets/javascripts/dispatcher.js | 4 | ||||
-rw-r--r-- | app/assets/javascripts/jobs/components/header.vue | 83 | ||||
-rw-r--r-- | app/assets/javascripts/jobs/components/sidebar_detail_row.vue | 31 | ||||
-rw-r--r-- | app/assets/javascripts/jobs/components/sidebar_details_block.vue | 150 | ||||
-rw-r--r-- | app/assets/javascripts/jobs/job_details_bundle.js | 68 | ||||
-rw-r--r-- | app/assets/javascripts/jobs/job_details_mediator.js | 67 | ||||
-rw-r--r-- | app/assets/javascripts/jobs/services/job_service.js | 14 | ||||
-rw-r--r-- | app/assets/javascripts/jobs/stores/job_store.js | 11 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/datetime_utility.js | 21 | ||||
-rw-r--r-- | app/assets/javascripts/pipelines/components/header_component.vue | 2 | ||||
-rw-r--r-- | app/assets/javascripts/vue_shared/components/header_ci_component.vue | 32 | ||||
-rw-r--r-- | app/assets/stylesheets/pages/builds.scss | 103 | ||||
-rw-r--r-- | app/assets/stylesheets/pages/pipelines.scss | 9 |
14 files changed, 558 insertions, 68 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; + } + } } |