Commit 23833900 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo Committed by Andrew Fontaine

Pipeline Graph: Add Stage Name to Needs/Layers VIew

parent fb0738b3
...@@ -54,6 +54,7 @@ export default { ...@@ -54,6 +54,7 @@ export default {
data() { data() {
return { return {
hoveredJobName: '', hoveredJobName: '',
hoveredSourceJobName: '',
highlightedJobs: [], highlightedJobs: [],
measurements: { measurements: {
width: 0, width: 0,
...@@ -93,6 +94,9 @@ export default { ...@@ -93,6 +94,9 @@ export default {
shouldHideLinks() { shouldHideLinks() {
return this.isStageView; return this.isStageView;
}, },
shouldShowStageName() {
return !this.isStageView;
},
// The show downstream check prevents showing redundant linked columns // The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() { showDownstreamPipelines() {
return ( return (
...@@ -148,6 +152,9 @@ export default { ...@@ -148,6 +152,9 @@ export default {
setJob(jobName) { setJob(jobName) {
this.hoveredJobName = jobName; this.hoveredJobName = jobName;
}, },
setSourceJob(jobName) {
this.hoveredSourceJobName = jobName;
},
slidePipelineContainer() { slidePipelineContainer() {
this.$refs.mainPipelineContainer.scrollBy({ this.$refs.mainPipelineContainer.scrollBy({
left: ONE_COL_WIDTH, left: ONE_COL_WIDTH,
...@@ -204,11 +211,13 @@ export default { ...@@ -204,11 +211,13 @@ export default {
<stage-column-component <stage-column-component
v-for="column in layout" v-for="column in layout"
:key="column.id || column.name" :key="column.id || column.name"
:title="column.name" :name="column.name"
:groups="column.groups" :groups="column.groups"
:action="column.status.action" :action="column.status.action"
:highlighted-jobs="highlightedJobs" :highlighted-jobs="highlightedJobs"
:show-stage-name="shouldShowStageName"
:job-hovered="hoveredJobName" :job-hovered="hoveredJobName"
:source-job-hovered="hoveredSourceJobName"
:pipeline-expanded="pipelineExpanded" :pipeline-expanded="pipelineExpanded"
:pipeline-id="pipeline.id" :pipeline-id="pipeline.id"
@refreshPipelineGraph="$emit('refreshPipelineGraph')" @refreshPipelineGraph="$emit('refreshPipelineGraph')"
...@@ -227,7 +236,7 @@ export default { ...@@ -227,7 +236,7 @@ export default {
:column-title="__('Downstream')" :column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM" :type="$options.pipelineTypeConstants.DOWNSTREAM"
:view-type="viewType" :view-type="viewType"
@downstreamHovered="setJob" @downstreamHovered="setSourceJob"
@pipelineExpandToggle="togglePipelineExpanded" @pipelineExpandToggle="togglePipelineExpanded"
@scrollContainer="slidePipelineContainer" @scrollContainer="slidePipelineContainer"
@error="onError" @error="onError"
......
<script> <script>
import { GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils'; import { reportToSentry } from '../../utils';
import JobItem from './job_item.vue'; import JobItem from './job_item.vue';
...@@ -11,12 +9,8 @@ import JobItem from './job_item.vue'; ...@@ -11,12 +9,8 @@ import JobItem from './job_item.vue';
* *
*/ */
export default { export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: { components: {
JobItem, JobItem,
CiIcon,
}, },
props: { props: {
group: { group: {
...@@ -28,6 +22,11 @@ export default { ...@@ -28,6 +22,11 @@ export default {
required: false, required: false,
default: -1, default: -1,
}, },
stageName: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
computedJobId() { computedJobId() {
...@@ -51,22 +50,21 @@ export default { ...@@ -51,22 +50,21 @@ export default {
<template> <template>
<div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright"> <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
<button <button
v-gl-tooltip.hover="{ boundary: 'viewport' }"
:title="tooltipText"
type="button" type="button"
data-toggle="dropdown" data-toggle="dropdown"
data-display="static" data-display="static"
class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!" class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!"
> >
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
<span class="gl-display-flex gl-align-items-center gl-min-w-0"> <job-item
<ci-icon :status="group.status" :size="24" class="gl-line-height-0" /> :dropdown-length="group.size"
<span class="gl-text-truncate mw-70p gl-pl-3"> :group-tooltip="tooltipText"
{{ group.name }} :job="group"
</span> :stage-name="stageName"
</span> @pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
<span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span> <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div>
</div> </div>
</button> </button>
......
...@@ -3,6 +3,7 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; ...@@ -3,6 +3,7 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils'; import { reportToSentry } from '../../utils';
import ActionComponent from '../jobs_shared/action_component.vue'; import ActionComponent from '../jobs_shared/action_component.vue';
import JobNameComponent from '../jobs_shared/job_name_component.vue'; import JobNameComponent from '../jobs_shared/job_name_component.vue';
...@@ -38,6 +39,7 @@ export default { ...@@ -38,6 +39,7 @@ export default {
hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
components: { components: {
ActionComponent, ActionComponent,
CiIcon,
JobNameComponent, JobNameComponent,
GlLink, GlLink,
}, },
...@@ -65,6 +67,11 @@ export default { ...@@ -65,6 +67,11 @@ export default {
required: false, required: false,
default: Infinity, default: Infinity,
}, },
groupTooltip: {
type: String,
required: false,
default: '',
},
jobHovered: { jobHovered: {
type: String, type: String,
required: false, required: false,
...@@ -80,24 +87,47 @@ export default { ...@@ -80,24 +87,47 @@ export default {
required: false, required: false,
default: -1, default: -1,
}, },
sourceJobHovered: {
type: String,
required: false,
default: '',
},
stageName: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
boundary() { boundary() {
return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
}, },
computedJobId() {
return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
},
detailsPath() { detailsPath() {
return accessValue(this.dataMethod, 'detailsPath', this.status); return accessValue(this.dataMethod, 'detailsPath', this.status);
}, },
hasDetails() { hasDetails() {
return accessValue(this.dataMethod, 'hasDetails', this.status); return accessValue(this.dataMethod, 'hasDetails', this.status);
}, },
computedJobId() { nameComponent() {
return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : ''; return this.hasDetails ? 'gl-link' : 'div';
},
showStageName() {
return Boolean(this.stageName);
}, },
status() { status() {
return this.job && this.job.status ? this.job.status : {}; return this.job && this.job.status ? this.job.status : {};
}, },
testId() {
return this.hasDetails ? 'job-with-link' : 'job-without-link';
},
tooltipText() { tooltipText() {
if (this.groupTooltip) {
return this.groupTooltip;
}
const textBuilder = []; const textBuilder = [];
const { name: jobName } = this.job; const { name: jobName } = this.job;
...@@ -129,7 +159,7 @@ export default { ...@@ -129,7 +159,7 @@ export default {
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() { relatedDownstreamHovered() {
return this.job.name === this.jobHovered; return this.job.name === this.sourceJobHovered;
}, },
relatedDownstreamExpanded() { relatedDownstreamExpanded() {
return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded; return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
...@@ -156,44 +186,45 @@ export default { ...@@ -156,44 +186,45 @@ export default {
<template> <template>
<div <div
:id="computedJobId" :id="computedJobId"
class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full"
data-qa-selector="job_item_container" data-qa-selector="job_item_container"
> >
<gl-link <component
v-if="hasDetails" :is="nameComponent"
v-gl-tooltip="{ v-gl-tooltip="{
boundary: 'viewport', boundary: 'viewport',
placement: 'bottom', placement: 'bottom',
customClass: 'gl-pointer-events-none', customClass: 'gl-pointer-events-none',
}" }"
:href="detailsPath"
:title="tooltipText" :title="tooltipText"
:class="jobClasses" :class="jobClasses"
class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" :href="detailsPath"
data-testid="job-with-link" class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full"
:data-testid="testId"
@click.stop="hideTooltips" @click.stop="hideTooltips"
@mouseout="hideTooltips" @mouseout="hideTooltips"
> >
<job-name-component :name="job.name" :status="job.status" :icon-size="24" /> <div class="ci-job-name-component gl-display-flex gl-align-items-center">
</gl-link> <ci-icon :size="24" :status="job.status" class="gl-line-height-0" />
<div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full">
<div class="gl-text-truncate mw-70p gl-line-height-normal">{{ job.name }}</div>
<div <div
v-else v-if="showStageName"
v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" data-testid="stage-name-in-job"
:title="tooltipText" class="gl-text-truncate mw-70p gl-font-sm gl-text-gray-500 gl-line-height-normal"
:class="jobClasses"
class="js-job-component-tooltip non-details-job-component menu-item"
data-testid="job-without-link"
@mouseout="hideTooltips"
> >
<job-name-component :name="job.name" :status="job.status" :icon-size="24" /> {{ stageName }}
</div>
</div>
</div> </div>
</component>
<action-component <action-component
v-if="hasAction" v-if="hasAction"
:tooltip-text="status.action.title" :tooltip-text="status.action.title"
:link="status.action.path" :link="status.action.path"
:action-icon="status.action.icon" :action-icon="status.action.icon"
class="gl-mr-1"
data-qa-selector="action_button" data-qa-selector="action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete" @pipelineActionRequestComplete="pipelineActionRequestComplete"
/> />
......
...@@ -22,12 +22,12 @@ export default { ...@@ -22,12 +22,12 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
pipelineId: { name: {
type: Number, type: String,
required: true, required: true,
}, },
title: { pipelineId: {
type: String, type: Number,
required: true, required: true,
}, },
action: { action: {
...@@ -50,6 +50,16 @@ export default { ...@@ -50,6 +50,16 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
showStageName: {
type: Boolean,
required: false,
default: false,
},
sourceJobHovered: {
type: String,
required: false,
default: '',
},
}, },
titleClasses: [ titleClasses: [
'gl-font-weight-bold', 'gl-font-weight-bold',
...@@ -75,7 +85,7 @@ export default { ...@@ -75,7 +85,7 @@ export default {
}); });
}, },
formattedTitle() { formattedTitle() {
return capitalize(escape(this.title)); return capitalize(escape(this.name));
}, },
hasAction() { hasAction() {
return !isEmpty(this.action); return !isEmpty(this.action);
...@@ -145,14 +155,20 @@ export default { ...@@ -145,14 +155,20 @@ export default {
v-if="singleJobExists(group)" v-if="singleJobExists(group)"
:job="group.jobs[0]" :job="group.jobs[0]"
:job-hovered="jobHovered" :job-hovered="jobHovered"
:source-job-hovered="sourceJobHovered"
:pipeline-expanded="pipelineExpanded" :pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId" :pipeline-id="pipelineId"
:stage-name="showStageName ? group.stageName : ''"
css-class-job-name="gl-build-content" css-class-job-name="gl-build-content"
:class="{ 'gl-opacity-3': isFadedOut(group.name) }" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/> />
<div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
<job-group-dropdown :group="group" :pipeline-id="pipelineId" /> <job-group-dropdown
:group="group"
:stage-name="showStageName ? group.stageName : ''"
:pipeline-id="pipelineId"
/>
</div> </div>
</div> </div>
</template> </template>
......
...@@ -5,7 +5,19 @@ const unwrapGroups = (stages) => { ...@@ -5,7 +5,19 @@ const unwrapGroups = (stages) => {
const { const {
groups: { nodes: groups }, groups: { nodes: groups },
} = stage; } = stage;
return { node: { ...stage, groups }, lookup: { stageIdx: idx } };
/*
Being peformance conscious here means we don't want to spread and copy the
group value just to add one parameter.
*/
/* eslint-disable no-param-reassign */
const groupsWithStageName = groups.map((group) => {
group.stageName = stage.name;
return group;
});
/* eslint-enable no-param-reassign */
return { node: { ...stage, groups: groupsWithStageName }, lookup: { stageIdx: idx } };
}); });
}; };
......
...@@ -148,7 +148,19 @@ ...@@ -148,7 +148,19 @@
} }
.gl-build-content { .gl-build-content {
@include build-content(); display: inline-block;
padding: 8px 10px 9px;
width: 100%;
border: 1px solid var(--border-color, $border-color);
border-radius: 30px;
background-color: var(--white, $white);
&:hover,
&:focus {
background-color: var(--gray-50, $gray-50);
border: 1px solid $dropdown-toggle-active-border-color;
color: var(--gl-text-color, $gl-text-color);
}
} }
.gl-ci-action-icon-container { .gl-ci-action-icon-container {
......
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import { GRAPHQL, STAGE_VIEW } from '~/pipelines/components/graph/constants'; import { GRAPHQL, LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import JobItem from '~/pipelines/components/graph/job_item.vue'; import JobItem from '~/pipelines/components/graph/job_item.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import { listByLayers } from '~/pipelines/components/parsing_utils';
import { import {
generateResponse, generateResponse,
mockPipelineResponse, mockPipelineResponse,
...@@ -17,6 +18,7 @@ describe('graph component', () => { ...@@ -17,6 +18,7 @@ describe('graph component', () => {
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findLinksLayer = () => wrapper.find(LinksLayer); const findLinksLayer = () => wrapper.find(LinksLayer);
const findStageColumns = () => wrapper.findAll(StageColumnComponent); const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const findStageNameInJob = () => wrapper.find('[data-testid="stage-name-in-job"]');
const defaultProps = { const defaultProps = {
pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
...@@ -82,6 +84,10 @@ describe('graph component', () => { ...@@ -82,6 +84,10 @@ describe('graph component', () => {
expect(findLinksLayer().exists()).toBe(true); expect(findLinksLayer().exists()).toBe(true);
}); });
it('does not display stage name on the job in default (stage) mode', () => {
expect(findStageNameInJob().exists()).toBe(false);
});
describe('when column requests a refresh', () => { describe('when column requests a refresh', () => {
beforeEach(() => { beforeEach(() => {
findStageColumns().at(0).vm.$emit('refreshPipelineGraph'); findStageColumns().at(0).vm.$emit('refreshPipelineGraph');
...@@ -93,7 +99,7 @@ describe('graph component', () => { ...@@ -93,7 +99,7 @@ describe('graph component', () => {
}); });
describe('when links are present', () => { describe('when links are present', () => {
beforeEach(async () => { beforeEach(() => {
createComponent({ createComponent({
mountFn: mount, mountFn: mount,
stubOverride: { 'job-item': false }, stubOverride: { 'job-item': false },
...@@ -132,4 +138,24 @@ describe('graph component', () => { ...@@ -132,4 +138,24 @@ describe('graph component', () => {
expect(findLinkedColumns()).toHaveLength(2); expect(findLinkedColumns()).toHaveLength(2);
}); });
}); });
describe('in layers mode', () => {
beforeEach(() => {
createComponent({
mountFn: mount,
stubOverride: {
'job-item': false,
'job-group-dropdown': false,
},
props: {
viewType: LAYER_VIEW,
pipelineLayers: listByLayers(defaultProps.pipeline),
},
});
});
it('displays the stage name on the job', () => {
expect(findStageNameInJob().exists()).toBe(true);
});
});
}); });
import { shallowMount } from '@vue/test-utils'; import { shallowMount, mount } from '@vue/test-utils';
import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue'; import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue';
describe('job group dropdown component', () => { describe('job group dropdown component', () => {
...@@ -65,12 +65,16 @@ describe('job group dropdown component', () => { ...@@ -65,12 +65,16 @@ describe('job group dropdown component', () => {
let wrapper; let wrapper;
const findButton = () => wrapper.find('button'); const findButton = () => wrapper.find('button');
const createComponent = ({ mountFn = shallowMount }) => {
wrapper = mountFn(JobGroupDropdown, { propsData: { group } });
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(JobGroupDropdown, { propsData: { group } }); createComponent({ mountFn: mount });
}); });
it('renders button with group name and size', () => { it('renders button with group name and size', () => {
......
...@@ -122,7 +122,7 @@ describe('pipeline graph job item', () => { ...@@ -122,7 +122,7 @@ describe('pipeline graph job item', () => {
}, },
}); });
expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test'); expect(findJobWithoutLink().attributes('title')).toBe('test');
}); });
it('should not render status label when it is provided', () => { it('should not render status label when it is provided', () => {
...@@ -138,7 +138,7 @@ describe('pipeline graph job item', () => { ...@@ -138,7 +138,7 @@ describe('pipeline graph job item', () => {
}, },
}); });
expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test - success'); expect(findJobWithoutLink().attributes('title')).toBe('test - success');
}); });
}); });
......
...@@ -28,7 +28,7 @@ const mockGroups = Array(4) ...@@ -28,7 +28,7 @@ const mockGroups = Array(4)
}); });
const defaultProps = { const defaultProps = {
title: 'Fish', name: 'Fish',
groups: mockGroups, groups: mockGroups,
pipelineId: 159, pipelineId: 159,
}; };
...@@ -62,7 +62,7 @@ describe('stage column component', () => { ...@@ -62,7 +62,7 @@ describe('stage column component', () => {
}); });
it('should render provided title', () => { it('should render provided title', () => {
expect(findStageColumnTitle().text()).toBe(defaultProps.title); expect(findStageColumnTitle().text()).toBe(defaultProps.name);
}); });
it('should render the provided groups', () => { it('should render the provided groups', () => {
...@@ -119,7 +119,7 @@ describe('stage column component', () => { ...@@ -119,7 +119,7 @@ describe('stage column component', () => {
], ],
}, },
], ],
title: 'test <img src=x onerror=alert(document.domain)>', name: 'test <img src=x onerror=alert(document.domain)>',
}, },
}); });
}); });
......
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