Commit b2f22b3f authored by Phil Hughes's avatar Phil Hughes

Merge branch '2122-transform-linked-into-button' into 'master'

Updates linked pipelines to render inline

Closes #2122

See merge request gitlab-org/gitlab-ee!8607
parents 60bb9eb4 738d27e8
<script> <script>
import _ from 'underscore'; import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import LinkedPipelinesColumn from 'ee/pipelines/components/graph/linked_pipelines_column.vue';
import EEGraphMixin from 'ee/pipelines/mixins/graph_component_mixin';
import StageColumnComponent from './stage_column_component.vue'; import StageColumnComponent from './stage_column_component.vue';
import LinkedPipelinesColumn from 'ee/pipelines/components/graph/linked_pipelines_column.vue'; // eslint-disable-line import/order
export default { export default {
components: { components: {
...@@ -10,6 +11,7 @@ export default { ...@@ -10,6 +11,7 @@ export default {
StageColumnComponent, StageColumnComponent,
GlLoadingIcon, GlLoadingIcon,
}, },
mixins: [EEGraphMixin],
props: { props: {
isLoading: { isLoading: {
type: Boolean, type: Boolean,
...@@ -20,36 +22,19 @@ export default { ...@@ -20,36 +22,19 @@ export default {
required: true, required: true,
}, },
}, },
computed: { computed: {
graph() { graph() {
return this.pipeline.details && this.pipeline.details.stages; return this.pipeline.details && this.pipeline.details.stages;
}, },
triggered() {
return this.pipeline.triggered || [];
},
triggeredBy() {
const response = this.pipeline.triggered_by;
return response ? [response] : [];
},
hasTriggered() {
return !!this.triggered.length;
},
hasTriggeredBy() {
return !!this.triggeredBy.length;
},
}, },
methods: { methods: {
capitalizeStageName(name) { capitalizeStageName(name) {
const escapedName = _.escape(name); const escapedName = _.escape(name);
return escapedName.charAt(0).toUpperCase() + escapedName.slice(1); return escapedName.charAt(0).toUpperCase() + escapedName.slice(1);
}, },
isFirstColumn(index) { isFirstColumn(index) {
return index === 0; return index === 0;
}, },
stageConnectorClass(index, stage) { stageConnectorClass(index, stage) {
let className; let className;
...@@ -63,10 +48,12 @@ export default { ...@@ -63,10 +48,12 @@ export default {
return className; return className;
}, },
refreshPipelineGraph() { refreshPipelineGraph() {
this.$emit('refreshPipelineGraph'); this.$emit('refreshPipelineGraph');
}, },
hasOnlyOneJob(stage) {
return stage.groups.length === 1;
},
}, },
}; };
</script> </script>
...@@ -75,11 +62,27 @@ export default { ...@@ -75,11 +62,27 @@ export default {
<div class="pipeline-visualization pipeline-graph pipeline-tab-content"> <div class="pipeline-visualization pipeline-graph pipeline-tab-content">
<div class="text-center"><gl-loading-icon v-if="isLoading" :size="3" /></div> <div class="text-center"><gl-loading-icon v-if="isLoading" :size="3" /></div>
<ul v-if="shouldRenderTriggeredByPipeline" class="d-inline-block upstream-pipeline align-top">
<stage-column-component
v-for="(stage, indexUpstream) in triggeredByGraph"
:key="stage.name"
:class="{
'has-only-one-job': hasOnlyOneJob(stage),
}"
:title="capitalizeStageName(stage.name)"
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(indexUpstream, stage)"
:is-first-column="isFirstColumn(indexUpstream)"
@refreshPipelineGraph="refreshTriggeredByPipelineGraph"
/>
</ul>
<linked-pipelines-column <linked-pipelines-column
v-if="hasTriggeredBy" v-if="hasTriggeredBy"
:linked-pipelines="triggeredBy" :linked-pipelines="triggeredByPipelines"
column-title="Upstream" :column-title="__('Upstream')"
graph-position="left" graph-position="left"
@linkedPipelineClick="pipeline => $emit('onClickTriggeredBy', pipeline)"
/> />
<ul <ul
...@@ -87,7 +90,7 @@ export default { ...@@ -87,7 +90,7 @@ export default {
:class="{ :class="{
'has-linked-pipelines': hasTriggered || hasTriggeredBy, 'has-linked-pipelines': hasTriggered || hasTriggeredBy,
}" }"
class="stage-column-list" class="stage-column-list align-top"
> >
<stage-column-component <stage-column-component
v-for="(stage, index) in graph" v-for="(stage, index) in graph"
...@@ -95,7 +98,7 @@ export default { ...@@ -95,7 +98,7 @@ export default {
:class="{ :class="{
'has-upstream': index === 0 && hasTriggeredBy, 'has-upstream': index === 0 && hasTriggeredBy,
'has-downstream': index === graph.length - 1 && hasTriggered, 'has-downstream': index === graph.length - 1 && hasTriggered,
'has-only-one-job': stage.groups.length === 1, 'has-only-one-job': hasOnlyOneJob(stage),
}" }"
:title="capitalizeStageName(stage.name)" :title="capitalizeStageName(stage.name)"
:groups="stage.groups" :groups="stage.groups"
...@@ -108,10 +111,30 @@ export default { ...@@ -108,10 +111,30 @@ export default {
<linked-pipelines-column <linked-pipelines-column
v-if="hasTriggered" v-if="hasTriggered"
:linked-pipelines="triggered" :linked-pipelines="triggeredPipelines"
column-title="Downstream" :column-title="__('Downstream')"
graph-position="right" graph-position="right"
@linkedPipelineClick="handleClickedDownstream"
/> />
<ul
v-if="shouldRenderTriggeredPipeline"
class="d-inline-block downstream-pipeline position-relative align-top"
:style="{ 'margin-top': marginTop }"
>
<stage-column-component
v-for="(stage, indexDownstream) in triggeredGraph"
:key="stage.name"
:class="{
'has-only-one-job': hasOnlyOneJob(stage),
}"
:title="capitalizeStageName(stage.name)"
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(indexDownstream, stage)"
:is-first-column="isFirstColumn(indexDownstream)"
@refreshPipelineGraph="refreshTriggeredPipelineGraph"
/>
</ul>
</div> </div>
</div> </div>
</template> </template>
...@@ -27,10 +27,10 @@ export default { ...@@ -27,10 +27,10 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
hasTriggeredBy: { hasTriggeredBy: {
type: Boolean, type: Boolean,
required: true, required: false,
default: false,
}, },
}, },
methods: { methods: {
......
...@@ -2,10 +2,11 @@ import Vue from 'vue'; ...@@ -2,10 +2,11 @@ import Vue from 'vue';
import Flash from '~/flash'; import Flash from '~/flash';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import { __ } from '~/locale'; import { __ } from '~/locale';
import PipelinesMediator from './pipeline_details_mediator'; import PipelinesMediator from 'ee/pipelines/pipeline_details_mediator';
import pipelineGraph from './components/graph/graph_component.vue'; import pipelineGraph from './components/graph/graph_component.vue';
import pipelineHeader from './components/header_component.vue'; import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub'; import eventHub from './event_hub';
import GraphEEMixin from 'ee/pipelines/mixins/graph_pipeline_bundle_mixin'; // eslint-disable-line import/order
Vue.use(Translate); Vue.use(Translate);
...@@ -22,6 +23,7 @@ export default () => { ...@@ -22,6 +23,7 @@ export default () => {
components: { components: {
pipelineGraph, pipelineGraph,
}, },
mixins: [GraphEEMixin],
data() { data() {
return { return {
mediator, mediator,
...@@ -41,9 +43,21 @@ export default () => { ...@@ -41,9 +43,21 @@ export default () => {
props: { props: {
isLoading: this.mediator.state.isLoading, isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline, pipeline: this.mediator.store.state.pipeline,
// EE-only start
triggeredPipelines: this.mediator.store.state.triggeredPipelines,
triggered: this.mediator.store.state.triggered,
triggeredByPipelines: this.mediator.store.state.triggeredByPipelines,
triggeredBy: this.mediator.store.state.triggeredBy,
// EE-only end
}, },
on: { on: {
refreshPipelineGraph: this.requestRefreshPipelineGraph, refreshPipelineGraph: this.requestRefreshPipelineGraph,
// EE-only start
refreshTriggeredPipelineGraph: this.mediator.refreshTriggeredByPipelineGraph,
refreshTriggeredByPipelineGraph: this.mediator.refreshTriggeredByPipelineGraph,
onClickTriggeredBy: pipeline => this.clickTriggeredBy(pipeline),
onClickTriggered: pipeline => this.clickTriggered(pipeline),
// EE-only end
}, },
}); });
}, },
......
...@@ -2,8 +2,8 @@ import Visibility from 'visibilityjs'; ...@@ -2,8 +2,8 @@ import Visibility from 'visibilityjs';
import Flash from '../flash'; import Flash from '../flash';
import Poll from '../lib/utils/poll'; import Poll from '../lib/utils/poll';
import { __ } from '../locale'; import { __ } from '../locale';
import PipelineStore from './stores/pipeline_store'; import PipelineStore from 'ee/pipelines/stores/pipeline_store'; // eslint-disable-line import/order
import PipelineService from './services/pipeline_service'; import PipelineService from 'ee/pipelines/services/pipeline_service'; // eslint-disable-line import/order
export default class pipelinesMediator { export default class pipelinesMediator {
constructor(options = {}) { constructor(options = {}) {
......
<script> <script>
import { GlLoadingIcon, GlTooltipDirective, GlLink } from '@gitlab/ui'; import { GlLoadingIcon, GlTooltipDirective, GlButton } from '@gitlab/ui';
import CiStatus from '~/vue_shared/components/ci_icon.vue'; import CiStatus from '~/vue_shared/components/ci_icon.vue';
export default { export default {
...@@ -9,17 +9,13 @@ export default { ...@@ -9,17 +9,13 @@ export default {
components: { components: {
CiStatus, CiStatus,
GlLoadingIcon, GlLoadingIcon,
GlLink, GlButton,
}, },
props: { props: {
pipelineId: { pipelineId: {
type: Number, type: Number,
required: true, required: true,
}, },
pipelinePath: {
type: String,
required: true,
},
pipelineStatus: { pipelineStatus: {
type: Object, type: Object,
required: true, required: true,
...@@ -38,6 +34,15 @@ export default { ...@@ -38,6 +34,15 @@ export default {
tooltipText() { tooltipText() {
return `${this.projectName} - ${this.pipelineStatus.label}`; return `${this.projectName} - ${this.pipelineStatus.label}`;
}, },
buttonId() {
return `js-linked-pipeline-${this.pipelineId}`;
},
},
methods: {
onClickLinkedPipeline() {
this.$root.$emit('bv::hide::tooltip', this.buttonId);
this.$emit('pipelineClicked');
},
}, },
}; };
</script> </script>
...@@ -45,21 +50,17 @@ export default { ...@@ -45,21 +50,17 @@ export default {
<template> <template>
<li class="linked-pipeline build"> <li class="linked-pipeline build">
<div class="curve"></div> <div class="curve"></div>
<div> <gl-button
<gl-link :id="buttonId"
v-gl-tooltip v-gl-tooltip
:href="pipelinePath" :title="tooltipText"
:title="tooltipText" class="js-linked-pipeline-content linked-pipeline-content"
class="js-linked-pipeline-content linked-pipeline-content" @click="onClickLinkedPipeline"
> >
<span class="js-linked-pipeline-status ci-status-text"> <gl-loading-icon v-if="isLoading" class="js-linked-pipeline-loading d-inline" />
<gl-loading-icon v-if="isLoading" class="js-linked-pipeline-loading" /> <ci-status v-else :status="pipelineStatus" class="js-linked-pipeline-status" />
<ci-status v-else :status="pipelineStatus" class="js-linked-pipeline-status" />
</span> <span class="str-truncated align-bottom"> {{ projectName }} &#8226; #{{ pipelineId }} </span>
<span class="linked-pipeline-project-name">{{ projectName }}</span> </gl-button>
<span class="project-name-pipeline-id-separator">&#8226;</span>
<span class="js-linked-pipeline-id">#{{ pipelineId }}</span>
</gl-link>
</div>
</li> </li>
</template> </template>
<script> <script>
import linkedPipeline from './linked_pipeline.vue'; import LinkedPipeline from './linked_pipeline.vue';
export default { export default {
components: { components: {
linkedPipeline, LinkedPipeline,
}, },
props: { props: {
columnTitle: { columnTitle: {
...@@ -19,7 +19,6 @@ export default { ...@@ -19,7 +19,6 @@ export default {
required: true, required: true,
}, },
}, },
computed: { computed: {
columnClass() { columnClass() {
return `graph-position-${this.graphPosition}`; return `graph-position-${this.graphPosition}`;
...@@ -38,11 +37,15 @@ export default { ...@@ -38,11 +37,15 @@ export default {
:key="pipeline.id" :key="pipeline.id"
:class="{ :class="{
'flat-connector-before': index === 0 && graphPosition === 'right', 'flat-connector-before': index === 0 && graphPosition === 'right',
active: !pipeline.isCollapsed || pipeline.isLoading,
'left-connector': !pipeline.isCollapsed && graphPosition === 'left',
}" }"
:pipeline-id="pipeline.id" :pipeline-id="pipeline.id"
:project-name="pipeline.project.name" :project-name="pipeline.project.name"
:pipeline-status="pipeline.details.status" :pipeline-status="pipeline.details.status"
:pipeline-path="pipeline.path" :pipeline-path="pipeline.path"
:is-loading="pipeline.isLoading"
@pipelineClicked="$emit('linkedPipelineClick', pipeline, index);"
/> />
</ul> </ul>
</div> </div>
......
import _ from 'underscore';
export default {
props: {
triggered: {
type: Object,
required: false,
default: () => ({}),
},
triggeredBy: {
type: Object,
required: false,
default: () => ({}),
},
triggeredByPipelines: {
type: Array,
required: false,
default: () => [],
},
triggeredPipelines: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
triggeredTopIndex: 1,
};
},
computed: {
triggeredGraph() {
return this.triggered && this.triggered.details && this.triggered.details.stages;
},
triggeredByGraph() {
return this.triggeredBy && this.triggeredBy.details && this.triggeredBy.details.stages;
},
hasTriggered() {
return this.triggeredPipelines.length > 0;
},
hasTriggeredBy() {
return this.triggeredByPipelines.length > 0;
},
shouldRenderTriggeredPipeline() {
return !this.isLoading && !_.isEmpty(this.triggered);
},
shouldRenderTriggeredByPipeline() {
return !this.isLoading && !_.isEmpty(this.triggeredBy);
},
/**
* Calculates the margin top of the clicked downstream pipeline by
* adding the height of each linked pipeline and the margin
*/
marginTop() {
return `${this.triggeredTopIndex * 52}px`;
},
},
methods: {
refreshTriggeredPipelineGraph() {
this.$emit('refreshTriggeredPipelineGraph');
},
refreshTriggeredByPipelineGraph() {
this.$emit('refreshTriggeredByPipelineGraph');
},
handleClickedDownstream(pipeline, clickedIndex) {
this.triggeredTopIndex = clickedIndex;
this.$emit('onClickTriggered', pipeline);
},
},
};
import pipelinesKeys from 'ee/pipelines/constants';
export default {
methods: {
/**
* Called when a linked pipeline is clicked.
*
* If the pipeline is collapsed we will start polling it & we will reset the other pipelines.
* If the pipeline is expanded we will close it.
*
* @param {String} method Method to fetch the pipeline
* @param {String} storeKey Store property that will be updates
* @param {String} resetStoreKey Store key for the visible pipeline that will need to be reset
* @param {Object} pipeline The clicked pipeline
*/
clickPipeline(method, storeKey, resetStoreKey, pipeline, pollKey) {
if (pipeline.isCollapsed) {
this.mediator[method](pipeline);
} else {
this.mediator.resetPipeline(storeKey, pipeline, resetStoreKey, pollKey);
}
},
clickTriggered(triggered) {
this.clickPipeline(
'fetchTriggeredPipeline',
pipelinesKeys.triggeredPipelines,
pipelinesKeys.triggered,
triggered,
'pollTriggered',
);
},
clickTriggeredBy(triggeredBy) {
this.clickPipeline(
'fetchTriggeredByPipeline',
pipelinesKeys.triggeredByPipelines,
pipelinesKeys.triggeredBy,
triggeredBy,
'pollTriggeredBy',
);
},
},
};
import CePipelineMediator from '~/pipelines/pipeline_details_mediator';
import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import PipelineService from 'ee/pipelines/services/pipeline_service';
/**
* Extends CE mediator with the logic to handle the upstream/downstream pipelines
*/
export default class EePipelineMediator extends CePipelineMediator {
/**
* Requests the clicked downstream pipeline pipeline
*
* @param {Object} pipeline
*/
fetchTriggeredPipeline(pipeline) {
if (this.pollTriggered) {
this.pollTriggered.stop();
this.pollTriggered = null;
}
this.store.requestTriggeredPipeline(pipeline);
this.pollTriggered = new Poll({
resource: PipelineService,
method: 'getUpstreamDownstream',
data: pipeline.path,
successCallback: ({ data }) => this.store.receiveTriggeredPipelineSuccess(pipeline, data),
errorCallback: () => {
this.store.receiveTriggeredPipelineError(pipeline);
createFlash(
__('An error occured while fetching this downstream pipeline. Please try again'),
);
},
});
this.pollTriggered.makeRequest();
}
refreshTriggeredPipelineGraph() {
this.pollTriggered.stop();
this.pollTriggered.restart();
}
/**
* Requests the clicked upstream pipeline pipeline
* @param {*} pipeline
*/
fetchTriggeredByPipeline(pipeline) {
if (this.pollTriggeredBy) {
this.pollTriggeredBy.stop();
this.pollTriggeredBy = null;
}
this.store.requestTriggeredByPipeline(pipeline);
this.pollTriggeredBy = new Poll({
resource: PipelineService,
method: 'getUpstreamDownstream',
data: pipeline.path,
successCallback: ({ data }) => this.store.receiveTriggeredByPipelineSuccess(pipeline, data),
errorCallback: () => {
this.store.receiveTriggeredByPipelineError(pipeline);
createFlash(__('An error occured while fetching this upstream pipeline. Please try again'));
},
});
this.pollTriggeredBy.makeRequest();
}
refreshTriggeredByPipelineGraph() {
this.pollTriggeredBy.stop();
this.pollTriggeredBy.restart();
}
resetPipeline(storeKey, pipeline, resetStoreKey, pollKey) {
this[pollKey].stop();
this.store.closePipeline(storeKey, pipeline, resetStoreKey);
}
}
...@@ -33,13 +33,30 @@ export default class PipelineStore extends CePipelineStore { ...@@ -33,13 +33,30 @@ export default class PipelineStore extends CePipelineStore {
super.storePipeline(pipeline); super.storePipeline(pipeline);
if (pipeline.triggered && pipeline.triggered.length) { if (pipeline.triggered && pipeline.triggered.length) {
this.state.triggeredPipelines = pipeline.triggered.map(triggered => this.state.triggeredPipelines = pipeline.triggered.map(triggered => {
PipelineStore.parsePipeline(triggered), // because we are polling we need to make sure we do not hijack user's clicks.
); const oldPipeline = this.state.triggeredPipelines.find(
oldValue => oldValue.id === triggered.id,
);
return Object.assign({}, triggered, {
isCollapsed: oldPipeline ? oldPipeline.isCollapsed : true,
isLoading: oldPipeline ? oldPipeline.isLoading : false,
});
});
} }
if (pipeline.triggered_by) { if (pipeline.triggered_by) {
this.state.triggeredByPipelines = [PipelineStore.parsePipeline(pipeline.triggered_by)]; this.state.triggeredByPipelines = [
Object.assign({}, pipeline.triggered_by, {
isCollapsed: this.state.triggeredByPipelines.length
? this.state.triggeredByPipelines[0].isCollapsed
: true,
isLoading: this.state.triggeredByPipelines.length
? this.state.triggeredByPipelines[0].isLoading
: false,
}),
];
} }
} }
...@@ -72,7 +89,7 @@ export default class PipelineStore extends CePipelineStore { ...@@ -72,7 +89,7 @@ export default class PipelineStore extends CePipelineStore {
this.updatePipeline( this.updatePipeline(
pipelinesKeys.triggeredPipelines, pipelinesKeys.triggeredPipelines,
pipeline, pipeline,
{ isLoading: false }, { isLoading: false, isCollapsed: false },
pipelinesKeys.triggered, pipelinesKeys.triggered,
response, response,
); );
...@@ -121,7 +138,7 @@ export default class PipelineStore extends CePipelineStore { ...@@ -121,7 +138,7 @@ export default class PipelineStore extends CePipelineStore {
this.updatePipeline( this.updatePipeline(
pipelinesKeys.triggeredByPipelines, pipelinesKeys.triggeredByPipelines,
pipeline, pipeline,
{ isLoading: false }, { isLoading: false, isCollapsed: false },
pipelinesKeys.triggeredBy, pipelinesKeys.triggeredBy,
response, response,
); );
...@@ -183,7 +200,6 @@ export default class PipelineStore extends CePipelineStore { ...@@ -183,7 +200,6 @@ export default class PipelineStore extends CePipelineStore {
if (triggered.id === pipeline.id) { if (triggered.id === pipeline.id) {
return Object.assign({}, triggered, { isLoading: true, isCollapsed: false }); return Object.assign({}, triggered, { isLoading: true, isCollapsed: false });
} }
// reset the others, in case another was one opened // reset the others, in case another was one opened
return PipelineStore.parsePipeline(triggered); return PipelineStore.parsePipeline(triggered);
}); });
......
...@@ -98,6 +98,10 @@ ...@@ -98,6 +98,10 @@
display: inline-block; display: inline-block;
} }
.upstream-pipeline {
margin-right: 84px;
}
.linked-pipelines-column.stage-column { .linked-pipelines-column.stage-column {
position: relative; position: relative;
...@@ -119,6 +123,16 @@ ...@@ -119,6 +123,16 @@
.cross-project-triangle { .cross-project-triangle {
left: -64px; left: -64px;
} }
// reset connectors for the downstream pipeline
.linked-pipeline.build {
.curve::before,
&::after {
content: '';
width: 0;
border: 0;
}
}
} }
.linked-pipeline.build { .linked-pipeline.build {
...@@ -129,16 +143,36 @@ ...@@ -129,16 +143,36 @@
@include flat-connector-before($linked-project-column-margin); @include flat-connector-before($linked-project-column-margin);
} }
&::after { &.active, {
right: -$linked-project-column-margin; .linked-pipeline-content,
width: $linked-project-column-margin; .linked-pipeline-content:hover,
.linked-pipeline-content:focus, {
background-color: $blue-100;
}
&.left-connector {
@include flat-connector-before(88px)
}
&::after {
content: '';
position: absolute;
top: 48%;
right: -88px;
border-top: 2px solid $border-color;
width: 88px;
height: 1px;
}
} }
.linked-pipeline-content { .linked-pipeline-content {
@include build-content(0); @include build-content(0);
white-space: nowrap; text-align: inherit;
overflow: hidden; min-height: 42px;
text-overflow: ellipsis;
svg {
top: 0;
}
} }
} }
} }
......
---
title: Renders upstream and downstream pipelines in the main pipeline graph
merge_request: 8607
author:
type: fixed
...@@ -48,43 +48,26 @@ describe('Linked pipeline', () => { ...@@ -48,43 +48,26 @@ describe('Linked pipeline', () => {
expect(vm.$el.tagName).toBe('LI'); expect(vm.$el.tagName).toBe('LI');
}); });
it('should render a link', () => { it('should render a button', () => {
const linkElement = vm.$el.querySelector('.js-linked-pipeline-content'); const linkElement = vm.$el.querySelector('.js-linked-pipeline-content');
expect(linkElement).not.toBeNull(); expect(linkElement).not.toBeNull();
}); });
it('should link to the correct path', () => {
const linkElement = vm.$el.querySelector('.js-linked-pipeline-content');
expect(linkElement.getAttribute('href')).toBe(props.pipelinePath);
});
it('should render the project name', () => { it('should render the project name', () => {
const projectNameElement = vm.$el.querySelector('.linked-pipeline-project-name'); expect(vm.$el.innerText).toContain(props.projectName);
expect(projectNameElement.innerText).toContain(props.projectName);
}); });
it('should render an svg within the status container', () => { it('should render an svg within the status container', () => {
console.log(vm.$el);
const pipelineStatusElement = vm.$el.querySelector('.js-linked-pipeline-status'); const pipelineStatusElement = vm.$el.querySelector('.js-linked-pipeline-status');
expect(pipelineStatusElement.querySelector('svg')).not.toBeNull(); expect(pipelineStatusElement.querySelector('svg')).not.toBeNull();
}); });
it('should render the pipeline status icon svg', () => { it('should render the pipeline status icon svg', () => {
const pipelineStatusElement = vm.$el.querySelector('.js-linked-pipeline-status'); expect(vm.$el.querySelector('.js-ci-status-icon-running')).not.toBeNull();
expect(vm.$el.querySelector('.js-ci-status-icon-running').innerHTML).toContain('<svg');
expect(pipelineStatusElement.querySelector('.ci-status-icon-running')).not.toBeNull();
expect(pipelineStatusElement.innerHTML).toContain('<svg');
});
it('should render the correct pipeline status icon style selector', () => {
const pipelineStatusElement = vm.$el.querySelector('.js-linked-pipeline-status');
expect(pipelineStatusElement.firstChild.classList.contains('ci-status-icon-running')).toBe(
true,
);
}); });
it('should have a ci-status child component', () => { it('should have a ci-status child component', () => {
...@@ -92,9 +75,7 @@ describe('Linked pipeline', () => { ...@@ -92,9 +75,7 @@ describe('Linked pipeline', () => {
}); });
it('should render the pipeline id', () => { it('should render the pipeline id', () => {
const pipelineIdElement = vm.$el.querySelector('.js-linked-pipeline-id'); expect(vm.$el.innerText).toContain(`#${props.pipelineId}`);
expect(pipelineIdElement.innerText).toContain(`#${props.pipelineId}`);
}); });
it('should correctly compute the tooltip text', () => { it('should correctly compute the tooltip text', () => {
...@@ -110,4 +91,35 @@ describe('Linked pipeline', () => { ...@@ -110,4 +91,35 @@ describe('Linked pipeline', () => {
expect(titleAttr).toContain(mockPipeline.details.status.label); expect(titleAttr).toContain(mockPipeline.details.status.label);
}); });
}); });
describe('on click', () => {
const props = {
pipelineId: mockPipeline.id,
pipelinePath: mockPipeline.path,
pipelineStatus: mockPipeline.details.status,
projectName: mockPipeline.project.name,
isLoading: false,
};
beforeEach(() => {
vm = mountComponent(Component, props);
});
it('emits `pipelineClicked` event', () => {
spyOn(vm, '$emit');
vm.$el.querySelector('button').click();
expect(vm.$emit).toHaveBeenCalledWith('pipelineClicked');
});
it('should emit `bv::hide::tooltip` to close the tooltip', () => {
spyOn(vm.$root, '$emit');
vm.$el.querySelector('button').click();
expect(vm.$root.$emit).toHaveBeenCalledWith(
'bv::hide::tooltip',
`js-linked-pipeline-${props.pipelineId}`,
);
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import LinkedPipelinesColumn from 'ee/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipelinesColumn from 'ee/pipelines/components/graph/linked_pipelines_column.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import mockData from './linked_pipelines_mock_data'; import mockData from './linked_pipelines_mock_data';
const LinkedPipelinesColumnComponent = Vue.extend(LinkedPipelinesColumn); describe('Linked Pipelines Column', () => {
const Component = Vue.extend(LinkedPipelinesColumn);
const props = {
columnTitle: 'Upstream',
linkedPipelines: mockData.triggered,
graphPosition: 'right',
};
let vm;
describe('Linked Pipelines Column', function() {
beforeEach(() => { beforeEach(() => {
this.propsData = { vm = mountComponent(Component, props);
columnTitle: 'Upstream',
linkedPipelines: mockData.triggered,
graphPosition: 'right',
};
this.linkedPipelinesColumn = new LinkedPipelinesColumnComponent({
propsData: this.propsData,
}).$mount();
}); });
it('instantiates a defined Vue component', () => { afterEach(() => {
expect(this.linkedPipelinesColumn).toBeDefined(); vm.$destroy();
}); });
it('renders the pipeline orientation', () => { it('renders the pipeline orientation', () => {
const titleElement = this.linkedPipelinesColumn.$el.querySelector( const titleElement = vm.$el.querySelector('.linked-pipelines-column-title');
'.linked-pipelines-column-title',
);
expect(titleElement.innerText).toContain(this.propsData.columnTitle); expect(titleElement.innerText).toContain(props.columnTitle);
}); });
it('has the correct number of linked pipeline child components', () => { it('has the correct number of linked pipeline child components', () => {
expect(this.linkedPipelinesColumn.$children.length).toBe(this.propsData.linkedPipelines.length); expect(vm.$children.length).toBe(props.linkedPipelines.length);
}); });
it('renders the correct number of linked pipelines', () => { it('renders the correct number of linked pipelines', () => {
const linkedPipelineElements = this.linkedPipelinesColumn.$el.querySelectorAll( const linkedPipelineElements = vm.$el.querySelectorAll('.linked-pipeline');
'.linked-pipeline',
);
expect(linkedPipelineElements.length).toBe(this.propsData.linkedPipelines.length); expect(linkedPipelineElements.length).toBe(props.linkedPipelines.length);
}); });
}); });
...@@ -608,6 +608,12 @@ msgstr "" ...@@ -608,6 +608,12 @@ msgstr ""
msgid "An error has occurred" msgid "An error has occurred"
msgstr "" msgstr ""
msgid "An error occured while fetching this downstream pipeline. Please try again"
msgstr ""
msgid "An error occured while fetching this upstream pipeline. Please try again"
msgstr ""
msgid "An error occurred adding a draft to the discussion." msgid "An error occurred adding a draft to the discussion."
msgstr "" msgstr ""
...@@ -2998,6 +3004,9 @@ msgstr "" ...@@ -2998,6 +3004,9 @@ msgstr ""
msgid "DownloadSource|Download" msgid "DownloadSource|Download"
msgstr "" msgstr ""
msgid "Downstream"
msgstr ""
msgid "Downvotes" msgid "Downvotes"
msgstr "" msgstr ""
...@@ -9043,6 +9052,9 @@ msgstr "" ...@@ -9043,6 +9052,9 @@ msgstr ""
msgid "UploadLink|click to upload" msgid "UploadLink|click to upload"
msgstr "" msgstr ""
msgid "Upstream"
msgstr ""
msgid "Upvotes" msgid "Upvotes"
msgstr "" msgstr ""
......
...@@ -28,28 +28,30 @@ describe('graph component', () => { ...@@ -28,28 +28,30 @@ describe('graph component', () => {
}); });
}); });
describe('when linked pipelines are present', function() { describe('when linked pipelines are present', () => {
beforeEach(function() { beforeEach(() => {
component = mountComponent(GraphComponent, { component = mountComponent(GraphComponent, {
isLoading: false, isLoading: false,
pipeline: graphJSON, pipeline: graphJSON,
triggeredByPipelines: [linkedPipelineJSON.triggered_by],
triggeredPipelines: linkedPipelineJSON.triggered,
}); });
}); });
describe('rendered output', function() { describe('rendered output', () => {
it('should include the pipelines graph', function() { it('should include the pipelines graph', () => {
expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true); expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true);
}); });
it('should not include the loading icon', function() { it('should not include the loading icon', () => {
expect(component.$el.querySelector('.fa-spinner')).toBeNull(); expect(component.$el.querySelector('.fa-spinner')).toBeNull();
}); });
it('should include the stage column list', function() { it('should include the stage column list', () => {
expect(component.$el.querySelector('.stage-column-list')).not.toBeNull(); expect(component.$el.querySelector('.stage-column-list')).not.toBeNull();
}); });
it('should include the no-margin class on the first child', function() { it('should include the no-margin class on the first child', () => {
const firstStageColumnElement = component.$el.querySelector( const firstStageColumnElement = component.$el.querySelector(
'.stage-column-list .stage-column', '.stage-column-list .stage-column',
); );
...@@ -57,7 +59,7 @@ describe('graph component', () => { ...@@ -57,7 +59,7 @@ describe('graph component', () => {
expect(firstStageColumnElement.classList.contains('no-margin')).toEqual(true); expect(firstStageColumnElement.classList.contains('no-margin')).toEqual(true);
}); });
it('should include the has-only-one-job class on the first child', function() { it('should include the has-only-one-job class on the first child', () => {
const firstStageColumnElement = component.$el.querySelector( const firstStageColumnElement = component.$el.querySelector(
'.stage-column-list .stage-column', '.stage-column-list .stage-column',
); );
...@@ -65,7 +67,7 @@ describe('graph component', () => { ...@@ -65,7 +67,7 @@ describe('graph component', () => {
expect(firstStageColumnElement.classList.contains('has-only-one-job')).toEqual(true); expect(firstStageColumnElement.classList.contains('has-only-one-job')).toEqual(true);
}); });
it('should include the left-margin class on the second child', function() { it('should include the left-margin class on the second child', () => {
const firstStageColumnElement = component.$el.querySelector( const firstStageColumnElement = component.$el.querySelector(
'.stage-column-list .stage-column:last-child', '.stage-column-list .stage-column:last-child',
); );
...@@ -73,44 +75,96 @@ describe('graph component', () => { ...@@ -73,44 +75,96 @@ describe('graph component', () => {
expect(firstStageColumnElement.classList.contains('left-margin')).toEqual(true); expect(firstStageColumnElement.classList.contains('left-margin')).toEqual(true);
}); });
it('should include the has-linked-pipelines flag', function() { it('should include the has-linked-pipelines flag', () => {
expect(component.$el.querySelector('.has-linked-pipelines')).not.toBeNull(); expect(component.$el.querySelector('.has-linked-pipelines')).not.toBeNull();
}); });
}); });
describe('computeds and methods', function() { describe('computeds and methods', () => {
describe('capitalizeStageName', function() { describe('capitalizeStageName', () => {
it('it capitalizes the stage name', function() { it('it capitalizes the stage name', () => {
expect(component.capitalizeStageName('mystage')).toBe('Mystage'); expect(component.capitalizeStageName('mystage')).toBe('Mystage');
}); });
}); });
describe('stageConnectorClass', function() { describe('stageConnectorClass', () => {
it('it returns left-margin when there is a triggerer', function() { it('it returns left-margin when there is a triggerer', () => {
expect(component.stageConnectorClass(0, { groups: ['job'] })).toBe('no-margin'); expect(component.stageConnectorClass(0, { groups: ['job'] })).toBe('no-margin');
}); });
}); });
}); });
describe('linked pipelines components', function() { describe('linked pipelines components', () => {
it('should coerce triggeredBy into a collection', function() { it('should render an upstream pipelines column', () => {
expect(component.triggeredBy.length).toBe(1);
});
it('should render an upstream pipelines column', function() {
expect(component.$el.querySelector('.linked-pipelines-column')).not.toBeNull(); expect(component.$el.querySelector('.linked-pipelines-column')).not.toBeNull();
expect(component.$el.innerHTML).toContain('Upstream'); expect(component.$el.innerHTML).toContain('Upstream');
}); });
it('should render a downstream pipelines column', function() { it('should render a downstream pipelines column', () => {
expect(component.$el.querySelector('.linked-pipelines-column')).not.toBeNull(); expect(component.$el.querySelector('.linked-pipelines-column')).not.toBeNull();
expect(component.$el.innerHTML).toContain('Downstream'); expect(component.$el.innerHTML).toContain('Downstream');
}); });
describe('triggered by', () => {
it('should emit `onClickTriggeredBy` when triggered by linked pipeline is clicked', () => {
spyOn(component, '$emit');
component.$el.querySelector('#js-linked-pipeline-129').click();
expect(component.$emit).toHaveBeenCalledWith(
'onClickTriggeredBy',
linkedPipelineJSON.triggered_by,
);
});
describe('with expanded triggered by pipeline', () => {
it('should render expanded upstream pipeline', () => {
component = mountComponent(GraphComponent, {
isLoading: false,
pipeline: graphJSON,
triggeredByPipelines: [
Object.assign({}, linkedPipelineJSON.triggered_by, { isCollapsed: false }),
],
triggeredPipelines: linkedPipelineJSON.triggered,
triggeredBy: linkedPipelineJSON.triggered_by,
});
expect(component.$el.querySelector('.upstream-pipeline')).not.toBeNull();
});
});
});
describe('triggered ', () => {
it('should emit `onClickTriggered` when triggered linked pipeline is clicked', () => {
spyOn(component, '$emit');
component.$el.querySelector('#js-linked-pipeline-132').click();
expect(component.$emit).toHaveBeenCalledWith(
'onClickTriggered',
linkedPipelineJSON.triggered[0],
);
});
describe('with expanded triggered pipeline', () => {
it('should render expanded downstream pipeline', () => {
component = mountComponent(GraphComponent, {
isLoading: false,
pipeline: graphJSON,
triggeredByPipelines: [linkedPipelineJSON.triggered_by],
triggeredPipelines: [
Object.assign({}, linkedPipelineJSON.triggered[0], { isCollapsed: false }),
],
triggered: linkedPipelineJSON.triggered[0],
});
expect(component.$el.querySelector('.downstream-pipeline')).not.toBeNull();
});
});
});
}); });
}); });
describe('when linked pipelines are not present', function() { describe('when linked pipelines are not present', () => {
beforeEach(function() { beforeEach(() => {
const pipeline = Object.assign(graphJSON, { triggered: null, triggered_by: null }); const pipeline = Object.assign(graphJSON, { triggered: null, triggered_by: null });
component = mountComponent(GraphComponent, { component = mountComponent(GraphComponent, {
isLoading: false, isLoading: false,
...@@ -118,24 +172,24 @@ describe('graph component', () => { ...@@ -118,24 +172,24 @@ describe('graph component', () => {
}); });
}); });
describe('rendered output', function() { describe('rendered output', () => {
it('should include the first column with a no margin', function() { it('should include the first column with a no margin', () => {
const firstColumn = component.$el.querySelector('.stage-column:first-child'); const firstColumn = component.$el.querySelector('.stage-column:first-child');
expect(firstColumn.classList.contains('no-margin')).toEqual(true); expect(firstColumn.classList.contains('no-margin')).toEqual(true);
}); });
it('should not render a linked pipelines column', function() { it('should not render a linked pipelines column', () => {
expect(component.$el.querySelector('.linked-pipelines-column')).toBeNull(); expect(component.$el.querySelector('.linked-pipelines-column')).toBeNull();
}); });
}); });
describe('stageConnectorClass', function() { describe('stageConnectorClass', () => {
it('it returns left-margin when no triggerer and there is one job', function() { it('it returns left-margin when no triggerer and there is one job', () => {
expect(component.stageConnectorClass(0, { groups: ['job'] })).toBe('no-margin'); expect(component.stageConnectorClass(0, { groups: ['job'] })).toBe('no-margin');
}); });
it('it returns left-margin when no triggerer and not the first stage', function() { it('it returns left-margin when no triggerer and not the first stage', () => {
expect(component.stageConnectorClass(99, { groups: ['job'] })).toBe('left-margin'); expect(component.stageConnectorClass(99, { groups: ['job'] })).toBe('left-margin');
}); });
}); });
......
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