Commit 8cd578a7 authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch '25226-realtime-pipelines-fe' into 'master'

Rewrite Pipeline Graph in Vue.js to allow realtime

Closes #25226 and #31557

See merge request !10878
parents 6ad3814e e0e52fe5
import CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
import CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
import FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
import MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
import PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
import RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
import SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
import SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
import WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
const StatusIconEntityMap = {
icon_status_canceled: CANCELED_SVG,
icon_status_created: CREATED_SVG,
icon_status_failed: FAILED_SVG,
icon_status_manual: MANUAL_SVG,
icon_status_pending: PENDING_SVG,
icon_status_running: RUNNING_SVG,
icon_status_skipped: SKIPPED_SVG,
icon_status_success: SUCCESS_SVG,
icon_status_warning: WARNING_SVG,
};
export {
CANCELED_SVG,
CREATED_SVG,
FAILED_SVG,
MANUAL_SVG,
PENDING_SVG,
RUNNING_SVG,
SKIPPED_SVG,
SUCCESS_SVG,
WARNING_SVG,
StatusIconEntityMap as default,
};
...@@ -49,6 +49,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; ...@@ -49,6 +49,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout'; import UserCallout from './user_callout';
import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
import ShortcutsWiki from './shortcuts_wiki'; import ShortcutsWiki from './shortcuts_wiki';
import Pipelines from './pipelines';
import BlobViewer from './blob/viewer/index'; import BlobViewer from './blob/viewer/index';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
...@@ -257,7 +258,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); ...@@ -257,7 +258,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`; const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
new gl.Pipelines({ new Pipelines({
initTabs: true, initTabs: true,
pipelineStatusUrl, pipelineStatusUrl,
tabsOptions: { tabsOptions: {
......
...@@ -31,82 +31,78 @@ ...@@ -31,82 +31,78 @@
* *
* ### How to use * ### How to use
* *
* new window.gl.LinkedTabs({ * new LinkedTabs({
* action: "#{controller.action_name}", * action: "#{controller.action_name}",
* defaultAction: 'tab1', * defaultAction: 'tab1',
* parentEl: '.tab-links' * parentEl: '.tab-links'
* }); * });
*/ */
(() => { export default class LinkedTabs {
window.gl = window.gl || {}; /**
* Binds the events and activates de default tab.
*
* @param {Object} options
*/
constructor(options = {}) {
this.options = options;
window.gl.LinkedTabs = class LinkedTabs { this.defaultAction = this.options.defaultAction;
/** this.action = this.options.action || this.defaultAction;
* Binds the events and activates de default tab.
*
* @param {Object} options
*/
constructor(options) {
this.options = options || {};
this.defaultAction = this.options.defaultAction; if (this.action === 'show') {
this.action = this.options.action || this.defaultAction; this.action = this.defaultAction;
}
if (this.action === 'show') {
this.action = this.defaultAction;
}
this.currentLocation = window.location; this.currentLocation = window.location;
const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`; const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
// since this is a custom event we need jQuery :( // since this is a custom event we need jQuery :(
$(document) $(document)
.off('shown.bs.tab', tabSelector) .off('shown.bs.tab', tabSelector)
.on('shown.bs.tab', tabSelector, e => this.tabShown(e)); .on('shown.bs.tab', tabSelector, e => this.tabShown(e));
this.activateTab(this.action); this.activateTab(this.action);
} }
/** /**
* Handles the `shown.bs.tab` event to set the currect url action. * Handles the `shown.bs.tab` event to set the currect url action.
* *
* @param {type} evt * @param {type} evt
* @return {Function} * @return {Function}
*/ */
tabShown(evt) { tabShown(evt) {
const source = evt.target.getAttribute('href'); const source = evt.target.getAttribute('href');
return this.setCurrentAction(source); return this.setCurrentAction(source);
} }
/** /**
* Updates the URL with the path that matched the given action. * Updates the URL with the path that matched the given action.
* *
* @param {String} source * @param {String} source
* @return {String} * @return {String}
*/ */
setCurrentAction(source) { setCurrentAction(source) {
const copySource = source; const copySource = source;
copySource.replace(/\/+$/, ''); copySource.replace(/\/+$/, '');
const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
history.replaceState({ history.replaceState({
url: newState, url: newState,
}, document.title, newState); }, document.title, newState);
return newState; return newState;
} }
/** /**
* Given the current action activates the correct tab. * Given the current action activates the correct tab.
* http://getbootstrap.com/javascript/#tab-show * http://getbootstrap.com/javascript/#tab-show
* Note: Will trigger `shown.bs.tab` * Note: Will trigger `shown.bs.tab`
*/ */
activateTab() { activateTab() {
return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show'); return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show');
} }
}; }
})();
/* eslint-disable no-new, guard-for-in, no-restricted-syntax, no-continue, no-param-reassign, max-len */ import LinkedTabs from './lib/utils/bootstrap_linked_tabs';
require('./lib/utils/bootstrap_linked_tabs'); export default class Pipelines {
constructor(options = {}) {
((global) => { if (options.initTabs && options.tabsOptions) {
class Pipelines { // eslint-disable-next-line no-new
constructor(options = {}) { new LinkedTabs(options.tabsOptions);
if (options.initTabs && options.tabsOptions) {
new global.LinkedTabs(options.tabsOptions);
}
if (options.pipelineStatusUrl) {
gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
}
this.addMarginToBuildColumns();
} }
addMarginToBuildColumns() { if (options.pipelineStatusUrl) {
this.pipelineGraph = document.querySelector('.js-pipeline-graph'); gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)');
for (const buildNodeIndex in secondChildBuildNodes) {
const buildNode = secondChildBuildNodes[buildNodeIndex];
const firstChildBuildNode = buildNode.previousElementSibling;
if (!firstChildBuildNode || !firstChildBuildNode.matches('.build')) continue;
const multiBuildColumn = buildNode.closest('.stage-column');
const previousColumn = multiBuildColumn.previousElementSibling;
if (!previousColumn || !previousColumn.matches('.stage-column')) continue;
multiBuildColumn.classList.add('left-margin');
firstChildBuildNode.classList.add('left-connector');
const columnBuilds = previousColumn.querySelectorAll('.build');
if (columnBuilds.length === 1) previousColumn.classList.add('no-margin');
}
this.pipelineGraph.classList.remove('hidden');
} }
} }
}
global.Pipelines = Pipelines;
})(window.gl || (window.gl = {}));
<script>
import getActionIcon from '../../../vue_shared/ci_action_icons';
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
/**
* Renders either a cancel, retry or play icon pointing to the given path.
* TODO: Remove UJS from here and use an async request instead.
*/
export default {
props: {
tooltipText: {
type: String,
required: true,
},
link: {
type: String,
required: true,
},
actionMethod: {
type: String,
required: true,
},
actionIcon: {
type: String,
required: true,
},
},
mixins: [
tooltipMixin,
],
computed: {
actionIconSvg() {
return getActionIcon(this.actionIcon);
},
cssClass() {
return `js-${gl.text.dasherize(this.actionIcon)}`;
},
},
};
</script>
<template>
<a
:data-method="actionMethod"
:title="tooltipText"
:href="link"
ref="tooltip"
class="ci-action-icon-container"
data-toggle="tooltip"
data-container="body">
<i
class="ci-action-icon-wrapper"
:class="cssClass"
v-html="actionIconSvg"
aria-hidden="true"
/>
</a>
</template>
<script>
import getActionIcon from '../../../vue_shared/ci_action_icons';
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
/**
* Renders either a cancel, retry or play icon pointing to the given path.
* TODO: Remove UJS from here and use an async request instead.
*/
export default {
props: {
tooltipText: {
type: String,
required: true,
},
link: {
type: String,
required: true,
},
actionMethod: {
type: String,
required: true,
},
actionIcon: {
type: String,
required: true,
},
},
mixins: [
tooltipMixin,
],
computed: {
actionIconSvg() {
return getActionIcon(this.actionIcon);
},
},
};
</script>
<template>
<a
:data-method="actionMethod"
:title="tooltipText"
:href="link"
ref="tooltip"
rel="nofollow"
class="ci-action-icon-wrapper js-ci-status-icon"
data-toggle="tooltip"
data-container="body"
v-html="actionIconSvg"
aria-label="Job's action">
</a>
</template>
<script>
import jobNameComponent from './job_name_component.vue';
import jobComponent from './job_component.vue';
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
/**
* Renders the dropdown for the pipeline graph.
*
* The following object should be provided as `job`:
*
* {
* "id": 4256,
* "name": "test",
* "status": {
* "icon": "icon_status_success",
* "text": "passed",
* "label": "passed",
* "group": "success",
* "details_path": "/root/ci-mock/builds/4256",
* "action": {
* "icon": "icon_action_retry",
* "title": "Retry",
* "path": "/root/ci-mock/builds/4256/retry",
* "method": "post"
* }
* }
* }
*/
export default {
props: {
job: {
type: Object,
required: true,
},
},
mixins: [
tooltipMixin,
],
components: {
jobComponent,
jobNameComponent,
},
computed: {
tooltipText() {
return `${this.job.name} - ${this.job.status.label}`;
},
},
};
</script>
<template>
<div>
<button
type="button"
data-toggle="dropdown"
data-container="body"
class="dropdown-menu-toggle build-content"
:title="tooltipText"
ref="tooltip">
<job-name-component
:name="job.name"
:status="job.status" />
<span class="dropdown-counter-badge">
{{job.size}}
</span>
</button>
<ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
<li class="scrollable-menu">
<ul>
<li v-for="item in job.jobs">
<job-component
:job="item"
:is-dropdown="true"
css-class-job-name="mini-pipeline-graph-dropdown-item"
/>
</li>
</ul>
</li>
</ul>
</div>
</template>
<script>
/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../../../lib/utils/poll';
import PipelineService from '../../services/pipeline_service';
import PipelineStore from '../../stores/pipeline_store';
import stageColumnComponent from './stage_column_component.vue';
import '../../../flash';
export default {
components: {
stageColumnComponent,
},
data() {
const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset;
const store = new PipelineStore();
return {
isLoading: false,
endpoint: DOMdata.endpoint,
store,
state: store.state,
};
},
created() {
this.service = new PipelineService(this.endpoint);
const poll = new Poll({
resource: this.service,
method: 'getPipeline',
successCallback: this.successCallback,
errorCallback: this.errorCallback,
});
if (!Visibility.hidden()) {
this.isLoading = true;
poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
poll.restart();
} else {
poll.stop();
}
});
},
methods: {
successCallback(response) {
const data = response.json();
this.isLoading = false;
this.store.storeGraph(data.details.stages);
},
errorCallback() {
this.isLoading = false;
return new Flash('An error occurred while fetching the pipeline.');
},
capitalizeStageName(name) {
return name.charAt(0).toUpperCase() + name.slice(1);
},
},
};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
<div class="pipeline-visualization pipeline-graph">
<div class="text-center">
<i
v-if="isLoading"
class="loading-icon fa fa-spin fa-spinner fa-3x"
aria-label="Loading"
aria-hidden="true" />
</div>
<ul
v-if="!isLoading"
class="stage-column-list">
<stage-column-component
v-for="stage in state.graph"
:title="capitalizeStageName(stage.name)"
:jobs="stage.groups"
:key="stage.name"/>
</ul>
</div>
</div>
</template>
<script>
import actionComponent from './action_component.vue';
import dropdownActionComponent from './dropdown_action_component.vue';
import jobNameComponent from './job_name_component.vue';
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
*
* The following object should be provided as `job`:
*
* {
* "id": 4256,
* "name": "test",
* "status": {
* "icon": "icon_status_success",
* "text": "passed",
* "label": "passed",
* "group": "success",
* "details_path": "/root/ci-mock/builds/4256",
* "action": {
* "icon": "icon_action_retry",
* "title": "Retry",
* "path": "/root/ci-mock/builds/4256/retry",
* "method": "post"
* }
* }
* }
*/
export default {
props: {
job: {
type: Object,
required: true,
},
cssClassJobName: {
type: String,
required: false,
default: '',
},
isDropdown: {
type: Boolean,
required: false,
default: false,
},
},
components: {
actionComponent,
dropdownActionComponent,
jobNameComponent,
},
mixins: [
tooltipMixin,
],
computed: {
tooltipText() {
return `${this.job.name} - ${this.job.status.label}`;
},
/**
* Verifies if the provided job has an action path
*
* @return {Boolean}
*/
hasAction() {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
},
};
</script>
<template>
<div>
<a
v-if="job.status.details_path"
:href="job.status.details_path"
:title="tooltipText"
:class="cssClassJobName"
ref="tooltip"
data-toggle="tooltip"
data-container="body">
<job-name-component
:name="job.name"
:status="job.status"
/>
</a>
<div
v-else
:title="tooltipText"
:class="cssClassJobName"
ref="tooltip"
data-toggle="tooltip"
data-container="body">
<job-name-component
:name="job.name"
:status="job.status"
/>
</div>
<action-component
v-if="hasAction && !isDropdown"
:tooltip-text="job.status.action.title"
:link="job.status.action.path"
:action-icon="job.status.action.icon"
:action-method="job.status.action.method"
/>
<dropdown-action-component
v-if="hasAction && isDropdown"
:tooltip-text="job.status.action.title"
:link="job.status.action.path"
:action-icon="job.status.action.icon"
:action-method="job.status.action.method"
/>
</div>
</template>
<script>
import ciIcon from '../../../vue_shared/components/ci_icon.vue';
/**
* Component that renders both the CI icon status and the job name.
* Used in
* - Badge component
* - Dropdown badge components
*/
export default {
props: {
name: {
type: String,
required: true,
},
status: {
type: Object,
required: true,
},
},
components: {
ciIcon,
},
};
</script>
<template>
<span>
<ci-icon
:status="status" />
<span class="ci-status-text">
{{name}}
</span>
</span>
</template>
<script>
import jobComponent from './job_component.vue';
import dropdownJobComponent from './dropdown_job_component.vue';
export default {
props: {
title: {
type: String,
required: true,
},
jobs: {
type: Array,
required: true,
},
},
components: {
jobComponent,
dropdownJobComponent,
},
methods: {
firstJob(list) {
return list[0];
},
jobId(job) {
return `ci-badge-${job.name}`;
},
},
};
</script>
<template>
<li class="stage-column">
<div class="stage-name">
{{title}}
</div>
<div class="builds-container">
<ul>
<li
v-for="job in jobs"
:key="job.id"
class="build"
:id="jobId(job)">
<div class="curve"></div>
<job-component
v-if="job.size === 1"
:job="job"
css-class-job-name="build-content"
/>
<dropdown-job-component
v-if="job.size > 1"
:job="job"
/>
</li>
</ul>
</div>
</li>
</template>
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
*/ */
/* global Flash */ /* global Flash */
import StatusIconEntityMap from '../../ci_status_icons'; import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
export default { export default {
props: { props: {
...@@ -113,7 +113,7 @@ export default { ...@@ -113,7 +113,7 @@ export default {
}, },
svgIcon() { svgIcon() {
return StatusIconEntityMap[this.stage.status.icon]; return borderlessStatusIconEntityMap[this.stage.status.icon];
}, },
}, },
}; };
......
import Vue from 'vue';
import pipelineGraph from './components/graph/graph_component.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#js-pipeline-graph-vue',
components: {
pipelineGraph,
},
render: createElement => createElement('pipeline-graph'),
}));
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class PipelineService {
constructor(endpoint) {
this.pipeline = Vue.resource(endpoint);
}
getPipeline() {
return this.pipeline.get();
}
}
export default class PipelineStore {
constructor() {
this.state = {};
this.state.graph = [];
}
storeGraph(graph = []) {
this.state.graph = graph;
}
}
import cancelSVG from 'icons/_icon_action_cancel.svg';
import retrySVG from 'icons/_icon_action_retry.svg';
import playSVG from 'icons/_icon_action_play.svg';
export default function getActionIcon(action) {
let icon;
switch (action) {
case 'icon_action_cancel':
icon = cancelSVG;
break;
case 'icon_action_retry':
icon = retrySVG;
break;
case 'icon_action_play':
icon = playSVG;
break;
default:
icon = '';
}
return icon;
}
import BORDERLESS_CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
import BORDERLESS_CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
import BORDERLESS_FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
import BORDERLESS_MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
import BORDERLESS_PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
import BORDERLESS_RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
import BORDERLESS_SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
import BORDERLESS_SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
import BORDERLESS_WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
import CANCELED_SVG from 'icons/_icon_status_canceled.svg';
import CREATED_SVG from 'icons/_icon_status_created.svg';
import FAILED_SVG from 'icons/_icon_status_failed.svg';
import MANUAL_SVG from 'icons/_icon_status_manual.svg';
import PENDING_SVG from 'icons/_icon_status_pending.svg';
import RUNNING_SVG from 'icons/_icon_status_running.svg';
import SKIPPED_SVG from 'icons/_icon_status_skipped.svg';
import SUCCESS_SVG from 'icons/_icon_status_success.svg';
import WARNING_SVG from 'icons/_icon_status_warning.svg';
export const borderlessStatusIconEntityMap = {
icon_status_canceled: BORDERLESS_CANCELED_SVG,
icon_status_created: BORDERLESS_CREATED_SVG,
icon_status_failed: BORDERLESS_FAILED_SVG,
icon_status_manual: BORDERLESS_MANUAL_SVG,
icon_status_pending: BORDERLESS_PENDING_SVG,
icon_status_running: BORDERLESS_RUNNING_SVG,
icon_status_skipped: BORDERLESS_SKIPPED_SVG,
icon_status_success: BORDERLESS_SUCCESS_SVG,
icon_status_warning: BORDERLESS_WARNING_SVG,
};
export const statusIconEntityMap = {
icon_status_canceled: CANCELED_SVG,
icon_status_created: CREATED_SVG,
icon_status_failed: FAILED_SVG,
icon_status_manual: MANUAL_SVG,
icon_status_pending: PENDING_SVG,
icon_status_running: RUNNING_SVG,
icon_status_skipped: SKIPPED_SVG,
icon_status_success: SUCCESS_SVG,
icon_status_warning: WARNING_SVG,
};
export const statusCssClasses = {
icon_status_canceled: 'canceled',
icon_status_created: 'created',
icon_status_failed: 'failed',
icon_status_manual: 'manual',
icon_status_pending: 'pending',
icon_status_running: 'running',
icon_status_skipped: 'skipped',
icon_status_success: 'success',
icon_status_warning: 'warning',
};
<script>
import { statusIconEntityMap, statusCssClasses } from '../../vue_shared/ci_status_icons';
export default {
props: {
status: {
type: Object,
required: true,
},
},
computed: {
statusIconSvg() {
return statusIconEntityMap[this.status.icon];
},
cssClass() {
const status = statusCssClasses[this.status.icon];
return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
},
},
};
</script>
<template>
<span
:class="cssClass"
v-html="statusIconSvg">
</span>
</template>
export default {
mounted() {
$(this.$refs.tooltip).tooltip();
},
updated() {
$(this.$refs.tooltip).tooltip('fixTitle');
},
};
...@@ -486,7 +486,7 @@ ...@@ -486,7 +486,7 @@
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
// Action Icons in big pipeline-graph nodes // Action Icons in big pipeline-graph nodes
> .ci-action-icon-container .ci-action-icon-wrapper { > div > .ci-action-icon-container .ci-action-icon-wrapper {
height: 30px; height: 30px;
width: 30px; width: 30px;
background: $white-light; background: $white-light;
...@@ -511,7 +511,7 @@ ...@@ -511,7 +511,7 @@
} }
} }
> .ci-action-icon-container { > div > .ci-action-icon-container {
position: absolute; position: absolute;
right: 5px; right: 5px;
top: 5px; top: 5px;
...@@ -541,7 +541,7 @@ ...@@ -541,7 +541,7 @@
} }
} }
> .build-content { > div > .build-content {
display: inline-block; display: inline-block;
padding: 8px 10px 9px; padding: 8px 10px 9px;
width: 100%; width: 100%;
...@@ -557,34 +557,6 @@ ...@@ -557,34 +557,6 @@
} }
.arrow {
&::before,
&::after {
content: '';
display: inline-block;
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
top: 18px;
}
&::before {
left: -5px;
margin-top: -6px;
border-width: 7px 5px 7px 0;
border-right-color: $border-color;
}
&::after {
left: -4px;
margin-top: -9px;
border-width: 10px 7px 10px 0;
border-right-color: $white-light;
}
}
// Connect first build in each stage with right horizontal line // Connect first build in each stage with right horizontal line
&:first-child { &:first-child {
&::after { &::after {
...@@ -859,7 +831,8 @@ ...@@ -859,7 +831,8 @@
border-radius: 3px; border-radius: 3px;
// build name // build name
.ci-build-text { .ci-build-text,
.ci-status-text {
font-weight: 200; font-weight: 200;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
...@@ -911,6 +884,38 @@ ...@@ -911,6 +884,38 @@
} }
} }
/**
* Top arrow in the dropdown in the big pipeline graph
*/
.big-pipeline-graph-dropdown-menu {
&::before,
&::after {
content: '';
display: inline-block;
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
top: 18px;
}
&::before {
left: -5px;
margin-top: -6px;
border-width: 7px 5px 7px 0;
border-right-color: $border-color;
}
&::after {
left: -4px;
margin-top: -9px;
border-width: 10px 7px 10px 0;
border-right-color: $white-light;
}
}
/** /**
* Top arrow in the dropdown in the mini pipeline graph * Top arrow in the dropdown in the mini pipeline graph
*/ */
......
-# Renders the graph node with both the status icon, status name and action icon
- subject = local_assigns.fetch(:subject)
- status = subject.detailed_status(current_user)
- klass = "ci-status-icon ci-status-icon-#{status.group} js-ci-status-icon-#{status.group}"
- tooltip = "#{subject.name} - #{status.label}"
- if status.has_details?
= link_to status.details_path, class: 'build-content has-tooltip', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
%span{ class: klass }= custom_icon(status.icon)
.ci-status-text= subject.name
- else
.build-content.has-tooltip{ data: { toggle: 'tooltip', title: tooltip } }
%span{ class: klass }= custom_icon(status.icon)
.ci-status-text= subject.name
- if status.has_action?
= link_to status.action_path, class: 'ci-action-icon-container has-tooltip', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
%i.ci-action-icon-wrapper{ class: "js-#{status.action_icon.dasherize}" }
= custom_icon(status.action_icon)
- pipeline = local_assigns.fetch(:pipeline)
.pipeline-visualization.pipeline-graph
%ul.stage-column-list
= render partial: "projects/stage/graph", collection: pipeline.stages, as: :stage
- failed_builds = @pipeline.statuses.latest.failed - failed_builds = @pipeline.statuses.latest.failed
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('pipelines_graph')
.tabs-holder .tabs-holder
%ul.pipelines-tabs.nav-links.no-top.no-bottom %ul.pipelines-tabs.nav-links.no-top.no-bottom
%li.js-pipeline-tab-link %li.js-pipeline-tab-link
...@@ -17,8 +21,7 @@ ...@@ -17,8 +21,7 @@
.tab-content .tab-content
#js-tab-pipeline.tab-pane #js-tab-pipeline.tab-pane
.build-content.middle-block.js-pipeline-graph #js-pipeline-graph-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } }
= render "projects/pipelines/graph", pipeline: pipeline
#js-tab-builds.tab-pane #js-tab-builds.tab-pane
- if pipeline.yaml_errors.present? - if pipeline.yaml_errors.present?
......
- stage = local_assigns.fetch(:stage)
- statuses = stage.statuses.latest
- status_groups = statuses.sort_by(&:sortable_name).group_by(&:group_name)
%li.stage-column
.stage-name
%a{ name: stage.name }
= stage.name.titleize
.builds-container
%ul
- status_groups.each do |group_name, grouped_statuses|
- if grouped_statuses.one?
- status = grouped_statuses.first
%li.build{ 'id' => "ci-badge-#{group_name}" }
.curve
= render 'ci/status/graph_badge', subject: status
- else
%li.build{ 'id' => "ci-badge-#{group_name}" }
.curve
= render 'projects/stage/in_stage_group', name: group_name, subject: grouped_statuses
- group_status = CommitStatus.where(id: subject).status
%button.dropdown-menu-toggle.build-content.has-tooltip{ type: 'button', data: { toggle: 'dropdown', title: "#{name} - #{group_status}", container: 'body' } }
%span{ class: "ci-status-icon ci-status-icon-#{group_status}" }
= ci_icon_for_status(group_status)
%span.ci-status-text
= name
%span.dropdown-counter-badge= subject.size
%ul.dropdown-menu.big-pipeline-graph-dropdown-menu.js-grouped-pipeline-dropdown
.arrow
.scrollable-menu
- subject.each do |status|
%li
= render 'ci/status/dropdown_graph_badge', subject: status
---
title: Re-rewrites pipeline graph in vue to support realtime data updates
merge_request:
author:
...@@ -49,6 +49,7 @@ var config = { ...@@ -49,6 +49,7 @@ var config = {
pdf_viewer: './blob/pdf_viewer.js', pdf_viewer: './blob/pdf_viewer.js',
pipelines: './pipelines/index.js', pipelines: './pipelines/index.js',
balsamiq_viewer: './blob/balsamiq_viewer.js', balsamiq_viewer: './blob/balsamiq_viewer.js',
pipelines_graph: './pipelines/graph_bundle.js',
profile: './profile/profile_bundle.js', profile: './profile/profile_bundle.js',
protected_branches: './protected_branches/protected_branches_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js',
protected_tags: './protected_tags', protected_tags: './protected_tags',
...@@ -145,6 +146,7 @@ var config = { ...@@ -145,6 +146,7 @@ var config = {
'pdf_viewer', 'pdf_viewer',
'pipelines', 'pipelines',
'balsamiq_viewer', 'balsamiq_viewer',
'pipelines_graph',
], ],
minChunks: function(module, count) { minChunks: function(module, count) {
return module.resource && (/vue_shared/).test(module.resource); return module.resource && (/vue_shared/).test(module.resource);
......
...@@ -370,6 +370,58 @@ describe 'Pipelines', :feature, :js do ...@@ -370,6 +370,58 @@ describe 'Pipelines', :feature, :js do
end end
end end
describe 'GET /:project/pipelines/show' do
let(:project) { create(:project) }
let(:pipeline) do
create(:ci_empty_pipeline,
project: project,
sha: project.commit.id,
user: user)
end
before do
create_build('build', 0, 'build', :success)
create_build('test', 1, 'rspec 0:2', :pending)
create_build('test', 1, 'rspec 1:2', :running)
create_build('test', 1, 'spinach 0:2', :created)
create_build('test', 1, 'spinach 1:2', :created)
create_build('test', 1, 'audit', :created)
create_build('deploy', 2, 'production', :created)
create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3)
visit namespace_project_pipeline_path(project.namespace, project, pipeline)
wait_for_vue_resource
end
it 'shows a graph with grouped stages' do
expect(page).to have_css('.js-pipeline-graph')
# header
expect(page).to have_text("##{pipeline.id}")
expect(page).to have_selector(%Q(img[alt$="#{pipeline.user.name}'s avatar"]))
expect(page).to have_link(pipeline.user.name, href: user_path(pipeline.user))
# stages
expect(page).to have_text('Build')
expect(page).to have_text('Test')
expect(page).to have_text('Deploy')
expect(page).to have_text('External')
# builds
expect(page).to have_text('rspec')
expect(page).to have_text('spinach')
expect(page).to have_text('rspec')
expect(page).to have_text('production')
expect(page).to have_text('jenkins')
end
def create_build(stage, stage_idx, name, status)
create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name, status: status)
end
end
describe 'POST /:project/pipelines' do describe 'POST /:project/pipelines' do
let(:project) { create(:project) } let(:project) { create(:project) }
......
require('~/lib/utils/bootstrap_linked_tabs'); import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
(() => { (() => {
// TODO: remove this hack! // TODO: remove this hack!
...@@ -25,7 +25,7 @@ require('~/lib/utils/bootstrap_linked_tabs'); ...@@ -25,7 +25,7 @@ require('~/lib/utils/bootstrap_linked_tabs');
}); });
it('should activate the tab correspondent to the given action', () => { it('should activate the tab correspondent to the given action', () => {
const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line const linkedTabs = new LinkedTabs({ // eslint-disable-line
action: 'tab1', action: 'tab1',
defaultAction: 'tab1', defaultAction: 'tab1',
parentEl: '.linked-tabs', parentEl: '.linked-tabs',
...@@ -35,7 +35,7 @@ require('~/lib/utils/bootstrap_linked_tabs'); ...@@ -35,7 +35,7 @@ require('~/lib/utils/bootstrap_linked_tabs');
}); });
it('should active the default tab action when the action is show', () => { it('should active the default tab action when the action is show', () => {
const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line const linkedTabs = new LinkedTabs({ // eslint-disable-line
action: 'show', action: 'show',
defaultAction: 'tab1', defaultAction: 'tab1',
parentEl: '.linked-tabs', parentEl: '.linked-tabs',
...@@ -49,7 +49,7 @@ require('~/lib/utils/bootstrap_linked_tabs'); ...@@ -49,7 +49,7 @@ require('~/lib/utils/bootstrap_linked_tabs');
it('should change the url according to the clicked tab', () => { it('should change the url according to the clicked tab', () => {
const historySpy = !phantomjs && spyOn(history, 'replaceState').and.callFake(() => {}); const historySpy = !phantomjs && spyOn(history, 'replaceState').and.callFake(() => {});
const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line const linkedTabs = new LinkedTabs({
action: 'show', action: 'show',
defaultAction: 'tab1', defaultAction: 'tab1',
parentEl: '.linked-tabs', parentEl: '.linked-tabs',
......
import * as icons from '~/ci_status_icons';
describe('CI status icons', () => {
const statuses = [
'canceled',
'created',
'failed',
'manual',
'pending',
'running',
'skipped',
'success',
'warning',
];
statuses.forEach((status) => {
it(`should export a ${status} svg`, () => {
const key = `${status.toUpperCase()}_SVG`;
expect(Object.hasOwnProperty.call(icons, key)).toBe(true);
expect(icons[key]).toMatch(/^<svg/);
});
});
describe('default export map', () => {
const entityIconNames = [
'icon_status_canceled',
'icon_status_created',
'icon_status_failed',
'icon_status_manual',
'icon_status_pending',
'icon_status_running',
'icon_status_skipped',
'icon_status_success',
'icon_status_warning',
];
entityIconNames.forEach((iconName) => {
it(`should have a '${iconName}' key`, () => {
expect(Object.hasOwnProperty.call(icons.default, iconName)).toBe(true);
});
});
});
});
#js-pipeline-graph-vue{ data: { endpoint: "foo" } }
import Vue from 'vue';
import actionComponent from '~/pipelines/components/graph/action_component.vue';
describe('pipeline graph action component', () => {
let component;
beforeEach(() => {
const ActionComponent = Vue.extend(actionComponent);
component = new ActionComponent({
propsData: {
tooltipText: 'bar',
link: 'foo',
actionMethod: 'post',
actionIcon: 'icon_action_cancel',
},
}).$mount();
});
it('should render a link', () => {
expect(component.$el.getAttribute('href')).toEqual('foo');
});
it('should render the provided title as a bootstrap tooltip', () => {
expect(component.$el.getAttribute('data-original-title')).toEqual('bar');
});
it('should update bootstrap tooltip when title changes', (done) => {
component.tooltipText = 'changed';
Vue.nextTick(() => {
expect(component.$el.getAttribute('data-original-title')).toBe('changed');
done();
});
});
it('should render an svg', () => {
expect(component.$el.querySelector('.ci-action-icon-wrapper')).toBeDefined();
expect(component.$el.querySelector('svg')).toBeDefined();
});
});
import Vue from 'vue';
import dropdownActionComponent from '~/pipelines/components/graph/dropdown_action_component.vue';
describe('action component', () => {
let component;
beforeEach(() => {
const DropdownActionComponent = Vue.extend(dropdownActionComponent);
component = new DropdownActionComponent({
propsData: {
tooltipText: 'bar',
link: 'foo',
actionMethod: 'post',
actionIcon: 'icon_action_cancel',
},
}).$mount();
});
it('should render a link', () => {
expect(component.$el.getAttribute('href')).toEqual('foo');
});
it('should render the provided title as a bootstrap tooltip', () => {
expect(component.$el.getAttribute('data-original-title')).toEqual('bar');
});
it('should render an svg', () => {
expect(component.$el.querySelector('svg')).toBeDefined();
});
});
import Vue from 'vue';
import graphComponent from '~/pipelines/components/graph/graph_component.vue';
describe('graph component', () => {
preloadFixtures('static/graph.html.raw');
let GraphComponent;
beforeEach(() => {
loadFixtures('static/graph.html.raw');
GraphComponent = Vue.extend(graphComponent);
});
describe('while is loading', () => {
it('should render a loading icon', () => {
const component = new GraphComponent().$mount('#js-pipeline-graph-vue');
expect(component.$el.querySelector('.loading-icon')).toBeDefined();
});
});
describe('with a successfull response', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
details: {
stages: [{
name: 'test',
title: 'test: passed',
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
details_path: '/root/ci-mock/pipelines/123#test',
},
path: '/root/ci-mock/pipelines/123#test',
groups: [{
name: 'test',
size: 1,
jobs: [{
id: 4153,
name: 'test',
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
details_path: '/root/ci-mock/builds/4153',
action: {
icon: 'icon_action_retry',
title: 'Retry',
path: '/root/ci-mock/builds/4153/retry',
method: 'post',
},
},
}],
}],
}],
},
}), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render the graph', (done) => {
const component = new GraphComponent().$mount('#js-pipeline-graph-vue');
setTimeout(() => {
expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true);
expect(component.$el.querySelector('loading-icon')).toBe(null);
expect(component.$el.querySelector('.stage-column-list')).toBeDefined();
done();
}, 0);
});
});
});
import Vue from 'vue';
import jobComponent from '~/pipelines/components/graph/job_component.vue';
describe('pipeline graph job component', () => {
let JobComponent;
const mockJob = {
id: 4256,
name: 'test',
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
details_path: '/root/ci-mock/builds/4256',
action: {
icon: 'icon_action_retry',
title: 'Retry',
path: '/root/ci-mock/builds/4256/retry',
method: 'post',
},
},
};
beforeEach(() => {
JobComponent = Vue.extend(jobComponent);
});
describe('name with link', () => {
it('should render the job name and status with a link', () => {
const component = new JobComponent({
propsData: {
job: mockJob,
},
}).$mount();
const link = component.$el.querySelector('a');
expect(link.getAttribute('href')).toEqual(mockJob.status.details_path);
expect(
link.getAttribute('data-original-title'),
).toEqual(`${mockJob.name} - ${mockJob.status.label}`);
expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined();
expect(
component.$el.querySelector('.ci-status-text').textContent.trim(),
).toEqual(mockJob.name);
});
});
describe('name without link', () => {
it('it should render status and name', () => {
const component = new JobComponent({
propsData: {
job: {
id: 4256,
name: 'test',
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
details_path: '/root/ci-mock/builds/4256',
},
},
},
}).$mount();
expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined();
expect(
component.$el.querySelector('.ci-status-text').textContent.trim(),
).toEqual(mockJob.name);
});
});
describe('action icon', () => {
it('it should render the action icon', () => {
const component = new JobComponent({
propsData: {
job: mockJob,
},
}).$mount();
expect(component.$el.querySelector('a.ci-action-icon-container')).toBeDefined();
expect(component.$el.querySelector('i.ci-action-icon-wrapper')).toBeDefined();
});
});
describe('dropdown', () => {
it('should render the dropdown action icon', () => {
const component = new JobComponent({
propsData: {
job: mockJob,
isDropdown: true,
},
}).$mount();
expect(component.$el.querySelector('a.ci-action-icon-wrapper')).toBeDefined();
});
});
it('should render provided class name', () => {
const component = new JobComponent({
propsData: {
job: mockJob,
cssClassJobName: 'css-class-job-name',
},
}).$mount();
expect(
component.$el.querySelector('a').classList.contains('css-class-job-name'),
).toBe(true);
});
});
import Vue from 'vue';
import jobNameComponent from '~/pipelines/components/graph/job_name_component.vue';
describe('job name component', () => {
let component;
beforeEach(() => {
const JobNameComponent = Vue.extend(jobNameComponent);
component = new JobNameComponent({
propsData: {
name: 'foo',
status: {
icon: 'icon_status_success',
},
},
}).$mount();
});
it('should render the provided name', () => {
expect(component.$el.querySelector('.ci-status-text').textContent.trim()).toEqual('foo');
});
it('should render an icon with the provided status', () => {
expect(component.$el.querySelector('.ci-status-icon-success')).toBeDefined();
expect(component.$el.querySelector('.ci-status-icon-success svg')).toBeDefined();
});
});
import Vue from 'vue';
import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
describe('stage column component', () => {
let component;
const mockJob = {
id: 4256,
name: 'test',
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
details_path: '/root/ci-mock/builds/4256',
action: {
icon: 'icon_action_retry',
title: 'Retry',
path: '/root/ci-mock/builds/4256/retry',
method: 'post',
},
},
};
beforeEach(() => {
const StageColumnComponent = Vue.extend(stageColumnComponent);
component = new StageColumnComponent({
propsData: {
title: 'foo',
jobs: [mockJob, mockJob, mockJob],
},
}).$mount();
});
it('should render provided title', () => {
expect(component.$el.querySelector('.stage-name').textContent.trim()).toEqual('foo');
});
it('should render the provided jobs', () => {
expect(component.$el.querySelectorAll('.builds-container > ul > li').length).toEqual(3);
});
});
require('~/pipelines'); import Pipelines from '~/pipelines';
// Fix for phantomJS // Fix for phantomJS
if (!Element.prototype.matches && Element.prototype.webkitMatchesSelector) { if (!Element.prototype.matches && Element.prototype.webkitMatchesSelector) {
Element.prototype.matches = Element.prototype.webkitMatchesSelector; Element.prototype.matches = Element.prototype.webkitMatchesSelector;
} }
(() => { describe('Pipelines', () => {
describe('Pipelines', () => { preloadFixtures('static/pipeline_graph.html.raw');
preloadFixtures('static/pipeline_graph.html.raw');
beforeEach(() => { beforeEach(() => {
loadFixtures('static/pipeline_graph.html.raw'); loadFixtures('static/pipeline_graph.html.raw');
}); });
it('should be defined', () => {
expect(window.gl.Pipelines).toBeDefined();
});
it('should create a `Pipelines` instance without options', () => {
expect(() => { new window.gl.Pipelines(); }).not.toThrow(); //eslint-disable-line
});
it('should create a `Pipelines` instance with options', () => { it('should be defined', () => {
const pipelines = new window.gl.Pipelines({ foo: 'bar' }); expect(Pipelines).toBeDefined();
});
expect(pipelines.pipelineGraph).toBeDefined(); it('should create a `Pipelines` instance without options', () => {
}); expect(() => { new Pipelines(); }).not.toThrow(); //eslint-disable-line
}); });
})(); });
import getActionIcon from '~/vue_shared/ci_action_icons';
import cancelSVG from 'icons/_icon_action_cancel.svg';
import retrySVG from 'icons/_icon_action_retry.svg';
import playSVG from 'icons/_icon_action_play.svg';
describe('getActionIcon', () => {
it('should return an empty string', () => {
expect(getActionIcon()).toEqual('');
});
it('should return cancel svg', () => {
expect(getActionIcon('icon_action_cancel')).toEqual(cancelSVG);
});
it('should return retry svg', () => {
expect(getActionIcon('icon_action_retry')).toEqual(retrySVG);
});
it('should return play svg', () => {
expect(getActionIcon('icon_action_play')).toEqual(playSVG);
});
});
import { borderlessStatusIconEntityMap, statusIconEntityMap } from '~/vue_shared/ci_status_icons';
describe('CI status icons', () => {
const statuses = [
'icon_status_canceled',
'icon_status_created',
'icon_status_failed',
'icon_status_manual',
'icon_status_pending',
'icon_status_running',
'icon_status_skipped',
'icon_status_success',
'icon_status_warning',
];
it('should have a dictionary for borderless icons', () => {
statuses.forEach((status) => {
expect(borderlessStatusIconEntityMap[status]).toBeDefined();
});
});
it('should have a dictionary for icons', () => {
statuses.forEach((status) => {
expect(statusIconEntityMap[status]).toBeDefined();
});
});
});
import Vue from 'vue';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
describe('CI Icon component', () => {
let CiIcon;
beforeEach(() => {
CiIcon = Vue.extend(ciIcon);
});
it('should render a span element with an svg', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_success',
},
},
}).$mount();
expect(component.$el.tagName).toEqual('SPAN');
expect(component.$el.querySelector('span > svg')).toBeDefined();
});
it('should render a success status', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_success',
},
},
}).$mount();
expect(component.$el.classList.contains('ci-status-icon-success')).toEqual(true);
});
it('should render a failed status', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_failed',
},
},
}).$mount();
expect(component.$el.classList.contains('ci-status-icon-failed')).toEqual(true);
});
it('should render success with warnings status', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_warning',
},
},
}).$mount();
expect(component.$el.classList.contains('ci-status-icon-warning')).toEqual(true);
});
it('should render pending status', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_pending',
},
},
}).$mount();
expect(component.$el.classList.contains('ci-status-icon-pending')).toEqual(true);
});
it('should render running status', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_running',
},
},
}).$mount();
expect(component.$el.classList.contains('ci-status-icon-running')).toEqual(true);
});
it('should render created status', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_created',
},
},
}).$mount();
expect(component.$el.classList.contains('ci-status-icon-created')).toEqual(true);
});
it('should render skipped status', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_skipped',
},
},
}).$mount();
expect(component.$el.classList.contains('ci-status-icon-skipped')).toEqual(true);
});
it('should render canceled status', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_canceled',
},
},
}).$mount();
expect(component.$el.classList.contains('ci-status-icon-canceled')).toEqual(true);
});
it('should render status for manual action', () => {
const component = new CiIcon({
propsData: {
status: {
icon: 'icon_status_manual',
},
},
}).$mount();
expect(component.$el.classList.contains('ci-status-icon-manual')).toEqual(true);
});
});
require 'spec_helper'
describe 'projects/pipelines/show' do
include Devise::Test::ControllerHelpers
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:pipeline) do
create(:ci_empty_pipeline,
project: project,
sha: project.commit.id,
user: user)
end
before do
controller.prepend_view_path('app/views/projects')
create_build('build', 0, 'build', :success)
create_build('test', 1, 'rspec 0:2', :pending)
create_build('test', 1, 'rspec 1:2', :running)
create_build('test', 1, 'spinach 0:2', :created)
create_build('test', 1, 'spinach 1:2', :created)
create_build('test', 1, 'audit', :created)
create_build('deploy', 2, 'production', :created)
create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3)
assign(:project, project)
assign(:pipeline, pipeline.present(current_user: user))
assign(:commit, project.commit)
allow(view).to receive(:can?).and_return(true)
end
it 'shows a graph with grouped stages' do
render
expect(rendered).to have_css('.js-pipeline-graph')
expect(rendered).to have_css('.js-grouped-pipeline-dropdown')
# header
expect(rendered).to have_text("##{pipeline.id}")
expect(rendered).to have_css('time', text: pipeline.created_at.strftime("%b %d, %Y"))
expect(rendered).to have_selector(%Q(img[alt$="#{pipeline.user.name}'s avatar"]))
expect(rendered).to have_link(pipeline.user.name, href: user_path(pipeline.user))
# stages
expect(rendered).to have_text('Build')
expect(rendered).to have_text('Test')
expect(rendered).to have_text('Deploy')
expect(rendered).to have_text('External')
# builds
expect(rendered).to have_text('rspec')
expect(rendered).to have_text('spinach')
expect(rendered).to have_text('rspec 0:2')
expect(rendered).to have_text('production')
expect(rendered).to have_text('jenkins')
end
private
def create_build(stage, stage_idx, name, status)
create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name, status: status)
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment