Commit aa440eb1 authored by Kamil Trzcinski's avatar Kamil Trzcinski

Single commit squash of all changes for...

Single commit squash of all changes for https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10878

It's needed due to https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10777 being merged with squash.
parent c17e6a6c
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,24 +31,21 @@ ...@@ -31,24 +31,21 @@
* *
* ### 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 || {};
window.gl.LinkedTabs = class LinkedTabs {
/** /**
* Binds the events and activates de default tab. * Binds the events and activates de default tab.
* *
* @param {Object} options * @param {Object} options
*/ */
constructor(options) { constructor(options = {}) {
this.options = options || {}; this.options = options;
this.defaultAction = this.options.defaultAction; this.defaultAction = this.options.defaultAction;
this.action = this.options.action || this.defaultAction; this.action = this.options.action || this.defaultAction;
...@@ -108,5 +105,4 @@ ...@@ -108,5 +105,4 @@
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 {
((global) => {
class Pipelines {
constructor(options = {}) { constructor(options = {}) {
if (options.initTabs && options.tabsOptions) { if (options.initTabs && options.tabsOptions) {
new global.LinkedTabs(options.tabsOptions); // eslint-disable-next-line no-new
new LinkedTabs(options.tabsOptions);
} }
if (options.pipelineStatusUrl) { if (options.pipelineStatusUrl) {
gl.utils.setCiStatusFavicon(options.pipelineStatusUrl); gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
} }
this.addMarginToBuildColumns();
}
addMarginToBuildColumns() {
this.pipelineGraph = document.querySelector('.js-pipeline-graph');
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);
},
},
};
</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"
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 { statusCssClasses, borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
export default { export default {
props: { props: {
...@@ -109,11 +109,11 @@ export default { ...@@ -109,11 +109,11 @@ export default {
}, },
triggerButtonClass() { triggerButtonClass() {
return `ci-status-icon-${this.stage.status.group}`; return `ci-status-icon-${statusCssClasses[this.stage.status.icon]}`;
}, },
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
...@@ -17,8 +17,11 @@ ...@@ -17,8 +17,11 @@
.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
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('pipelines_graph')
#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);
......
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(() => {
...@@ -14,17 +13,10 @@ if (!Element.prototype.matches && Element.prototype.webkitMatchesSelector) { ...@@ -14,17 +13,10 @@ if (!Element.prototype.matches && Element.prototype.webkitMatchesSelector) {
}); });
it('should be defined', () => { it('should be defined', () => {
expect(window.gl.Pipelines).toBeDefined(); expect(Pipelines).toBeDefined();
}); });
it('should create a `Pipelines` instance without options', () => { it('should create a `Pipelines` instance without options', () => {
expect(() => { new window.gl.Pipelines(); }).not.toThrow(); //eslint-disable-line expect(() => { new Pipelines(); }).not.toThrow(); //eslint-disable-line
}); });
});
it('should create a `Pipelines` instance with options', () => {
const pipelines = new window.gl.Pipelines({ foo: 'bar' });
expect(pipelines.pipelineGraph).toBeDefined();
});
});
})();
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);
});
});
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