Commit 65b8c08d authored by Payton Burdette's avatar Payton Burdette Committed by Sarah Groff Hennigh-Palermo

Refactor downstream pipeline viz

Change styling, add naviagation
to child pipelines, change expand
button. Add test coverage.
parent 63a26a05
...@@ -44,6 +44,10 @@ export default { ...@@ -44,6 +44,10 @@ export default {
return { return {
downstreamMarginTop: null, downstreamMarginTop: null,
jobName: null, jobName: null,
pipelineExpanded: {
jobName: '',
expanded: false,
},
}; };
}, },
computed: { computed: {
...@@ -120,6 +124,19 @@ export default { ...@@ -120,6 +124,19 @@ export default {
setJob(jobName) { setJob(jobName) {
this.jobName = jobName; this.jobName = jobName;
}, },
setPipelineExpanded(jobName, expanded) {
if (expanded) {
this.pipelineExpanded = {
jobName,
expanded,
};
} else {
this.pipelineExpanded = {
expanded,
jobName: '',
};
}
},
}, },
}; };
</script> </script>
...@@ -181,6 +198,7 @@ export default { ...@@ -181,6 +198,7 @@ export default {
:has-triggered-by="hasTriggeredBy" :has-triggered-by="hasTriggeredBy"
:action="stage.status.action" :action="stage.status.action"
:job-hovered="jobName" :job-hovered="jobName"
:pipeline-expanded="pipelineExpanded"
@refreshPipelineGraph="refreshPipelineGraph" @refreshPipelineGraph="refreshPipelineGraph"
/> />
</ul> </ul>
...@@ -193,6 +211,7 @@ export default { ...@@ -193,6 +211,7 @@ export default {
graph-position="right" graph-position="right"
@linkedPipelineClick="handleClickedDownstream" @linkedPipelineClick="handleClickedDownstream"
@downstreamHovered="setJob" @downstreamHovered="setJob"
@pipelineExpandToggle="setPipelineExpanded"
/> />
<pipeline-graph <pipeline-graph
......
...@@ -31,7 +31,7 @@ import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; ...@@ -31,7 +31,7 @@ import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
*/ */
export default { export default {
hoverClass: 'gl-inset-border-1-blue-500', hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
components: { components: {
ActionComponent, ActionComponent,
JobNameComponent, JobNameComponent,
...@@ -61,6 +61,11 @@ export default { ...@@ -61,6 +61,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
pipelineExpanded: {
type: Object,
required: false,
default: () => ({}),
},
}, },
computed: { computed: {
boundary() { boundary() {
...@@ -101,8 +106,14 @@ export default { ...@@ -101,8 +106,14 @@ export default {
hasAction() { hasAction() {
return this.job.status && this.job.status.action && this.job.status.action.path; return this.job.status && this.job.status.action && this.job.status.action.path;
}, },
relatedDownstreamHovered() {
return this.job.name === this.jobHovered;
},
relatedDownstreamExpanded() {
return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
},
jobClasses() { jobClasses() {
return this.job.name === this.jobHovered return this.relatedDownstreamHovered || this.relatedDownstreamExpanded
? `${this.$options.hoverClass} ${this.cssClassJobName}` ? `${this.$options.hoverClass} ${this.cssClassJobName}`
: this.cssClassJobName; : this.cssClassJobName;
}, },
......
<script> <script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui'; import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
import CiStatus from '~/vue_shared/components/ci_icon.vue'; import CiStatus from '~/vue_shared/components/ci_icon.vue';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
...@@ -10,6 +10,8 @@ export default { ...@@ -10,6 +10,8 @@ export default {
components: { components: {
CiStatus, CiStatus,
GlButton, GlButton,
GlLink,
GlLoadingIcon,
}, },
props: { props: {
pipeline: { pipeline: {
...@@ -25,6 +27,11 @@ export default { ...@@ -25,6 +27,11 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
expanded: false,
};
},
computed: { computed: {
tooltipText() { tooltipText() {
return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label}
...@@ -66,11 +73,22 @@ export default { ...@@ -66,11 +73,22 @@ export default {
? sprintf(__('Created by %{job}'), { job: this.pipeline.source_job.name }) ? sprintf(__('Created by %{job}'), { job: this.pipeline.source_job.name })
: ''; : '';
}, },
expandedIcon() {
if (this.parentPipeline) {
return this.expanded ? 'angle-right' : 'angle-left';
}
return this.expanded ? 'angle-left' : 'angle-right';
},
expandButtonPosition() {
return this.parentPipeline ? 'gl-left-0 gl-border-r-1!' : 'gl-right-0 gl-border-l-1!';
},
}, },
methods: { methods: {
onClickLinkedPipeline() { onClickLinkedPipeline() {
this.$root.$emit('bv::hide::tooltip', this.buttonId); this.$root.$emit('bv::hide::tooltip', this.buttonId);
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);
}, },
hideTooltips() { hideTooltips() {
this.$root.$emit('bv::hide::tooltip'); this.$root.$emit('bv::hide::tooltip');
...@@ -88,27 +106,53 @@ export default { ...@@ -88,27 +106,53 @@ export default {
<template> <template>
<li <li
ref="linkedPipeline" ref="linkedPipeline"
v-gl-tooltip
class="linked-pipeline build" class="linked-pipeline build"
:title="tooltipText"
:class="{ 'downstream-pipeline': isDownstream }" :class="{ 'downstream-pipeline': isDownstream }"
data-qa-selector="child_pipeline" data-qa-selector="child_pipeline"
@mouseover="onDownstreamHovered" @mouseover="onDownstreamHovered"
@mouseleave="onDownstreamHoverLeave" @mouseleave="onDownstreamHoverLeave"
> >
<gl-button <div
:id="buttonId" class="gl-relative gl-bg-white gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1"
v-gl-tooltip :class="{ 'gl-pl-9': parentPipeline }"
:title="tooltipText"
class="linked-pipeline-content"
data-qa-selector="linked_pipeline_button"
:class="`js-pipeline-expand-${pipeline.id}`"
:loading="pipeline.isLoading"
@click="onClickLinkedPipeline"
> >
<ci-status v-if="!pipeline.isLoading" :status="pipelineStatus" css-classes="gl-top-0" /> <div class="gl-display-flex">
<span class="str-truncated"> {{ downstreamTitle }} &#8226; #{{ pipeline.id }} </span> <ci-status
v-if="!pipeline.isLoading"
:status="pipelineStatus"
css-classes="gl-top-0 gl-pr-2"
/>
<div v-else class="gl-pr-2"><gl-loading-icon inline /></div>
<div class="gl-display-flex gl-flex-direction-column gl-w-13">
<span class="gl-text-truncate">
{{ downstreamTitle }}
</span>
<div class="gl-text-truncate">
<gl-link
v-if="childPipeline"
class="gl-text-blue-500!"
:href="pipeline.path"
data-testid="childPipelineLink"
>#{{ pipeline.id }}</gl-link
>
<span v-else>#{{ pipeline.id }}</span>
</div>
</div>
</div>
<div class="gl-pt-2"> <div class="gl-pt-2">
<span class="badge badge-primary" data-testid="downstream-pipeline-label">{{ label }}</span> <span class="badge badge-primary" data-testid="downstream-pipeline-label">{{ label }}</span>
</div> </div>
</gl-button> <gl-button
:id="buttonId"
class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
:class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
:icon="expandedIcon"
data-testid="expandPipelineButton"
data-qa-selector="linked_pipeline_button"
@click="onClickLinkedPipeline"
/>
</div>
</li> </li>
</template> </template>
...@@ -44,6 +44,9 @@ export default { ...@@ -44,6 +44,9 @@ export default {
onDownstreamHovered(jobName) { onDownstreamHovered(jobName) {
this.$emit('downstreamHovered', jobName); this.$emit('downstreamHovered', jobName);
}, },
onPipelineExpandToggle(jobName, expanded) {
this.$emit('pipelineExpandToggle', jobName, expanded);
},
}, },
}; };
</script> </script>
...@@ -65,6 +68,7 @@ export default { ...@@ -65,6 +68,7 @@ export default {
:project-id="projectId" :project-id="projectId"
@pipelineClicked="onPipelineClick($event, pipeline, index)" @pipelineClicked="onPipelineClick($event, pipeline, index)"
@downstreamHovered="onDownstreamHovered" @downstreamHovered="onDownstreamHovered"
@pipelineExpandToggle="onPipelineExpandToggle"
/> />
</ul> </ul>
</div> </div>
......
...@@ -41,6 +41,11 @@ export default { ...@@ -41,6 +41,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
pipelineExpanded: {
type: Object,
required: false,
default: () => ({}),
},
}, },
computed: { computed: {
hasAction() { hasAction() {
...@@ -86,6 +91,7 @@ export default { ...@@ -86,6 +91,7 @@ export default {
v-if="group.size === 1" v-if="group.size === 1"
:job="group.jobs[0]" :job="group.jobs[0]"
:job-hovered="jobHovered" :job-hovered="jobHovered"
:pipeline-expanded="pipelineExpanded"
css-class-job-name="build-content" css-class-job-name="build-content"
@pipelineActionRequestComplete="pipelineActionRequestComplete" @pipelineActionRequestComplete="pipelineActionRequestComplete"
/> />
......
...@@ -119,3 +119,7 @@ ...@@ -119,3 +119,7 @@
width: auto !important; width: auto !important;
} }
} }
.gl-shadow-x0-y0-b3-s1-blue-500 {
box-shadow: inset 0 0 3px $gl-border-size-1 $blue-500;
}
---
title: Improve ability to navigate to child pipelines
merge_request: 40650
author:
type: added
...@@ -164,7 +164,7 @@ ...@@ -164,7 +164,7 @@
} }
&.downstream-pipeline { &.downstream-pipeline {
height: 68px; height: 86px;
} }
.linked-pipeline-content { .linked-pipeline-content {
......
...@@ -16,6 +16,9 @@ describe('graph component', () => { ...@@ -16,6 +16,9 @@ describe('graph component', () => {
let wrapper; let wrapper;
const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]');
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]');
beforeEach(() => { beforeEach(() => {
setHTMLFixture('<div class="layout-page"></div>'); setHTMLFixture('<div class="layout-page"></div>');
}); });
...@@ -167,7 +170,7 @@ describe('graph component', () => { ...@@ -167,7 +170,7 @@ describe('graph component', () => {
describe('triggered by', () => { describe('triggered by', () => {
describe('on click', () => { describe('on click', () => {
it('should emit `onClickTriggeredBy` when triggered by linked pipeline is clicked', () => { it('should emit `onClickTriggeredBy` when triggered by linked pipeline is clicked', () => {
const btnWrapper = wrapper.find('.linked-pipeline-content'); const btnWrapper = findExpandPipelineBtn();
btnWrapper.trigger('click'); btnWrapper.trigger('click');
...@@ -213,7 +216,7 @@ describe('graph component', () => { ...@@ -213,7 +216,7 @@ describe('graph component', () => {
), ),
}); });
const btnWrappers = wrapper.findAll('.linked-pipeline-content'); const btnWrappers = findAllExpandPipelineBtns();
const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1); const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1);
downstreamBtnWrapper.trigger('click'); downstreamBtnWrapper.trigger('click');
......
...@@ -13,6 +13,7 @@ describe('pipeline graph job item', () => { ...@@ -13,6 +13,7 @@ describe('pipeline graph job item', () => {
}); });
}; };
const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500';
const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const delayedJobFixture = getJSONFixture('jobs/delayed.json');
const mockJob = { const mockJob = {
id: 4256, id: 4256,
...@@ -33,6 +34,18 @@ describe('pipeline graph job item', () => { ...@@ -33,6 +34,18 @@ describe('pipeline graph job item', () => {
}, },
}, },
}; };
const mockJobWithoutDetails = {
id: 4257,
name: 'test',
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
details_path: '/root/ci-mock/builds/4257',
has_details: false,
},
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -61,18 +74,7 @@ describe('pipeline graph job item', () => { ...@@ -61,18 +74,7 @@ describe('pipeline graph job item', () => {
describe('name without link', () => { describe('name without link', () => {
beforeEach(() => { beforeEach(() => {
createWrapper({ createWrapper({
job: { job: mockJobWithoutDetails,
id: 4257,
name: 'test',
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
details_path: '/root/ci-mock/builds/4257',
has_details: false,
},
},
cssClassJobName: 'css-class-job-name', cssClassJobName: 'css-class-job-name',
jobHovered: 'test', jobHovered: 'test',
}); });
...@@ -86,7 +88,7 @@ describe('pipeline graph job item', () => { ...@@ -86,7 +88,7 @@ describe('pipeline graph job item', () => {
}); });
it('should apply hover class and provided class name', () => { it('should apply hover class and provided class name', () => {
expect(findJobWithoutLink().classes()).toContain('gl-inset-border-1-blue-500'); expect(findJobWithoutLink().classes()).toContain(triggerActiveClass);
expect(findJobWithoutLink().classes()).toContain('css-class-job-name'); expect(findJobWithoutLink().classes()).toContain('css-class-job-name');
}); });
}); });
...@@ -154,4 +156,24 @@ describe('pipeline graph job item', () => { ...@@ -154,4 +156,24 @@ describe('pipeline graph job item', () => {
); );
}); });
}); });
describe('trigger job highlighting', () => {
it('trigger job should stay highlighted when downstream is expanded', () => {
createWrapper({
job: mockJobWithoutDetails,
pipelineExpanded: { jobName: mockJob.name, expanded: true },
});
expect(findJobWithoutLink().classes()).toContain(triggerActiveClass);
});
it('trigger job should not be highlighted when downstream is closed', () => {
createWrapper({
job: mockJobWithoutDetails,
pipelineExpanded: { jobName: mockJob.name, expanded: false },
});
expect(findJobWithoutLink().classes()).not.toContain(triggerActiveClass);
});
});
}); });
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui'; import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue';
import CiStatus from '~/vue_shared/components/ci_icon.vue'; import CiStatus from '~/vue_shared/components/ci_icon.vue';
...@@ -16,10 +16,18 @@ describe('Linked pipeline', () => { ...@@ -16,10 +16,18 @@ describe('Linked pipeline', () => {
const findButton = () => wrapper.find(GlButton); const findButton = () => wrapper.find(GlButton);
const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]'); const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]');
const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findPipelineLink = () => wrapper.find('[data-testid="childPipelineLink"]');
const findExpandButton = () => wrapper.find('[data-testid="expandPipelineButton"]');
const createWrapper = propsData => { const createWrapper = (propsData, data = []) => {
wrapper = mount(LinkedPipelineComponent, { wrapper = mount(LinkedPipelineComponent, {
propsData, propsData,
data() {
return {
...data,
};
},
}); });
}; };
...@@ -76,7 +84,7 @@ describe('Linked pipeline', () => { ...@@ -76,7 +84,7 @@ describe('Linked pipeline', () => {
}); });
it('should render the tooltip text as the title attribute', () => { it('should render the tooltip text as the title attribute', () => {
const titleAttr = findButton().attributes('title'); const titleAttr = findLinkedPipeline().attributes('title');
expect(titleAttr).toContain(mockPipeline.project.name); expect(titleAttr).toContain(mockPipeline.project.name);
expect(titleAttr).toContain(mockPipeline.details.status.label); expect(titleAttr).toContain(mockPipeline.details.status.label);
...@@ -117,6 +125,56 @@ describe('Linked pipeline', () => { ...@@ -117,6 +125,56 @@ describe('Linked pipeline', () => {
createWrapper(upstreamProps); createWrapper(upstreamProps);
expect(findPipelineLabel().exists()).toBe(true); expect(findPipelineLabel().exists()).toBe(true);
}); });
it('downsteram pipeline should link to the child pipeline if child', () => {
createWrapper(downstreamProps);
expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path);
});
it('upstream pipeline should not contain a link', () => {
createWrapper(upstreamProps);
expect(findPipelineLink().exists()).toBe(false);
});
it.each`
presentClass | missingClass
${'gl-right-0'} | ${'gl-left-0'}
${'gl-border-l-1!'} | ${'gl-border-r-1!'}
`(
'pipeline expand button should be postioned right when child pipeline',
({ presentClass, missingClass }) => {
createWrapper(downstreamProps);
expect(findExpandButton().classes()).toContain(presentClass);
expect(findExpandButton().classes()).not.toContain(missingClass);
},
);
it.each`
presentClass | missingClass
${'gl-left-0'} | ${'gl-right-0'}
${'gl-border-r-1!'} | ${'gl-border-l-1!'}
`(
'pipeline expand button should be postioned left when parent pipeline',
({ presentClass, missingClass }) => {
createWrapper(upstreamProps);
expect(findExpandButton().classes()).toContain(presentClass);
expect(findExpandButton().classes()).not.toContain(missingClass);
},
);
it.each`
pipelineType | anglePosition | expanded
${downstreamProps} | ${'angle-right'} | ${false}
${downstreamProps} | ${'angle-left'} | ${true}
${upstreamProps} | ${'angle-left'} | ${false}
${upstreamProps} | ${'angle-right'} | ${true}
`(
'$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded',
({ pipelineType, anglePosition, expanded }) => {
createWrapper(pipelineType, { expanded });
expect(findExpandButton().props('icon')).toBe(anglePosition);
},
);
}); });
describe('when isLoading is true', () => { describe('when isLoading is true', () => {
...@@ -130,8 +188,8 @@ describe('Linked pipeline', () => { ...@@ -130,8 +188,8 @@ describe('Linked pipeline', () => {
createWrapper(props); createWrapper(props);
}); });
it('sets the loading prop to true', () => { it('loading icon is visible', () => {
expect(findButton().props('loading')).toBe(true); expect(findLoadingIcon().exists()).toBe(true);
}); });
}); });
...@@ -172,5 +230,10 @@ describe('Linked pipeline', () => { ...@@ -172,5 +230,10 @@ describe('Linked pipeline', () => {
findLinkedPipeline().trigger('mouseleave'); findLinkedPipeline().trigger('mouseleave');
expect(wrapper.emitted().downstreamHovered).toStrictEqual([['']]); expect(wrapper.emitted().downstreamHovered).toStrictEqual([['']]);
}); });
it('should emit pipelineExpanded with job name and expanded state on click', () => {
findExpandButton().trigger('click');
expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['trigger_job', 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