Commit e50538f4 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '276949-pipeline-restructure-6' into 'master'

Pipeline Graph Structural Update: Add Linked Pipeline Interactions

See merge request gitlab-org/gitlab!49164
parents 18ec1870 5afd00e1
<script> <script>
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue'; import StageColumnComponent from './stage_column_component.vue';
import { MAIN } from './constants'; import { DOWNSTREAM, MAIN, UPSTREAM } from './constants';
export default { export default {
name: 'PipelineGraph', name: 'PipelineGraph',
components: { components: {
LinkedGraphWrapper,
LinkedPipelinesColumn,
StageColumnComponent, StageColumnComponent,
}, },
props: { props: {
...@@ -23,10 +27,60 @@ export default { ...@@ -23,10 +27,60 @@ export default {
default: MAIN, default: MAIN,
}, },
}, },
pipelineTypeConstants: {
DOWNSTREAM,
UPSTREAM,
},
data() {
return {
hoveredJobName: '',
pipelineExpanded: {
jobName: '',
expanded: false,
},
};
},
computed: { computed: {
downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
},
graph() { graph() {
return this.pipeline.stages; return this.pipeline.stages;
}, },
hasDownstreamPipelines() {
return Boolean(this.pipeline?.downstream?.length > 0);
},
hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0);
},
// The two show checks prevent upstream / downstream from showing redundant linked columns
showDownstreamPipelines() {
return (
this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM
);
},
showUpstreamPipelines() {
return (
this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM
);
},
upstreamPipelines() {
return this.hasUpstreamPipelines ? this.pipeline.upstream : [];
},
},
methods: {
handleError(errorType) {
this.$emit('error', errorType);
},
setJob(jobName) {
this.hoveredJobName = jobName;
},
togglePipelineExpanded(jobName, expanded) {
this.pipelineExpanded = {
expanded,
jobName: expanded ? jobName : '',
};
},
}, },
}; };
</script> </script>
...@@ -36,13 +90,39 @@ export default { ...@@ -36,13 +90,39 @@ export default {
class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap" class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap"
:class="{ 'gl-py-5': !isLinkedPipeline }" :class="{ 'gl-py-5': !isLinkedPipeline }"
> >
<linked-graph-wrapper>
<template #upstream>
<linked-pipelines-column
v-if="showUpstreamPipelines"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
@error="handleError"
/>
</template>
<template #main>
<stage-column-component <stage-column-component
v-for="stage in graph" v-for="stage in graph"
:key="stage.name" :key="stage.name"
:title="stage.name" :title="stage.name"
:groups="stage.groups" :groups="stage.groups"
:action="stage.status.action" :action="stage.status.action"
:job-hovered="hoveredJobName"
:pipeline-expanded="pipelineExpanded"
/>
</template>
<template #downstream>
<linked-pipelines-column
v-if="showDownstreamPipelines"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
@downstreamHovered="setJob"
@pipelineExpandToggle="togglePipelineExpanded"
@error="handleError"
/> />
</template>
</linked-graph-wrapper>
</div> </div>
</div> </div>
</template> </template>
...@@ -42,7 +42,7 @@ export default { ...@@ -42,7 +42,7 @@ export default {
}; };
}, },
update(data) { update(data) {
return unwrapPipelineData(this.pipelineIid, data); return unwrapPipelineData(this.pipelineProjectPath, data);
}, },
error() { error() {
this.reportFailure(LOAD_FAILURE); this.reportFailure(LOAD_FAILURE);
...@@ -77,13 +77,11 @@ export default { ...@@ -77,13 +77,11 @@ export default {
}; };
</script> </script>
<template> <template>
<div>
<gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert"> <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
{{ alert.text }} {{ alert.text }}
</gl-alert> </gl-alert>
<gl-loading-icon <gl-loading-icon v-if="$apollo.queries.pipeline.loading" class="gl-mx-auto gl-my-4" size="lg" />
v-else-if="$apollo.queries.pipeline.loading" <pipeline-graph v-if="pipeline" :pipeline="pipeline" @error="reportFailure" />
class="gl-mx-auto gl-my-4" </div>
size="lg"
/>
<pipeline-graph v-else :pipeline="pipeline" />
</template> </template>
...@@ -25,23 +25,33 @@ export default { ...@@ -25,23 +25,33 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
pipeline: { expanded: {
type: Object, type: Boolean,
required: true, required: true,
}, },
projectId: { pipeline: {
type: Number, type: Object,
required: true, required: true,
}, },
type: { type: {
type: String, type: String,
required: true, required: true,
}, },
/*
The next two props will be removed or required
once the graph transition is done.
See: https://gitlab.com/gitlab-org/gitlab/-/issues/291043
*/
isLoading: {
type: Boolean,
required: false,
default: false,
},
projectId: {
type: Number,
required: false,
default: -1,
}, },
data() {
return {
expanded: false,
};
}, },
computed: { computed: {
tooltipText() { tooltipText() {
...@@ -74,6 +84,9 @@ export default { ...@@ -74,6 +84,9 @@ export default {
} }
return __('Multi-project'); return __('Multi-project');
}, },
pipelineIsLoading() {
return Boolean(this.isLoading || this.pipeline.isLoading);
},
isDownstream() { isDownstream() {
return this.type === DOWNSTREAM; return this.type === DOWNSTREAM;
}, },
...@@ -81,7 +94,9 @@ export default { ...@@ -81,7 +94,9 @@ export default {
return this.type === UPSTREAM; return this.type === UPSTREAM;
}, },
isSameProject() { isSameProject() {
return this.projectId === this.pipeline.project.id; return this.projectId > -1
? this.projectId === this.pipeline.project.id
: !this.pipeline.multiproject;
}, },
sourceJobName() { sourceJobName() {
return accessValue(this.dataMethod, 'sourceJob', this.pipeline); return accessValue(this.dataMethod, 'sourceJob', this.pipeline);
...@@ -101,16 +116,15 @@ export default { ...@@ -101,16 +116,15 @@ export default {
}, },
methods: { methods: {
onClickLinkedPipeline() { onClickLinkedPipeline() {
this.$root.$emit('bv::hide::tooltip', this.buttonId); this.hideTooltips();
this.expanded = !this.expanded;
this.$emit('pipelineClicked', this.$refs.linkedPipeline); this.$emit('pipelineClicked', this.$refs.linkedPipeline);
this.$emit('pipelineExpandToggle', this.pipeline.source_job.name, this.expanded); this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded);
}, },
hideTooltips() { hideTooltips() {
this.$root.$emit('bv::hide::tooltip'); this.$root.$emit('bv::hide::tooltip');
}, },
onDownstreamHovered() { onDownstreamHovered() {
this.$emit('downstreamHovered', this.pipeline.source_job.name); this.$emit('downstreamHovered', this.sourceJobName);
}, },
onDownstreamHoverLeave() { onDownstreamHoverLeave() {
this.$emit('downstreamHovered', ''); this.$emit('downstreamHovered', '');
...@@ -120,10 +134,10 @@ export default { ...@@ -120,10 +134,10 @@ export default {
</script> </script>
<template> <template>
<li <div
ref="linkedPipeline" ref="linkedPipeline"
v-gl-tooltip v-gl-tooltip
class="linked-pipeline build" class="linked-pipeline build gl-pipeline-job-width"
:title="tooltipText" :title="tooltipText"
:class="{ 'downstream-pipeline': isDownstream }" :class="{ 'downstream-pipeline': isDownstream }"
data-qa-selector="child_pipeline" data-qa-selector="child_pipeline"
...@@ -136,8 +150,9 @@ export default { ...@@ -136,8 +150,9 @@ export default {
> >
<div class="gl-display-flex"> <div class="gl-display-flex">
<ci-status <ci-status
v-if="!pipeline.isLoading" v-if="!pipelineIsLoading"
:status="pipelineStatus" :status="pipelineStatus"
:size="24"
css-classes="gl-top-0 gl-pr-2" css-classes="gl-top-0 gl-pr-2"
/> />
<div v-else class="gl-pr-2"><gl-loading-icon inline /></div> <div v-else class="gl-pr-2"><gl-loading-icon inline /></div>
...@@ -160,10 +175,10 @@ export default { ...@@ -160,10 +175,10 @@ export default {
class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!" class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
:class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`" :class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
:icon="expandedIcon" :icon="expandedIcon"
data-testid="expandPipelineButton" data-testid="expand-pipeline-button"
data-qa-selector="expand_pipeline_button" data-qa-selector="expand_pipeline_button"
@click="onClickLinkedPipeline" @click="onClickLinkedPipeline"
/> />
</div> </div>
</li> </div>
</template> </template>
<script> <script>
import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql';
import LinkedPipeline from './linked_pipeline.vue'; import LinkedPipeline from './linked_pipeline.vue';
import { LOAD_FAILURE } from '../../constants';
import { UPSTREAM } from './constants'; import { UPSTREAM } from './constants';
import { unwrapPipelineData } from './utils';
export default { export default {
components: { components: {
LinkedPipeline, LinkedPipeline,
PipelineGraph: () => import('./graph_component.vue'),
}, },
props: { props: {
columnTitle: { columnTitle: {
...@@ -19,11 +23,22 @@ export default { ...@@ -19,11 +23,22 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectId: {
type: Number,
required: true,
}, },
data() {
return {
currentPipeline: null,
loadingPipelineId: null,
pipelineExpanded: false,
};
}, },
titleClasses: [
'gl-font-weight-bold',
'gl-pipeline-job-width',
'gl-text-truncate',
'gl-line-height-36',
'gl-pl-3',
'gl-mb-5',
],
computed: { computed: {
columnClass() { columnClass() {
const positionValues = { const positionValues = {
...@@ -35,14 +50,66 @@ export default { ...@@ -35,14 +50,66 @@ export default {
graphPosition() { graphPosition() {
return this.isUpstream ? 'left' : 'right'; return this.isUpstream ? 'left' : 'right';
}, },
// Refactor string match when BE returns Upstream/Downstream indicators
isUpstream() { isUpstream() {
return this.type === UPSTREAM; return this.type === UPSTREAM;
}, },
computedTitleClasses() {
const positionalClasses = this.isUpstream
? ['gl-w-full', 'gl-text-right', 'gl-linked-pipeline-padding']
: [];
return [...this.$options.titleClasses, ...positionalClasses];
},
}, },
methods: { methods: {
onPipelineClick(downstreamNode, pipeline, index) { getPipelineData(pipeline) {
this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); const projectPath = pipeline.project.fullPath;
this.$apollo.addSmartQuery('currentPipeline', {
query: getPipelineDetails,
variables() {
return {
projectPath,
iid: pipeline.iid,
};
},
update(data) {
return unwrapPipelineData(projectPath, data);
},
result() {
this.loadingPipelineId = null;
},
error() {
this.$emit('error', LOAD_FAILURE);
},
});
},
isExpanded(id) {
return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id);
},
isLoadingPipeline(id) {
return this.loadingPipelineId === id;
},
onPipelineClick(pipeline) {
/* If the clicked pipeline has been expanded already, close it, clear, exit */
if (this.currentPipeline?.id === pipeline.id) {
this.pipelineExpanded = false;
this.currentPipeline = null;
return;
}
/* Set the loading id */
this.loadingPipelineId = pipeline.id;
/*
Expand the pipeline.
If this was not a toggle close action, and
it was already showing a different pipeline, then
this will be a no-op, but that doesn't matter.
*/
this.pipelineExpanded = true;
this.getPipelineData(pipeline);
}, },
onDownstreamHovered(jobName) { onDownstreamHovered(jobName) {
this.$emit('downstreamHovered', jobName); this.$emit('downstreamHovered', jobName);
...@@ -60,25 +127,40 @@ export default { ...@@ -60,25 +127,40 @@ export default {
</script> </script>
<template> <template>
<div :class="columnClass" class="stage-column linked-pipelines-column"> <div class="gl-display-flex">
<div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> <div :class="columnClass" class="linked-pipelines-column">
<div v-if="isUpstream" class="cross-project-triangle"></div> <div data-testid="linked-column-title" class="stage-name" :class="computedTitleClasses">
<ul> {{ columnTitle }}
<linked-pipeline </div>
v-for="(pipeline, index) in linkedPipelines" <ul class="gl-pl-0">
<li
v-for="pipeline in linkedPipelines"
:key="pipeline.id" :key="pipeline.id"
:class="{ class="gl-display-flex gl-mb-4"
active: pipeline.isExpanded, :class="{ 'gl-flex-direction-row-reverse': isUpstream }"
'left-connector': pipeline.isExpanded && graphPosition === 'left', >
}" <linked-pipeline
class="gl-display-inline-block"
:is-loading="isLoadingPipeline(pipeline.id)"
:pipeline="pipeline" :pipeline="pipeline"
:column-title="columnTitle" :column-title="columnTitle"
:project-id="projectId"
:type="type" :type="type"
@pipelineClicked="onPipelineClick($event, pipeline, index)" :expanded="isExpanded(pipeline.id)"
@downstreamHovered="onDownstreamHovered" @downstreamHovered="onDownstreamHovered"
@pipelineClicked="onPipelineClick(pipeline)"
@pipelineExpandToggle="onPipelineExpandToggle" @pipelineExpandToggle="onPipelineExpandToggle"
/> />
<div v-if="isExpanded(pipeline.id)" class="gl-display-inline-block">
<pipeline-graph
v-if="currentPipeline"
:type="type"
class="d-inline-block gl-mt-n2"
:pipeline="currentPipeline"
:is-linked-pipeline="true"
/>
</div>
</li>
</ul> </ul>
</div> </div>
</div>
</template> </template>
...@@ -35,7 +35,9 @@ export default { ...@@ -35,7 +35,9 @@ export default {
graphPosition() { graphPosition() {
return this.isUpstream ? 'left' : 'right'; return this.isUpstream ? 'left' : 'right';
}, },
// Refactor string match when BE returns Upstream/Downstream indicators isExpanded() {
return this.pipeline?.isExpanded || false;
},
isUpstream() { isUpstream() {
return this.type === UPSTREAM; return this.type === UPSTREAM;
}, },
...@@ -64,9 +66,8 @@ export default { ...@@ -64,9 +66,8 @@ export default {
<div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
<div v-if="isUpstream" class="cross-project-triangle"></div> <div v-if="isUpstream" class="cross-project-triangle"></div>
<ul> <ul>
<li v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id">
<linked-pipeline <linked-pipeline
v-for="(pipeline, index) in linkedPipelines"
:key="pipeline.id"
:class="{ :class="{
active: pipeline.isExpanded, active: pipeline.isExpanded,
'left-connector': pipeline.isExpanded && graphPosition === 'left', 'left-connector': pipeline.isExpanded && graphPosition === 'left',
...@@ -75,10 +76,12 @@ export default { ...@@ -75,10 +76,12 @@ export default {
:column-title="columnTitle" :column-title="columnTitle"
:project-id="projectId" :project-id="projectId"
:type="type" :type="type"
:expanded="isExpanded"
@pipelineClicked="onPipelineClick($event, pipeline, index)" @pipelineClicked="onPipelineClick($event, pipeline, index)"
@downstreamHovered="onDownstreamHovered" @downstreamHovered="onDownstreamHovered"
@pipelineExpandToggle="onPipelineExpandToggle" @pipelineExpandToggle="onPipelineExpandToggle"
/> />
</li>
</ul> </ul>
</div> </div>
</template> </template>
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { unwrapStagesWithNeeds } from '../unwrapping_utils'; import { unwrapStagesWithNeeds } from '../unwrapping_utils';
const addMulti = (mainId, pipeline) => { const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
return { ...pipeline, multiproject: mainId !== pipeline.id }; return {
...linkedPipeline,
multiproject: mainPipelineProjectPath !== linkedPipeline.project.fullPath,
};
}; };
const unwrapPipelineData = (mainPipelineId, data) => { const transformId = linkedPipeline => {
return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) };
};
const unwrapPipelineData = (mainPipelineProjectPath, data) => {
if (!data?.project?.pipeline) { if (!data?.project?.pipeline) {
return null; return null;
} }
const { pipeline } = data.project;
const { const {
id,
upstream, upstream,
downstream, downstream,
stages: { nodes: stages }, stages: { nodes: stages },
} = data.project.pipeline; } = pipeline;
const nodes = unwrapStagesWithNeeds(stages); const nodes = unwrapStagesWithNeeds(stages);
return { return {
id, ...pipeline,
id: getIdFromGraphQLId(pipeline.id),
stages: nodes, stages: nodes,
upstream: upstream ? [upstream].map(addMulti.bind(null, mainPipelineId)) : [], upstream: upstream
downstream: downstream ? downstream.map(addMulti.bind(null, mainPipelineId)) : [], ? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
: [],
downstream: downstream
? downstream.nodes.map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
: [],
}; };
}; };
......
<template>
<div class="gl-display-flex">
<slot name="upstream"></slot>
<slot name="main"></slot>
<slot name="downstream"></slot>
</div>
</template>
...@@ -17,7 +17,7 @@ export default { ...@@ -17,7 +17,7 @@ export default {
<template> <template>
<div> <div>
<div <div
class="gl-display-flex gl-align-items-center gl-w-full gl-px-8 gl-py-4 gl-mb-5" class="gl-display-flex gl-align-items-center gl-w-full gl-px-8 gl-mb-5"
:class="stageClasses" :class="stageClasses"
> >
<slot name="stages"> </slot> <slot name="stages"> </slot>
......
fragment LinkedPipelineData on Pipeline {
id
iid
path
status: detailedStatus {
group
label
icon
}
sourceJob {
name
}
project {
name
fullPath
}
}
#import "../fragments/linked_pipelines.fragment.graphql"
query getPipelineDetails($projectPath: ID!, $iid: ID!) { query getPipelineDetails($projectPath: ID!, $iid: ID!) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
pipeline(iid: $iid) { pipeline(iid: $iid) {
id: iid id
iid
downstream {
nodes {
...LinkedPipelineData
}
}
upstream {
...LinkedPipelineData
}
stages { stages {
nodes { nodes {
name name
......
...@@ -2,6 +2,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { ...@@ -2,6 +2,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
pipeline(iid: $iid) { pipeline(iid: $iid) {
id id
iid
status status
retryable retryable
cancelable cancelable
......
...@@ -139,6 +139,10 @@ ...@@ -139,6 +139,10 @@
width: 186px; width: 186px;
} }
.gl-linked-pipeline-padding {
padding-right: 120px;
}
.gl-build-content { .gl-build-content {
@include build-content(); @include build-content();
} }
......
...@@ -15,8 +15,8 @@ describe('graph component', () => { ...@@ -15,8 +15,8 @@ describe('graph component', () => {
let mediator; let mediator;
let wrapper; let wrapper;
const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]'); const findExpandPipelineBtn = () => wrapper.find('[data-testid="expand-pipeline-button"]');
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]'); const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expand-pipeline-button"]');
const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy); const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy);
const findStageColumnAt = i => findStageColumns().at(i); const findStageColumnAt = i => findStageColumns().at(i);
......
import { shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; import { GRAPHQL } from '~/pipelines/components/graph/constants';
import { mockPipelineResponse } from './mock_data'; import {
generateResponse,
mockPipelineResponse,
pipelineWithUpstreamDownstream,
} from './mock_data';
describe('graph component', () => { describe('graph component', () => {
let wrapper; let wrapper;
...@@ -11,10 +15,8 @@ describe('graph component', () => { ...@@ -11,10 +15,8 @@ describe('graph component', () => {
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findStageColumns = () => wrapper.findAll(StageColumnComponent); const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const generateResponse = raw => unwrapPipelineData(raw.data.project.pipeline.id, raw.data);
const defaultProps = { const defaultProps = {
pipeline: generateResponse(mockPipelineResponse), pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
}; };
const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => {
...@@ -23,6 +25,9 @@ describe('graph component', () => { ...@@ -23,6 +25,9 @@ describe('graph component', () => {
...defaultProps, ...defaultProps,
...props, ...props,
}, },
provide: {
dataMethod: GRAPHQL,
},
}); });
}; };
...@@ -33,7 +38,7 @@ describe('graph component', () => { ...@@ -33,7 +38,7 @@ describe('graph component', () => {
describe('with data', () => { describe('with data', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent({ mountFn: mount });
}); });
it('renders the main columns in the graph', () => { it('renders the main columns in the graph', () => {
...@@ -43,11 +48,24 @@ describe('graph component', () => { ...@@ -43,11 +48,24 @@ describe('graph component', () => {
describe('when linked pipelines are not present', () => { describe('when linked pipelines are not present', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent({ mountFn: mount });
}); });
it('should not render a linked pipelines column', () => { it('should not render a linked pipelines column', () => {
expect(findLinkedColumns()).toHaveLength(0); expect(findLinkedColumns()).toHaveLength(0);
}); });
}); });
describe('when linked pipelines are present', () => {
beforeEach(() => {
createComponent({
mountFn: mount,
props: { pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse) },
});
});
it('should render linked pipelines columns', () => {
expect(findLinkedColumns()).toHaveLength(2);
});
});
}); });
...@@ -17,7 +17,7 @@ describe('Linked pipeline', () => { ...@@ -17,7 +17,7 @@ describe('Linked pipeline', () => {
const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]'); const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]');
const findExpandButton = () => wrapper.find('[data-testid="expandPipelineButton"]'); const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]');
const createWrapper = (propsData, data = []) => { const createWrapper = (propsData, data = []) => {
wrapper = mount(LinkedPipelineComponent, { wrapper = mount(LinkedPipelineComponent, {
...@@ -40,20 +40,13 @@ describe('Linked pipeline', () => { ...@@ -40,20 +40,13 @@ describe('Linked pipeline', () => {
projectId: invalidTriggeredPipelineId, projectId: invalidTriggeredPipelineId,
columnTitle: 'Downstream', columnTitle: 'Downstream',
type: DOWNSTREAM, type: DOWNSTREAM,
expanded: false,
}; };
beforeEach(() => { beforeEach(() => {
createWrapper(props); createWrapper(props);
}); });
it('should render a list item as the containing element', () => {
expect(wrapper.element.tagName).toBe('LI');
});
it('should render a button', () => {
expect(findButton().exists()).toBe(true);
});
it('should render the project name', () => { it('should render the project name', () => {
expect(wrapper.text()).toContain(props.pipeline.project.name); expect(wrapper.text()).toContain(props.pipeline.project.name);
}); });
...@@ -105,12 +98,14 @@ describe('Linked pipeline', () => { ...@@ -105,12 +98,14 @@ describe('Linked pipeline', () => {
projectId: validTriggeredPipelineId, projectId: validTriggeredPipelineId,
columnTitle: 'Downstream', columnTitle: 'Downstream',
type: DOWNSTREAM, type: DOWNSTREAM,
expanded: false,
}; };
const upstreamProps = { const upstreamProps = {
...downstreamProps, ...downstreamProps,
columnTitle: 'Upstream', columnTitle: 'Upstream',
type: UPSTREAM, type: UPSTREAM,
expanded: false,
}; };
it('parent/child label container should exist', () => { it('parent/child label container should exist', () => {
...@@ -173,7 +168,7 @@ describe('Linked pipeline', () => { ...@@ -173,7 +168,7 @@ describe('Linked pipeline', () => {
`( `(
'$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded', '$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded',
({ pipelineType, anglePosition, expanded }) => { ({ pipelineType, anglePosition, expanded }) => {
createWrapper(pipelineType, { expanded }); createWrapper({ ...pipelineType, expanded });
expect(findExpandButton().props('icon')).toBe(anglePosition); expect(findExpandButton().props('icon')).toBe(anglePosition);
}, },
); );
...@@ -185,6 +180,7 @@ describe('Linked pipeline', () => { ...@@ -185,6 +180,7 @@ describe('Linked pipeline', () => {
projectId: invalidTriggeredPipelineId, projectId: invalidTriggeredPipelineId,
columnTitle: 'Downstream', columnTitle: 'Downstream',
type: DOWNSTREAM, type: DOWNSTREAM,
expanded: false,
}; };
beforeEach(() => { beforeEach(() => {
...@@ -202,6 +198,7 @@ describe('Linked pipeline', () => { ...@@ -202,6 +198,7 @@ describe('Linked pipeline', () => {
projectId: validTriggeredPipelineId, projectId: validTriggeredPipelineId,
columnTitle: 'Downstream', columnTitle: 'Downstream',
type: DOWNSTREAM, type: DOWNSTREAM,
expanded: false,
}; };
beforeEach(() => { beforeEach(() => {
...@@ -219,10 +216,7 @@ describe('Linked pipeline', () => { ...@@ -219,10 +216,7 @@ describe('Linked pipeline', () => {
jest.spyOn(wrapper.vm.$root, '$emit'); jest.spyOn(wrapper.vm.$root, '$emit');
findButton().trigger('click'); findButton().trigger('click');
expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual([ expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual(['bv::hide::tooltip']);
'bv::hide::tooltip',
'js-linked-pipeline-34993051',
]);
}); });
it('should emit downstreamHovered with job name on mouseover', () => { it('should emit downstreamHovered with job name on mouseover', () => {
......
import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue';
import { UPSTREAM } from '~/pipelines/components/graph/constants'; import getPipelineDetails from '~/pipelines/graphql/queries/get_pipeline_details.query.graphql';
import mockData from './linked_pipelines_mock_data'; import { DOWNSTREAM, GRAPHQL } from '~/pipelines/components/graph/constants';
import { LOAD_FAILURE } from '~/pipelines/constants';
import {
mockPipelineResponse,
pipelineWithUpstreamDownstream,
wrappedPipelineReturn,
} from './mock_data';
const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse);
describe('Linked Pipelines Column', () => { describe('Linked Pipelines Column', () => {
const propsData = { const defaultProps = {
columnTitle: 'Upstream', columnTitle: 'Upstream',
linkedPipelines: mockData.triggered, linkedPipelines: processedPipeline.downstream,
graphPosition: 'right', type: DOWNSTREAM,
projectId: 19,
type: UPSTREAM,
}; };
let wrapper; let wrapper;
const findLinkedColumnTitle = () => wrapper.find('[data-testid="linked-column-title"]');
const findLinkedPipelineElements = () => wrapper.findAll(LinkedPipeline);
const findPipelineGraph = () => wrapper.find(PipelineGraph);
const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]');
beforeEach(() => { const localVue = createLocalVue();
wrapper = shallowMount(LinkedPipelinesColumn, { propsData }); localVue.use(VueApollo);
const createComponent = ({ apolloProvider, mountFn = shallowMount, props = {} } = {}) => {
wrapper = mountFn(LinkedPipelinesColumn, {
apolloProvider,
localVue,
propsData: {
...defaultProps,
...props,
},
provide: {
dataMethod: GRAPHQL,
},
}); });
};
const createComponentWithApollo = (
mountFn = shallowMount,
getPipelineDetailsHandler = jest.fn().mockResolvedValue(wrappedPipelineReturn),
) => {
const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]];
const apolloProvider = createMockApollo(requestHandlers);
createComponent({ apolloProvider, mountFn });
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
it('renders the pipeline orientation', () => { describe('it renders correctly', () => {
const titleElement = wrapper.find('.linked-pipelines-column-title'); beforeEach(() => {
createComponent();
});
expect(titleElement.text()).toBe(propsData.columnTitle); it('renders the pipeline title', () => {
expect(findLinkedColumnTitle().text()).toBe(defaultProps.columnTitle);
}); });
it('renders the correct number of linked pipelines', () => { it('renders the correct number of linked pipelines', () => {
const linkedPipelineElements = wrapper.findAll(LinkedPipeline); expect(findLinkedPipelineElements()).toHaveLength(defaultProps.linkedPipelines.length);
});
});
describe('click action', () => {
const clickExpandButton = async () => {
await findExpandButton().trigger('click');
await wrapper.vm.$nextTick();
};
expect(linkedPipelineElements.length).toBe(propsData.linkedPipelines.length); const clickExpandButtonAndAwaitTimers = async () => {
await clickExpandButton();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
};
describe('when successful', () => {
beforeEach(() => {
createComponentWithApollo(mount);
}); });
it('renders cross project triangle when column is upstream', () => { it('toggles the pipeline visibility', async () => {
expect(wrapper.find('.cross-project-triangle').exists()).toBe(true); expect(findPipelineGraph().exists()).toBe(false);
await clickExpandButtonAndAwaitTimers();
expect(findPipelineGraph().exists()).toBe(true);
await clickExpandButton();
expect(findPipelineGraph().exists()).toBe(false);
});
});
describe('on error', () => {
beforeEach(() => {
createComponentWithApollo(mount, jest.fn().mockRejectedValue(new Error('GraphQL error')));
});
it('emits the error', async () => {
await clickExpandButton();
expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]);
});
it('does not show the pipeline', async () => {
expect(findPipelineGraph().exists()).toBe(false);
await clickExpandButtonAndAwaitTimers();
expect(findPipelineGraph().exists()).toBe(false);
});
});
}); });
}); });
import { unwrapPipelineData } from '~/pipelines/components/graph/utils';
export const mockPipelineResponse = { export const mockPipelineResponse = {
data: { data: {
project: { project: {
__typename: 'Project', __typename: 'Project',
pipeline: { pipeline: {
__typename: 'Pipeline', __typename: 'Pipeline',
id: '22', id: 163,
iid: '22',
downstream: null,
upstream: null,
stages: { stages: {
__typename: 'CiStageConnection', __typename: 'CiStageConnection',
nodes: [ nodes: [
...@@ -497,3 +502,164 @@ export const mockPipelineResponse = { ...@@ -497,3 +502,164 @@ export const mockPipelineResponse = {
}, },
}, },
}; };
export const downstream = {
nodes: [
{
id: 175,
iid: '31',
path: '/root/elemenohpee/-/pipelines/175',
status: {
group: 'success',
label: 'passed',
icon: 'status_success',
__typename: 'DetailedStatus',
},
sourceJob: {
name: 'test_c',
__typename: 'CiJob',
},
project: {
id: 'gid://gitlab/Project/25',
name: 'elemenohpee',
fullPath: 'root/elemenohpee',
__typename: 'Project',
},
__typename: 'Pipeline',
multiproject: true,
},
{
id: 181,
iid: '27',
path: '/root/abcd-dag/-/pipelines/181',
status: {
group: 'success',
label: 'passed',
icon: 'status_success',
__typename: 'DetailedStatus',
},
sourceJob: {
name: 'test_d',
__typename: 'CiJob',
},
project: {
id: 'gid://gitlab/Project/23',
name: 'abcd-dag',
fullPath: 'root/abcd-dag',
__typename: 'Project',
},
__typename: 'Pipeline',
multiproject: false,
},
],
};
export const upstream = {
id: 161,
iid: '24',
path: '/root/abcd-dag/-/pipelines/161',
status: {
group: 'success',
label: 'passed',
icon: 'status_success',
__typename: 'DetailedStatus',
},
sourceJob: null,
project: {
id: 'gid://gitlab/Project/23',
name: 'abcd-dag',
fullPath: 'root/abcd-dag',
__typename: 'Project',
},
__typename: 'Pipeline',
multiproject: true,
};
export const wrappedPipelineReturn = {
data: {
project: {
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/175',
iid: '38',
downstream: {
nodes: [],
},
upstream: {
id: 'gid://gitlab/Ci::Pipeline/174',
iid: '37',
path: '/root/elemenohpee/-/pipelines/174',
status: {
group: 'success',
label: 'passed',
icon: 'status_success',
},
sourceJob: {
name: 'test_c',
},
project: {
id: 'gid://gitlab/Project/25',
name: 'elemenohpee',
fullPath: 'root/elemenohpee',
},
},
stages: {
nodes: [
{
name: 'build',
status: {
action: null,
},
groups: {
nodes: [
{
status: {
label: 'passed',
group: 'success',
icon: 'status_success',
},
name: 'build_n',
size: 1,
jobs: {
nodes: [
{
name: 'build_n',
scheduledAt: null,
needs: {
nodes: [],
},
status: {
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
detailsPath: '/root/elemenohpee/-/jobs/1662',
group: 'success',
action: {
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/elemenohpee/-/jobs/1662/retry',
title: 'Retry',
},
},
},
],
},
},
],
},
},
],
},
},
},
},
};
export const generateResponse = (raw, mockPath) => unwrapPipelineData(mockPath, raw.data);
export const pipelineWithUpstreamDownstream = base => {
const pip = { ...base };
pip.data.project.pipeline.downstream = downstream;
pip.data.project.pipeline.upstream = upstream;
return generateResponse(pip, 'root/abcd-dag');
};
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