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> <script>
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; 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 LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue'; import StageColumnComponent from './stage_column_component.vue';
import { DOWNSTREAM, MAIN, UPSTREAM } from './constants'; import { DOWNSTREAM, MAIN, UPSTREAM } from './constants';
...@@ -8,6 +9,7 @@ import { reportToSentry } from './utils'; ...@@ -8,6 +9,7 @@ import { reportToSentry } from './utils';
export default { export default {
name: 'PipelineGraph', name: 'PipelineGraph',
components: { components: {
LinksLayer,
LinkedGraphWrapper, LinkedGraphWrapper,
LinkedPipelinesColumn, LinkedPipelinesColumn,
StageColumnComponent, StageColumnComponent,
...@@ -32,9 +34,15 @@ export default { ...@@ -32,9 +34,15 @@ export default {
DOWNSTREAM, DOWNSTREAM,
UPSTREAM, UPSTREAM,
}, },
CONTAINER_REF: 'PIPELINE_LINKS_CONTAINER_REF',
BASE_CONTAINER_ID: 'pipeline-links-container',
data() { data() {
return { return {
hoveredJobName: '', hoveredJobName: '',
measurements: {
width: 0,
height: 0,
},
pipelineExpanded: { pipelineExpanded: {
jobName: '', jobName: '',
expanded: false, expanded: false,
...@@ -42,6 +50,9 @@ export default { ...@@ -42,6 +50,9 @@ export default {
}; };
}, },
computed: { computed: {
containerId() {
return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`;
},
downstreamPipelines() { downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : []; return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
}, },
...@@ -54,12 +65,13 @@ export default { ...@@ -54,12 +65,13 @@ export default {
hasUpstreamPipelines() { hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0); 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() { showDownstreamPipelines() {
return ( return (
this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM
); );
}, },
// The show upstream check prevents showing redundant linked columns
showUpstreamPipelines() { showUpstreamPipelines() {
return ( return (
this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM
...@@ -72,7 +84,19 @@ export default { ...@@ -72,7 +84,19 @@ export default {
errorCaptured(err, _vm, info) { errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
}, },
mounted() {
this.measurements = this.getMeasurements();
},
methods: { methods: {
getMeasurements() {
return {
width: this.$refs[this.containerId].scrollWidth,
height: this.$refs[this.containerId].scrollHeight,
};
},
onError(errorType) {
this.$emit('error', errorType);
},
setJob(jobName) { setJob(jobName) {
this.hoveredJobName = jobName; this.hoveredJobName = jobName;
}, },
...@@ -88,43 +112,57 @@ export default { ...@@ -88,43 +112,57 @@ export default {
<template> <template>
<div class="js-pipeline-graph"> <div class="js-pipeline-graph">
<div <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-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> <links-layer
<template #upstream> :pipeline-data="graph"
<linked-pipelines-column :pipeline-id="pipeline.id"
v-if="showUpstreamPipelines" :container-id="containerId"
:linked-pipelines="upstreamPipelines" :container-measurements="measurements"
:column-title="__('Upstream')" :highlighted-job="hoveredJobName"
:type="$options.pipelineTypeConstants.UPSTREAM" default-link-color="gl-stroke-transparent"
@error="emit('error', errorType)" @error="onError"
/> >
</template> <linked-graph-wrapper>
<template #main> <template #upstream>
<stage-column-component <linked-pipelines-column
v-for="stage in graph" v-if="showUpstreamPipelines"
:key="stage.name" :linked-pipelines="upstreamPipelines"
:title="stage.name" :column-title="__('Upstream')"
:groups="stage.groups" :type="$options.pipelineTypeConstants.UPSTREAM"
:action="stage.status.action" @error="onError"
:job-hovered="hoveredJobName" />
:pipeline-expanded="pipelineExpanded" </template>
@refreshPipelineGraph="$emit('refreshPipelineGraph')" <template #main>
/> <stage-column-component
</template> v-for="stage in graph"
<template #downstream> :key="stage.name"
<linked-pipelines-column :title="stage.name"
v-if="showDownstreamPipelines" :groups="stage.groups"
:linked-pipelines="downstreamPipelines" :action="stage.status.action"
:column-title="__('Downstream')" :job-hovered="hoveredJobName"
:type="$options.pipelineTypeConstants.DOWNSTREAM" :pipeline-expanded="pipelineExpanded"
@downstreamHovered="setJob" :pipeline-id="pipeline.id"
@pipelineExpandToggle="togglePipelineExpanded" @refreshPipelineGraph="$emit('refreshPipelineGraph')"
@error="emit('error', errorType)" @jobHover="setJob"
/> />
</template> </template>
</linked-graph-wrapper> <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>
</div> </div>
</template> </template>
...@@ -23,8 +23,16 @@ export default { ...@@ -23,8 +23,16 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
pipelineId: {
type: Number,
required: false,
default: -1,
},
}, },
computed: { computed: {
computedJobId() {
return this.pipelineId > -1 ? `${this.group.name}-${this.pipelineId}` : '';
},
tooltipText() { tooltipText() {
const { name, status } = this.group; const { name, status } = this.group;
return `${name} - ${status.label}`; return `${name} - ${status.label}`;
...@@ -41,7 +49,7 @@ export default { ...@@ -41,7 +49,7 @@ export default {
}; };
</script> </script>
<template> <template>
<div 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' }" v-gl-tooltip.hover="{ boundary: 'viewport' }"
:title="tooltipText" :title="tooltipText"
......
...@@ -74,6 +74,11 @@ export default { ...@@ -74,6 +74,11 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
pipelineId: {
type: Number,
required: false,
default: -1,
},
}, },
computed: { computed: {
boundary() { boundary() {
...@@ -85,6 +90,9 @@ export default { ...@@ -85,6 +90,9 @@ export default {
hasDetails() { hasDetails() {
return accessValue(this.dataMethod, 'hasDetails', this.status); return accessValue(this.dataMethod, 'hasDetails', this.status);
}, },
computedJobId() {
return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
},
status() { status() {
return this.job && this.job.status ? this.job.status : {}; return this.job && this.job.status ? this.job.status : {};
}, },
...@@ -146,6 +154,7 @@ export default { ...@@ -146,6 +154,7 @@ export default {
</script> </script>
<template> <template>
<div <div
: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"
data-qa-selector="job_item_container" data-qa-selector="job_item_container"
> >
......
...@@ -24,6 +24,10 @@ export default { ...@@ -24,6 +24,10 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
pipelineId: {
type: Number,
required: true,
},
action: { action: {
type: Object, type: Object,
required: false, required: false,
...@@ -94,16 +98,19 @@ export default { ...@@ -94,16 +98,19 @@ export default {
:key="getGroupId(group)" :key="getGroupId(group)"
data-testid="stage-column-group" data-testid="stage-column-group"
class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width" class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width"
@mouseenter="$emit('jobHover', group.name)"
@mouseleave="$emit('jobHover', '')"
> >
<job-item <job-item
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" :pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
css-class-job-name="gl-build-content" css-class-job-name="gl-build-content"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/> />
<job-group-dropdown v-else :group="group" /> <job-group-dropdown v-else :group="group" :pipeline-id="pipelineId" />
</div> </div>
</template> </template>
</main-graph-wrapper> </main-graph-wrapper>
......
import * as d3 from 'd3'; import * as d3 from 'd3';
import { createUniqueLinkId } from '../../utils';
export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`;
/** /**
* This function expects its first argument data structure * This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`, * to be the same shaped as the one generated by `parseData`,
...@@ -7,12 +9,11 @@ import { createUniqueLinkId } from '../../utils'; ...@@ -7,12 +9,11 @@ import { createUniqueLinkId } from '../../utils';
* we find the nodes in the graph, calculate their coordinates and * we find the nodes in the graph, calculate their coordinates and
* trace the lines that represent the needs of each job. * trace the lines that represent the needs of each job.
* @param {Object} nodeDict - Resulting object of `parseData` with nodes and links * @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 {String} containerID - Id for the svg the links will be draw in
* @param {ref} svg - Reference to the svg we draw in
* @returns {Array} Links that contain all the information about them * @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); const containerEl = document.getElementById(containerID);
return links.map((link) => { return links.map((link) => {
const path = d3.path(); const path = d3.path();
...@@ -20,8 +21,11 @@ export const generateLinksData = ({ links }, containerID) => { ...@@ -20,8 +21,11 @@ export const generateLinksData = ({ links }, containerID) => {
const sourceId = link.source; const sourceId = link.source;
const targetId = link.target; const targetId = link.target;
const sourceNodeEl = document.getElementById(sourceId); const modifiedSourceId = `${sourceId}${modifier}`;
const targetNodeEl = document.getElementById(targetId); const modifiedTargetId = `${targetId}${modifier}`;
const sourceNodeEl = document.getElementById(modifiedSourceId);
const targetNodeEl = document.getElementById(modifiedTargetId);
const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect(); const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect();
const targetNodeCoordinates = targetNodeEl.getBoundingClientRect(); const targetNodeCoordinates = targetNodeEl.getBoundingClientRect();
...@@ -35,11 +39,11 @@ export const generateLinksData = ({ links }, containerID) => { ...@@ -35,11 +39,11 @@ export const generateLinksData = ({ links }, containerID) => {
// from the total to make sure it's aligned properly. We then make the line // 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 // positioned in the center of the job node by adding half the height
// of the job pill. // of the job pill.
const paddingLeft = Number( const paddingLeft = parseFloat(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-left').replace('px', ''), window.getComputedStyle(containerEl, null).getPropertyValue('padding-left'),
); );
const paddingTop = Number( const paddingTop = parseFloat(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-top').replace('px', ''), window.getComputedStyle(containerEl, null).getPropertyValue('padding-top'),
); );
const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft; 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> <script>
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { generateLinksData } from '../graph_shared/drawing_utils';
import JobPill from './job_pill.vue'; import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue'; import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils'; import { parseData } from '../parsing_utils';
import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants'; import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils'; import { createJobsHash, generateJobNeedsDict } from '../../utils';
......
...@@ -158,7 +158,7 @@ export default async function () { ...@@ -158,7 +158,7 @@ export default async function () {
); );
const { pipelineProjectPath, pipelineIid } = dataset; const { pipelineProjectPath, pipelineIid } = dataset;
createPipelinesDetailApp(SELECTORS.PIPELINE_DETAILS, pipelineProjectPath, pipelineIid); createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid);
} catch { } catch {
Flash(__('An error occurred while loading the pipeline.')); Flash(__('An error occurred while loading the pipeline.'));
} }
......
...@@ -6,8 +6,6 @@ export const validateParams = (params) => { ...@@ -6,8 +6,6 @@ export const validateParams = (params) => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); 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 * This function takes the stages array and transform it
* into a hash where each key is a job name and the job data * into a hash where each key is a job name and the job data
......
...@@ -25947,6 +25947,9 @@ msgstr "" ...@@ -25947,6 +25947,9 @@ msgstr ""
msgid "Show latest version" msgid "Show latest version"
msgstr "" msgstr ""
msgid "Show links anyways"
msgstr ""
msgid "Show list" msgid "Show list"
msgstr "" msgstr ""
...@@ -28799,6 +28802,9 @@ msgstr "" ...@@ -28799,6 +28802,9 @@ msgstr ""
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
msgid "This graph has a large number of jobs and showing the links between them may have performance implications."
msgstr ""
msgid "This group" msgid "This group"
msgstr "" msgstr ""
......
...@@ -30,7 +30,7 @@ job_test_2: ...@@ -30,7 +30,7 @@ job_test_2:
job_build: job_build:
stage: build stage: build
script: script:
- echo "build" - echo "build"
needs: ["job_test_2"] needs: ["job_test_2"]
`; `;
......
...@@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ 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 LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import { GRAPHQL } from '~/pipelines/components/graph/constants'; import { GRAPHQL } from '~/pipelines/components/graph/constants';
import { import {
generateResponse, generateResponse,
...@@ -13,6 +14,7 @@ describe('graph component', () => { ...@@ -13,6 +14,7 @@ describe('graph component', () => {
let wrapper; let wrapper;
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findLinksLayer = () => wrapper.find(LinksLayer);
const findStageColumns = () => wrapper.findAll(StageColumnComponent); const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const defaultProps = { const defaultProps = {
...@@ -28,6 +30,9 @@ describe('graph component', () => { ...@@ -28,6 +30,9 @@ describe('graph component', () => {
provide: { provide: {
dataMethod: GRAPHQL, dataMethod: GRAPHQL,
}, },
stubs: {
'links-inner': true,
},
}); });
}; };
...@@ -45,6 +50,10 @@ describe('graph component', () => { ...@@ -45,6 +50,10 @@ describe('graph component', () => {
expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length); expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length);
}); });
it('renders the links layer', () => {
expect(findLinksLayer().exists()).toBe(true);
});
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');
......
...@@ -30,6 +30,7 @@ const mockGroups = Array(4) ...@@ -30,6 +30,7 @@ const mockGroups = Array(4)
const defaultProps = { const defaultProps = {
title: 'Fish', title: 'Fish',
groups: mockGroups, groups: mockGroups,
pipelineId: 159,
}; };
describe('stage column component', () => { describe('stage column component', () => {
...@@ -92,36 +93,51 @@ describe('stage column component', () => { ...@@ -92,36 +93,51 @@ describe('stage column component', () => {
}); });
describe('job', () => { describe('job', () => {
beforeEach(() => { describe('text handling', () => {
createComponent({ beforeEach(() => {
method: mount, createComponent({
props: { method: mount,
groups: [ props: {
{ groups: [
id: 4259, {
name: '<img src=x onerror=alert(document.domain)>', id: 4259,
status: { name: '<img src=x onerror=alert(document.domain)>',
icon: 'status_success', status: {
label: 'success', icon: 'status_success',
tooltip: '<img src=x onerror=alert(document.domain)>', 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', () => { it('capitalizes and escapes name', () => {
expect(findStageColumnTitle().text()).toBe( expect(findStageColumnTitle().text()).toBe(
'Test &lt;img src=x onerror=alert(document.domain)&gt;', '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', () => { describe('interactions', () => {
expect(findStageColumnGroup().attributes('id')).toBe( beforeEach(() => {
'ci-badge-&lt;img src=x onerror=alert(document.domain)&gt;', 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: export const yamlString = `stages:
- empty - 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