Commit c1f89970 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo Committed by Natalia Tepluhina

Add link capability to graph

Includes new component, specs
parent d89d8429
<script>
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinksLayer from '../graph_shared/links_layer.vue';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue';
import { DOWNSTREAM, MAIN, UPSTREAM } from './constants';
......@@ -8,6 +9,7 @@ import { reportToSentry } from './utils';
export default {
name: 'PipelineGraph',
components: {
LinksLayer,
LinkedGraphWrapper,
LinkedPipelinesColumn,
StageColumnComponent,
......@@ -32,9 +34,15 @@ export default {
DOWNSTREAM,
UPSTREAM,
},
CONTAINER_REF: 'PIPELINE_LINKS_CONTAINER_REF',
BASE_CONTAINER_ID: 'pipeline-links-container',
data() {
return {
hoveredJobName: '',
measurements: {
width: 0,
height: 0,
},
pipelineExpanded: {
jobName: '',
expanded: false,
......@@ -42,6 +50,9 @@ export default {
};
},
computed: {
containerId() {
return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`;
},
downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
},
......@@ -54,12 +65,13 @@ export default {
hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0);
},
// The two show checks prevent upstream / downstream from showing redundant linked columns
// The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() {
return (
this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM
);
},
// The show upstream check prevents showing redundant linked columns
showUpstreamPipelines() {
return (
this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM
......@@ -72,7 +84,19 @@ export default {
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
},
mounted() {
this.measurements = this.getMeasurements();
},
methods: {
getMeasurements() {
return {
width: this.$refs[this.containerId].scrollWidth,
height: this.$refs[this.containerId].scrollHeight,
};
},
onError(errorType) {
this.$emit('error', errorType);
},
setJob(jobName) {
this.hoveredJobName = jobName;
},
......@@ -88,43 +112,57 @@ export default {
<template>
<div class="js-pipeline-graph">
<div
:id="containerId"
:ref="containerId"
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 }"
>
<linked-graph-wrapper>
<template #upstream>
<linked-pipelines-column
v-if="showUpstreamPipelines"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
@error="emit('error', errorType)"
/>
</template>
<template #main>
<stage-column-component
v-for="stage in graph"
:key="stage.name"
:title="stage.name"
:groups="stage.groups"
:action="stage.status.action"
:job-hovered="hoveredJobName"
:pipeline-expanded="pipelineExpanded"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
/>
</template>
<template #downstream>
<linked-pipelines-column
v-if="showDownstreamPipelines"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
@downstreamHovered="setJob"
@pipelineExpandToggle="togglePipelineExpanded"
@error="emit('error', errorType)"
/>
</template>
</linked-graph-wrapper>
<links-layer
:pipeline-data="graph"
:pipeline-id="pipeline.id"
:container-id="containerId"
:container-measurements="measurements"
:highlighted-job="hoveredJobName"
default-link-color="gl-stroke-transparent"
@error="onError"
>
<linked-graph-wrapper>
<template #upstream>
<linked-pipelines-column
v-if="showUpstreamPipelines"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
@error="onError"
/>
</template>
<template #main>
<stage-column-component
v-for="stage in graph"
:key="stage.name"
:title="stage.name"
:groups="stage.groups"
:action="stage.status.action"
:job-hovered="hoveredJobName"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipeline.id"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
@jobHover="setJob"
/>
</template>
<template #downstream>
<linked-pipelines-column
v-if="showDownstreamPipelines"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
@downstreamHovered="setJob"
@pipelineExpandToggle="togglePipelineExpanded"
@error="onError"
/>
</template>
</linked-graph-wrapper>
</links-layer>
</div>
</div>
</template>
......@@ -23,8 +23,16 @@ export default {
type: Object,
required: true,
},
pipelineId: {
type: Number,
required: false,
default: -1,
},
},
computed: {
computedJobId() {
return this.pipelineId > -1 ? `${this.group.name}-${this.pipelineId}` : '';
},
tooltipText() {
const { name, status } = this.group;
return `${name} - ${status.label}`;
......@@ -41,7 +49,7 @@ export default {
};
</script>
<template>
<div class="ci-job-dropdown-container dropdown dropright">
<div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
<button
v-gl-tooltip.hover="{ boundary: 'viewport' }"
:title="tooltipText"
......
......@@ -74,6 +74,11 @@ export default {
required: false,
default: () => ({}),
},
pipelineId: {
type: Number,
required: false,
default: -1,
},
},
computed: {
boundary() {
......@@ -85,6 +90,9 @@ export default {
hasDetails() {
return accessValue(this.dataMethod, 'hasDetails', this.status);
},
computedJobId() {
return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
},
status() {
return this.job && this.job.status ? this.job.status : {};
},
......@@ -146,6 +154,7 @@ export default {
</script>
<template>
<div
:id="computedJobId"
class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
data-qa-selector="job_item_container"
>
......
......@@ -24,6 +24,10 @@ export default {
type: Array,
required: true,
},
pipelineId: {
type: Number,
required: true,
},
action: {
type: Object,
required: false,
......@@ -94,16 +98,19 @@ export default {
:key="getGroupId(group)"
data-testid="stage-column-group"
class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width"
@mouseenter="$emit('jobHover', group.name)"
@mouseleave="$emit('jobHover', '')"
>
<job-item
v-if="group.size === 1"
:job="group.jobs[0]"
:job-hovered="jobHovered"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
css-class-job-name="gl-build-content"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/>
<job-group-dropdown v-else :group="group" />
<job-group-dropdown v-else :group="group" :pipeline-id="pipelineId" />
</div>
</template>
</main-graph-wrapper>
......
import * as d3 from 'd3';
import { createUniqueLinkId } from '../../utils';
export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`;
/**
* This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`,
......@@ -7,12 +9,11 @@ import { createUniqueLinkId } from '../../utils';
* we find the nodes in the graph, calculate their coordinates and
* trace the lines that represent the needs of each job.
* @param {Object} nodeDict - Resulting object of `parseData` with nodes and links
* @param {Object} jobs - An object where each key is the job name that contains the job data
* @param {ref} svg - Reference to the svg we draw in
* @param {String} containerID - Id for the svg the links will be draw in
* @returns {Array} Links that contain all the information about them
*/
export const generateLinksData = ({ links }, containerID) => {
export const generateLinksData = ({ links }, containerID, modifier = '') => {
const containerEl = document.getElementById(containerID);
return links.map((link) => {
const path = d3.path();
......@@ -20,8 +21,11 @@ export const generateLinksData = ({ links }, containerID) => {
const sourceId = link.source;
const targetId = link.target;
const sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId);
const modifiedSourceId = `${sourceId}${modifier}`;
const modifiedTargetId = `${targetId}${modifier}`;
const sourceNodeEl = document.getElementById(modifiedSourceId);
const targetNodeEl = document.getElementById(modifiedTargetId);
const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect();
const targetNodeCoordinates = targetNodeEl.getBoundingClientRect();
......@@ -35,11 +39,11 @@ export const generateLinksData = ({ links }, containerID) => {
// from the total to make sure it's aligned properly. We then make the line
// positioned in the center of the job node by adding half the height
// of the job pill.
const paddingLeft = Number(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-left').replace('px', ''),
const paddingLeft = parseFloat(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-left'),
);
const paddingTop = Number(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-top').replace('px', ''),
const paddingTop = parseFloat(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-top'),
);
const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft;
......
<script>
import { isEmpty } from 'lodash';
import { DRAW_FAILURE } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils';
import { parseData } from '../parsing_utils';
import { generateLinksData } from './drawing_utils';
export default {
name: 'LinksInner',
STROKE_WIDTH: 2,
props: {
containerId: {
type: String,
required: true,
},
containerMeasurements: {
type: Object,
required: true,
},
pipelineId: {
type: Number,
required: true,
},
pipelineData: {
type: Array,
required: true,
},
defaultLinkColor: {
type: String,
required: false,
default: 'gl-stroke-gray-200',
},
highlightedJob: {
type: String,
required: false,
default: '',
},
},
data() {
return {
links: [],
needsObject: null,
};
},
computed: {
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
isPipelineDataEmpty() {
return isEmpty(this.pipelineData);
},
highlightedJobs() {
// If you are hovering on a job, then the jobs we want to highlight are:
// The job you are currently hovering + all of its needs.
return this.hasHighlightedJob
? [this.highlightedJob, ...this.needsObject[this.highlightedJob]]
: [];
},
highlightedLinks() {
// If you are hovering on a job, then the links we want to highlight are:
// All the links whose `source` and `target` are highlighted jobs.
if (this.hasHighlightedJob) {
const filteredLinks = this.links.filter((link) => {
return (
this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target)
);
});
return filteredLinks.map((link) => link.ref);
}
return [];
},
viewBox() {
return [0, 0, this.containerMeasurements.width, this.containerMeasurements.height];
},
},
watch: {
highlightedJob() {
// On first hover, generate the needs reference
if (!this.needsObject) {
const jobs = createJobsHash(this.pipelineData);
this.needsObject = generateJobNeedsDict(jobs) ?? {};
}
},
},
mounted() {
if (!isEmpty(this.pipelineData)) {
this.prepareLinkData();
}
},
methods: {
isLinkHighlighted(linkRef) {
return this.highlightedLinks.includes(linkRef);
},
prepareLinkData() {
try {
const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
const parsedData = parseData(arrayOfJobs);
this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`);
} catch {
this.$emit('error', DRAW_FAILURE);
}
},
getLinkClasses(link) {
return [
this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor,
{ 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) },
];
},
},
};
</script>
<template>
<div class="gl-display-flex gl-relative">
<svg
id="link-svg"
class="gl-absolute"
:viewBox="viewBox"
:width="`${containerMeasurements.width}px`"
:height="`${containerMeasurements.height}px`"
>
<template>
<path
v-for="link in links"
:key="link.path"
:ref="link.ref"
:d="link.path"
class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease"
:class="getLinkClasses(link)"
:stroke-width="$options.STROKE_WIDTH"
/>
</template>
</svg>
<slot></slot>
</div>
</template>
<script>
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
import LinksInner from './links_inner.vue';
export default {
name: 'LinksLayer',
components: {
GlAlert,
LinksInner,
},
MAX_GROUPS: 200,
props: {
containerMeasurements: {
type: Object,
required: true,
},
pipelineData: {
type: Array,
required: true,
},
},
data() {
return {
alertDismissed: false,
showLinksOverride: false,
};
},
i18n: {
showLinksAnyways: __('Show links anyways'),
tooManyJobs: __(
'This graph has a large number of jobs and showing the links between them may have performance implications.',
),
},
computed: {
containerZero() {
return !this.containerMeasurements.width || !this.containerMeasurements.height;
},
numGroups() {
return this.pipelineData.reduce((acc, { groups }) => {
return acc + Number(groups.length);
}, 0);
},
showAlert() {
return !this.showLinkedLayers && !this.alertDismissed;
},
showLinkedLayers() {
return (
!this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS)
);
},
},
methods: {
dismissAlert() {
this.alertDismissed = true;
},
overrideShowLinks() {
this.dismissAlert();
this.showLinksOverride = true;
},
},
};
</script>
<template>
<links-inner
v-if="showLinkedLayers"
:container-measurements="containerMeasurements"
:pipeline-data="pipelineData"
v-bind="$attrs"
v-on="$listeners"
>
<slot></slot>
</links-inner>
<div v-else>
<gl-alert
v-if="showAlert"
class="gl-w-max-content gl-ml-4"
:primary-button-text="$options.i18n.showLinksAnyways"
@primaryAction="overrideShowLinks"
@dismiss="dismissAlert"
>
{{ $options.i18n.tooManyJobs }}
</gl-alert>
<slot></slot>
</div>
</template>
<script>
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
import { generateLinksData } from '../graph_shared/drawing_utils';
import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils';
import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils';
......
......@@ -158,7 +158,7 @@ export default async function () {
);
const { pipelineProjectPath, pipelineIid } = dataset;
createPipelinesDetailApp(SELECTORS.PIPELINE_DETAILS, pipelineProjectPath, pipelineIid);
createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid);
} catch {
Flash(__('An error occurred while loading the pipeline.'));
}
......
......@@ -6,8 +6,6 @@ export const validateParams = (params) => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
};
export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`;
/**
* This function takes the stages array and transform it
* into a hash where each key is a job name and the job data
......
......@@ -25947,6 +25947,9 @@ msgstr ""
msgid "Show latest version"
msgstr ""
msgid "Show links anyways"
msgstr ""
msgid "Show list"
msgstr ""
......@@ -28799,6 +28802,9 @@ msgstr ""
msgid "This field is required."
msgstr ""
msgid "This graph has a large number of jobs and showing the links between them may have performance implications."
msgstr ""
msgid "This group"
msgstr ""
......
......@@ -30,7 +30,7 @@ job_test_2:
job_build:
stage: build
script:
script:
- echo "build"
needs: ["job_test_2"]
`;
......
......@@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import { GRAPHQL } from '~/pipelines/components/graph/constants';
import {
generateResponse,
......@@ -13,6 +14,7 @@ describe('graph component', () => {
let wrapper;
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findLinksLayer = () => wrapper.find(LinksLayer);
const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const defaultProps = {
......@@ -28,6 +30,9 @@ describe('graph component', () => {
provide: {
dataMethod: GRAPHQL,
},
stubs: {
'links-inner': true,
},
});
};
......@@ -45,6 +50,10 @@ describe('graph component', () => {
expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length);
});
it('renders the links layer', () => {
expect(findLinksLayer().exists()).toBe(true);
});
describe('when column requests a refresh', () => {
beforeEach(() => {
findStageColumns().at(0).vm.$emit('refreshPipelineGraph');
......
......@@ -30,6 +30,7 @@ const mockGroups = Array(4)
const defaultProps = {
title: 'Fish',
groups: mockGroups,
pipelineId: 159,
};
describe('stage column component', () => {
......@@ -92,36 +93,51 @@ describe('stage column component', () => {
});
describe('job', () => {
beforeEach(() => {
createComponent({
method: mount,
props: {
groups: [
{
id: 4259,
name: '<img src=x onerror=alert(document.domain)>',
status: {
icon: 'status_success',
label: 'success',
tooltip: '<img src=x onerror=alert(document.domain)>',
describe('text handling', () => {
beforeEach(() => {
createComponent({
method: mount,
props: {
groups: [
{
id: 4259,
name: '<img src=x onerror=alert(document.domain)>',
status: {
icon: 'status_success',
label: 'success',
tooltip: '<img src=x onerror=alert(document.domain)>',
},
},
},
],
title: 'test <img src=x onerror=alert(document.domain)>',
},
],
title: 'test <img src=x onerror=alert(document.domain)>',
},
});
});
});
it('capitalizes and escapes name', () => {
expect(findStageColumnTitle().text()).toBe(
'Test &lt;img src=x onerror=alert(document.domain)&gt;',
);
it('capitalizes and escapes name', () => {
expect(findStageColumnTitle().text()).toBe(
'Test &lt;img src=x onerror=alert(document.domain)&gt;',
);
});
it('escapes id', () => {
expect(findStageColumnGroup().attributes('id')).toBe(
'ci-badge-&lt;img src=x onerror=alert(document.domain)&gt;',
);
});
});
it('escapes id', () => {
expect(findStageColumnGroup().attributes('id')).toBe(
'ci-badge-&lt;img src=x onerror=alert(document.domain)&gt;',
);
describe('interactions', () => {
beforeEach(() => {
createComponent({ method: mount });
});
it('emits jobHovered event on mouseenter and mouseleave', async () => {
await findStageColumnGroup().trigger('mouseenter');
expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name]]);
await findStageColumnGroup().trigger('mouseleave');
expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name], ['']]);
});
});
});
......
import { createUniqueLinkId } from '~/pipelines/utils';
import { createUniqueLinkId } from '~/pipelines/components/graph_shared/drawing_utils';
export const yamlString = `stages:
- empty
......
import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlButton } from '@gitlab/ui';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import { generateResponse, mockPipelineResponse } from '../graph/mock_data';
describe('links layer component', () => {
let wrapper;
const findAlert = () => wrapper.find(GlAlert);
const findShowAnyways = () => findAlert().find(GlButton);
const findLinksInner = () => wrapper.find(LinksInner);
const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo');
const containerId = `pipeline-links-container-${pipeline.id}`;
const slotContent = "<div>Ceci n'est pas un graphique</div>";
const tooManyStages = Array(101)
.fill(0)
.flatMap(() => pipeline.stages);
const defaultProps = {
containerId,
containerMeasurements: { width: 400, height: 400 },
pipelineId: pipeline.id,
pipelineData: pipeline.stages,
};
const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => {
wrapper = mountFn(LinksLayer, {
propsData: {
...defaultProps,
...props,
},
slots: {
default: slotContent,
},
stubs: {
'links-inner': true,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with data under max stages', () => {
beforeEach(() => {
createComponent();
});
it('renders the default slot', () => {
expect(wrapper.html()).toContain(slotContent);
});
it('renders the inner links component', () => {
expect(findLinksInner().exists()).toBe(true);
});
});
describe('with more than the max number of stages', () => {
describe('rendering', () => {
beforeEach(() => {
createComponent({ props: { pipelineData: tooManyStages } });
});
it('renders the default slot', () => {
expect(wrapper.html()).toContain(slotContent);
});
it('renders the alert component', () => {
expect(findAlert().exists()).toBe(true);
});
it('does not render the inner links component', () => {
expect(findLinksInner().exists()).toBe(false);
});
});
describe('interactions', () => {
beforeEach(() => {
createComponent({ mountFn: mount, props: { pipelineData: tooManyStages } });
});
it('renders the disable button', () => {
expect(findShowAnyways().exists()).toBe(true);
expect(findShowAnyways().text()).toBe(wrapper.vm.$options.i18n.showLinksAnyways);
});
it('shows links when override is clicked', async () => {
expect(findLinksInner().exists()).toBe(false);
await findShowAnyways().trigger('click');
expect(findLinksInner().exists()).toBe(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