Commit 497a1286 authored by Clement Ho's avatar Clement Ho

Merge branch 'bpj-mini-pipeline-graph-linked-pipelines' into 'master'

Add linked pipelines to mini pipeline graph in MR Widget

Closes #2602

See merge request !2039
parents 26dfe71e 71a44d34
import PipelineStage from '../../pipelines/components/stage.vue'; import PipelineStage from '../../pipelines/components/stage.vue';
import ciIcon from '../../vue_shared/components/ci_icon.vue'; import ciIcon from '../../vue_shared/components/ci_icon.vue';
import { statusIconEntityMap } from '../../vue_shared/ci_status_icons'; import { statusIconEntityMap } from '../../vue_shared/ci_status_icons';
import linkedPipelinesMiniList from '../../vue_shared/components/linked_pipelines_mini_list.vue';
export default { export default {
name: 'MRWidgetPipeline', name: 'MRWidgetPipeline',
...@@ -10,6 +11,7 @@ export default { ...@@ -10,6 +11,7 @@ export default {
components: { components: {
'pipeline-stage': PipelineStage, 'pipeline-stage': PipelineStage,
ciIcon, ciIcon,
linkedPipelinesMiniList,
}, },
computed: { computed: {
hasCIError() { hasCIError() {
...@@ -26,6 +28,18 @@ export default { ...@@ -26,6 +28,18 @@ export default {
status() { status() {
return this.mr.pipeline.details.status || {}; return this.mr.pipeline.details.status || {};
}, },
/* We typically set defaults ([]) in the store or prop declarations, but because triggered
* and triggeredBy are appended to `pipeline`, we can't set defaults in the store, and we
* need to check their length here to prevent initializing linked-pipeline-mini-lists
* unneccessarily. */
triggered() {
return this.mr.pipeline.triggered || [];
},
triggeredBy() {
return this.mr.pipeline.triggered_by || [];
},
}, },
template: ` template: `
<div class="mr-widget-heading"> <div class="mr-widget-heading">
...@@ -61,12 +75,25 @@ export default { ...@@ -61,12 +75,25 @@ export default {
</span> </span>
<div class="mr-widget-pipeline-graph"> <div class="mr-widget-pipeline-graph">
<div class="stage-cell"> <div class="stage-cell">
<linked-pipelines-mini-list
v-if="triggeredBy.length"
:triggered-by="triggeredBy"
/>
<div <div
v-if="mr.pipeline.details.stages.length > 0" v-if="mr.pipeline.details.stages.length > 0"
v-for="stage in mr.pipeline.details.stages" v-for="(stage, index) in mr.pipeline.details.stages"
class="stage-container dropdown js-mini-pipeline-graph"> class="stage-container dropdown js-mini-pipeline-graph"
:class="{
'has-downstream': index === mr.pipeline.details.stages.length - 1 && triggered.length
}">
<pipeline-stage :stage="stage" /> <pipeline-stage :stage="stage" />
</div> </div>
<linked-pipelines-mini-list
v-if="triggered.length"
:triggered="triggered"
/>
</div> </div>
</div> </div>
<span> <span>
......
<script>
import arrowSvg from 'icons/_arrow_mini_pipeline_graph.svg';
import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
import ciStatus from './ci_icon.vue';
import tooltipMixin from '../mixins/tooltip';
export default {
props: {
triggeredBy: {
type: Array,
required: false,
default: () => [],
},
triggered: {
type: Array,
required: false,
default: () => [],
},
pipelinePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
arrowSvg,
maxRenderedPipelines: 3,
};
},
mixins: [
tooltipMixin,
],
components: {
ciStatus,
},
computed: {
// Exactly one of these (triggeredBy and triggered) must be truthy. Never both. Never neither.
isUpstream() {
return !!this.triggeredBy.length && !this.triggered.length;
},
isDownstream() {
return !this.triggeredBy.length && !!this.triggered.length;
},
linkedPipelines() {
return this.isUpstream ? this.triggeredBy : this.triggered;
},
totalPipelineCount() {
return this.linkedPipelines.length;
},
linkedPipelinesTrimmed() {
return (this.totalPipelineCount > this.maxRenderedPipelines) ?
this.linkedPipelines.slice(0, this.maxRenderedPipelines) :
this.linkedPipelines;
},
shouldRenderCounter() {
return this.isDownstream && this.linkedPipelines.length > this.maxRenderedPipelines;
},
counterLabel() {
return `+${this.linkedPipelines.length - this.maxRenderedPipelines}`;
},
counterTooltipText() {
return `${this.counterLabel} more downstream pipelines`;
},
},
methods: {
pipelineTooltipText(pipeline) {
return `${pipeline.project.name} - ${pipeline.details.status.label}`;
},
getStatusIcon(icon) {
return borderlessStatusIconEntityMap[icon];
},
triggerButtonClass(group) {
return `ci-status-icon-${group}`;
},
},
};
</script>
<template>
<span
v-if="linkedPipelines"
class="linked-pipeline-mini-list"
:class="{
'is-upstream' : isUpstream,
'is-downstream': isDownstream
}">
<span
class="arrow-icon"
v-if="isDownstream"
v-html="arrowSvg"
aria-hidden="true">
</span>
<a
class="linked-pipeline-mini-item"
v-for="(pipeline, index) in linkedPipelinesTrimmed"
:key="pipeline.id"
:href="pipeline.path"
:title="pipelineTooltipText(pipeline)"
data-toggle="tooltip"
data-placement="top"
data-container="body"
ref="tooltip"
:class="triggerButtonClass(pipeline.details.status.group)"
v-html="getStatusIcon(pipeline.details.status.icon)">
</a>
<a
v-if="shouldRenderCounter"
class="linked-pipelines-counter linked-pipeline-mini-item"
:title="counterTooltipText"
:href="pipelinePath"
data-toggle="tooltip"
data-placement="top"
data-container="body"
ref="tooltip">
{{ counterLabel }}
</a>
<span
class="arrow-icon"
v-if="isUpstream"
v-html="arrowSvg"
aria-hidden="true">
</span>
</span>
</template>
...@@ -644,7 +644,8 @@ ...@@ -644,7 +644,8 @@
} }
// Dropdown button in mini pipeline graph // Dropdown button in mini pipeline graph
.mini-pipeline-graph-dropdown-toggle { .mini-pipeline-graph-dropdown-toggle,
.linked-pipeline-mini-item {
border-radius: 100px; border-radius: 100px;
background-color: $white-light; background-color: $white-light;
border-width: 1px; border-width: 1px;
...@@ -998,6 +999,93 @@ ...@@ -998,6 +999,93 @@
} }
} }
.linked-pipeline-mini-list {
display: inline-block;
&.is-downstream {
margin-left: -4px;
}
.arrow-icon {
display: inline-block;
vertical-align: middle;
margin: -2px 5px 0;
svg {
fill: $gray-darkest;
}
}
&:hover {
.linked-pipeline-mini-item {
margin-left: 0;
}
}
.linked-pipeline-mini-item {
position: relative;
display: inline-block;
vertical-align: middle;
height: 20px;
width: 20px;
transition: margin .2s linear;
margin: 2px 5px 3px -12px;
svg {
top: 0;
right: 0;
width: 18px;
height: 18px;
}
// override dropdown-toggle width expansion
&:hover {
width: 20px;
}
&:first-of-type:last-of-type {
margin-right: 1px;
}
&:nth-of-type(1) {
margin-left: 0;
z-index: 100;
}
&:nth-of-type(2):not(.linked-pipelines-counter) {
z-index: 99;
}
&:nth-of-type(3) {
z-index: 98;
}
&:nth-of-type(4) {
z-index: 97;
}
}
.linked-pipelines-counter {
position: relative;
font-size: 12px;
vertical-align: middle;
line-height: 20px;
height: 22px;
width: 22px;
padding-left: 1px;
margin-left: -15px;
border-radius: 2em;
background: $gray-darkest;
color: $white-light;
z-index: 96;
text-decoration: none;
&:hover {
width: 22px;
background: darken($gray-darkest, 10%);
}
}
}
/** /**
* Cross-project pipelines (applied conditionally to pipeline graph) * Cross-project pipelines (applied conditionally to pipeline graph)
*/ */
......
<svg width="16" height="8" viewBox="0 0 16 8" xmlns="http://www.w3.org/2000/svg"><path d="M10 3H0v2h10v3l5.549-3.7c.251-.167.25-.435 0-.6L10 0v3z" fill-rule="nonzero"/></svg>
...@@ -2,6 +2,7 @@ import Vue from 'vue'; ...@@ -2,6 +2,7 @@ import Vue from 'vue';
import { statusIconEntityMap } from '~/vue_shared/ci_status_icons'; import { statusIconEntityMap } from '~/vue_shared/ci_status_icons';
import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline'; import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline';
import mockData from '../mock_data'; import mockData from '../mock_data';
import mockLinkedPipelines from '../../pipelines/graph/linked_pipelines_mock_data';
const createComponent = (mr) => { const createComponent = (mr) => {
const Component = Vue.extend(pipelineComponent); const Component = Vue.extend(pipelineComponent);
...@@ -77,6 +78,7 @@ describe('MRWidgetPipeline', () => { ...@@ -77,6 +78,7 @@ describe('MRWidgetPipeline', () => {
}); });
it('should render template elements correctly', () => { it('should render template elements correctly', () => {
// TODO: Break this into separate specs
expect(el.classList.contains('mr-widget-heading')).toBeTruthy(); expect(el.classList.contains('mr-widget-heading')).toBeTruthy();
expect(el.querySelectorAll('.ci-status-icon.ci-status-icon-success').length).toEqual(1); expect(el.querySelectorAll('.ci-status-icon.ci-status-icon-success').length).toEqual(1);
expect(el.querySelector('.pipeline-id').textContent).toContain(`#${pipeline.id}`); expect(el.querySelector('.pipeline-id').textContent).toContain(`#${pipeline.id}`);
...@@ -127,5 +129,53 @@ describe('MRWidgetPipeline', () => { ...@@ -127,5 +129,53 @@ describe('MRWidgetPipeline', () => {
done(); done();
}); });
}); });
it('should not render upstream or downstream pipelines', () => {
expect(el.querySelector('.linked-pipeline-mini-list')).toBeNull();
});
});
describe('when upstream pipelines are passed', function () {
beforeEach(function () {
const pipeline = Object.assign({}, mockData.pipeline, {
triggered_by: mockLinkedPipelines.triggered_by,
});
this.vm = createComponent({
pipeline,
pipelineDetailedStatus: mockData.pipeline.details.status,
hasCI: true,
ciStatus: 'success',
}).$mount();
});
it('should render the linked pipelines mini list', function (done) {
Vue.nextTick(() => {
expect(this.vm.$el.querySelector('.linked-pipeline-mini-list.is-upstream')).not.toBeNull();
done();
});
});
});
describe('when downstream pipelines are passed', function () {
beforeEach(function () {
const pipeline = Object.assign({}, mockData.pipeline, {
triggered: mockLinkedPipelines.triggered,
});
this.vm = createComponent({
pipeline,
pipelineDetailedStatus: mockData.pipeline.details.status,
hasCI: true,
ciStatus: 'success',
}).$mount();
});
it('should render the linked pipelines mini list', function (done) {
Vue.nextTick(() => {
expect(this.vm.$el.querySelector('.linked-pipeline-mini-list.is-downstream')).not.toBeNull();
done();
});
});
}); });
}); });
import Vue from 'vue';
import LinkedPipelinesMiniList from '~/vue_shared/components/linked_pipelines_mini_list.vue';
import mockData from '../../pipelines/graph/linked_pipelines_mock_data';
const ListComponent = Vue.extend(LinkedPipelinesMiniList);
describe('Linked pipeline mini list', () => {
describe('when passed an upstream pipeline as prop', () => {
beforeEach(() => {
this.component = new ListComponent({
propsData: {
triggeredBy: mockData.triggered_by,
},
}).$mount();
});
it('should render one linked pipeline item', () => {
expect(this.component.$el.querySelectorAll('.linked-pipeline-mini-item').length).toBe(1);
});
it('should render a linked pipeline with the correct href', () => {
const linkElement = this.component.$el.querySelector('.linked-pipeline-mini-item');
expect(linkElement.getAttribute('href')).toBe('/gitlab-org/gitlab-ce/pipelines/129');
});
it('should render one ci status icon', () => {
expect(this.component.$el.querySelectorAll('.linked-pipeline-mini-item svg').length).toBe(1);
});
it('should render the correct ci status icon', () => {
const iconElement = this.component.$el.querySelector('.linked-pipeline-mini-item');
expect(iconElement.classList.contains('ci-status-icon-running')).toBe(true);
expect(iconElement.innerHTML).toContain('<svg');
});
it('should render an arrow icon', () => {
const iconElement = this.component.$el.querySelector('.arrow-icon');
expect(iconElement).not.toBeNull();
expect(iconElement.innerHTML).toContain('<svg');
});
it('should have an activated tooltip', () => {
const itemElement = this.component.$el.querySelector('.linked-pipeline-mini-item');
expect(itemElement.getAttribute('data-original-title')).toBe('GitLabCE - running');
});
it('should correctly set is-upstream', () => {
expect(this.component.$el.classList.contains('is-upstream')).toBe(true);
});
it('should correctly compute shouldRenderCounter', () => {
expect(this.component.shouldRenderCounter).toBe(false);
});
it('should not render the pipeline counter', () => {
expect(this.component.$el.querySelector('.linked-pipelines-counter')).toBeNull();
});
});
describe('when passed downstream pipelines as props', () => {
beforeEach(() => {
this.component = new ListComponent({
propsData: {
triggered: mockData.triggered,
pipelinePath: 'my/pipeline/path',
},
}).$mount();
});
it('should render one linked pipeline item', () => {
expect(this.component.$el.querySelectorAll('.linked-pipeline-mini-item:not(.linked-pipelines-counter)').length).toBe(3);
});
it('should render three ci status icons', () => {
expect(this.component.$el.querySelectorAll('.linked-pipeline-mini-item svg').length).toBe(3);
});
it('should render the correct ci status icon', () => {
const iconElement = this.component.$el.querySelector('.linked-pipeline-mini-item');
expect(iconElement.classList.contains('ci-status-icon-running')).toBe(true);
expect(iconElement.innerHTML).toContain('<svg');
});
it('should render an arrow icon', () => {
const iconElement = this.component.$el.querySelector('.arrow-icon');
expect(iconElement).not.toBeNull();
expect(iconElement.innerHTML).toContain('<svg');
});
it('should have prepped tooltips', () => {
const itemElement = this.component.$el.querySelectorAll('.linked-pipeline-mini-item')[2];
expect(itemElement.getAttribute('title')).toBe('GitLabCE - running');
});
it('should correctly set is-downstream', () => {
expect(this.component.$el.classList.contains('is-downstream')).toBe(true);
});
it('should correctly compute shouldRenderCounter', () => {
expect(this.component.shouldRenderCounter).toBe(true);
});
it('should correctly trim linkedPipelines', () => {
expect(this.component.triggered.length).toBe(6);
expect(this.component.linkedPipelinesTrimmed.length).toBe(3);
});
it('should render the pipeline counter', () => {
expect(this.component.$el.querySelector('.linked-pipelines-counter')).not.toBeNull();
});
it('should set the correct pipeline path', () => {
expect(this.component.$el.querySelector('.linked-pipelines-counter').getAttribute('href')).toBe('my/pipeline/path');
});
it('should render the correct counterTooltipText', () => {
expect(this.component.$el.querySelector('.linked-pipelines-counter').getAttribute('data-original-title')).toBe(this.component.counterTooltipText);
});
});
});
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