Commit 48edc675 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo

Merge branch '296984-apply-links-layer-to-pipeline-editing-vis' into 'master'

Resolve "Apply Links Layer to Pipeline Editing Vis"

See merge request gitlab-org/gitlab!58098
parents 5d5cec6d dc3d354a
...@@ -10,6 +10,10 @@ export default { ...@@ -10,6 +10,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
pipelineId: {
type: Number,
required: true,
},
isHighlighted: { isHighlighted: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -32,6 +36,9 @@ export default { ...@@ -32,6 +36,9 @@ export default {
}, },
}, },
computed: { computed: {
id() {
return `${this.jobName}-${this.pipelineId}`;
},
jobPillClasses() { jobPillClasses() {
return [ return [
{ 'gl-opacity-3': this.isFadedOut }, { 'gl-opacity-3': this.isFadedOut },
...@@ -52,7 +59,7 @@ export default { ...@@ -52,7 +59,7 @@ export default {
<template> <template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div <div
:id="jobName" :id="id"
class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
:class="jobPillClasses" :class="jobPillClasses"
@mouseover="onMouseEnter" @mouseover="onMouseEnter"
......
...@@ -3,9 +3,7 @@ import { GlAlert } from '@gitlab/ui'; ...@@ -3,9 +3,7 @@ import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
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 LinksLayer from '../graph_shared/links_layer.vue';
import { generateLinksData } from '../graph_shared/drawing_utils';
import { parseData } from '../parsing_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';
...@@ -13,10 +11,12 @@ export default { ...@@ -13,10 +11,12 @@ export default {
components: { components: {
GlAlert, GlAlert,
JobPill, JobPill,
LinksLayer,
StagePill, StagePill,
}, },
CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF', CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF',
CONTAINER_ID: 'pipeline-graph-container', BASE_CONTAINER_ID: 'pipeline-graph-container',
PIPELINE_ID: 0,
STROKE_WIDTH: 2, STROKE_WIDTH: 2,
errorTexts: { errorTexts: {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'), [DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
...@@ -36,33 +36,16 @@ export default { ...@@ -36,33 +36,16 @@ export default {
return { return {
failureType: null, failureType: null,
highlightedJob: null, highlightedJob: null,
links: [], highlightedJobs: [],
needsObject: null, measurements: {
height: 0, height: 0,
width: 0, width: 0,
},
}; };
}, },
computed: { computed: {
hideGraph() { containerId() {
// We won't even try to render the graph with these condition return `${this.$options.BASE_CONTAINER_ID}-${this.$options.PIPELINE_ID}`;
// because it would cause additional errors down the line for the user
// which is confusing.
return this.isPipelineDataEmpty || this.isInvalidCiConfig;
},
pipelineStages() {
return this.pipelineData?.stages || [];
},
isPipelineDataEmpty() {
return !this.isInvalidCiConfig && this.pipelineStages.length === 0;
},
isInvalidCiConfig() {
return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
},
hasError() {
return this.failureType;
},
hasHighlightedJob() {
return Boolean(this.highlightedJob);
}, },
failure() { failure() {
switch (this.failureType) { switch (this.failureType) {
...@@ -92,28 +75,26 @@ export default { ...@@ -92,28 +75,26 @@ export default {
}; };
} }
}, },
viewBox() { hasError() {
return [0, 0, this.width, this.height]; return this.failureType;
}, },
highlightedJobs() { hasHighlightedJob() {
// If you are hovering on a job, then the jobs we want to highlight are: return Boolean(this.highlightedJob);
// The job you are currently hovering + all of its needs.
return [this.highlightedJob, ...this.needsObject[this.highlightedJob]];
}, },
highlightedLinks() { hideGraph() {
// If you are hovering on a job, then the links we want to highlight are: // We won't even try to render the graph with these condition
// All the links whose `source` and `target` are highlighted jobs. // because it would cause additional errors down the line for the user
if (this.hasHighlightedJob) { // which is confusing.
const filteredLinks = this.links.filter((link) => { return this.isPipelineDataEmpty || this.isInvalidCiConfig;
return ( },
this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target) isInvalidCiConfig() {
); return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
}); },
isPipelineDataEmpty() {
return filteredLinks.map((link) => link.ref); return !this.isInvalidCiConfig && this.pipelineStages.length === 0;
} },
pipelineStages() {
return []; return this.pipelineData?.stages || [];
}, },
}, },
watch: { watch: {
...@@ -127,21 +108,17 @@ export default { ...@@ -127,21 +108,17 @@ export default {
} else { } else {
this.$nextTick(() => { this.$nextTick(() => {
this.computeGraphDimensions(); this.computeGraphDimensions();
this.prepareLinkData();
}); });
} }
}, },
}, },
}, },
methods: { methods: {
prepareLinkData() { computeGraphDimensions() {
try { this.measurements = {
const arrayOfJobs = this.pipelineStages.flatMap(({ groups }) => groups); width: this.$refs[this.$options.CONTAINER_REF].scrollWidth,
const parsedData = parseData(arrayOfJobs); height: this.$refs[this.$options.CONTAINER_REF].scrollHeight,
this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID); };
} catch {
this.reportFailure(DRAW_FAILURE);
}
}, },
getStageBackgroundClasses(index) { getStageBackgroundClasses(index) {
const { length } = this.pipelineStages; const { length } = this.pipelineStages;
...@@ -161,22 +138,14 @@ export default { ...@@ -161,22 +138,14 @@ export default {
return ''; return '';
}, },
highlightNeeds(uniqueJobId) { isJobHighlighted(jobName) {
// The first time we hover, we create the object where return this.highlightedJobs.includes(jobName);
// we store all the data to properly highlight the needs.
if (!this.needsObject) {
const jobs = createJobsHash(this.pipelineStages);
this.needsObject = generateJobNeedsDict(jobs) ?? {};
}
this.highlightedJob = uniqueJobId;
}, },
removeHighlightNeeds() { onError(error) {
this.highlightedJob = null; this.reportFailure(error.type);
}, },
computeGraphDimensions() { removeHoveredJob() {
this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`; this.highlightedJob = null;
this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`;
}, },
reportFailure(errorType) { reportFailure(errorType) {
this.failureType = errorType; this.failureType = errorType;
...@@ -184,17 +153,11 @@ export default { ...@@ -184,17 +153,11 @@ export default {
resetFailure() { resetFailure() {
this.failureType = null; this.failureType = null;
}, },
isJobHighlighted(jobName) { setHoveredJob(jobName) {
return this.highlightedJobs.includes(jobName); this.highlightedJob = jobName;
},
isLinkHighlighted(linkRef) {
return this.highlightedLinks.includes(linkRef);
}, },
getLinkClasses(link) { updateHighlightedJobs(jobs) {
return [ this.highlightedJobs = jobs;
this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : 'gl-stroke-gray-200',
{ 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) },
];
}, },
}, },
}; };
...@@ -211,48 +174,47 @@ export default { ...@@ -211,48 +174,47 @@ export default {
</gl-alert> </gl-alert>
<div <div
v-if="!hideGraph" v-if="!hideGraph"
:id="$options.CONTAINER_ID" :id="containerId"
:ref="$options.CONTAINER_REF" :ref="$options.CONTAINER_REF"
class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7"
data-testid="graph-container" data-testid="graph-container"
> >
<svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute"> <links-layer
<path :pipeline-data="pipelineStages"
v-for="link in links" :pipeline-id="$options.PIPELINE_ID"
:key="link.path" :container-id="containerId"
:ref="link.ref" :container-measurements="measurements"
:d="link.path" :highlighted-job="highlightedJob"
class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease" @highlightedJobsChange="updateHighlightedJobs"
:class="getLinkClasses(link)" @error="onError"
:stroke-width="$options.STROKE_WIDTH"
/>
</svg>
<div
v-for="(stage, index) in pipelineStages"
:key="`${stage.name}-${index}`"
class="gl-flex-direction-column"
> >
<div <div
class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5" v-for="(stage, index) in pipelineStages"
:class="getStageBackgroundClasses(index)" :key="`${stage.name}-${index}`"
data-testid="stage-background" class="gl-flex-direction-column"
>
<stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
</div>
<div
class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
> >
<job-pill <div
v-for="group in stage.groups" class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5"
:key="group.name" :class="getStageBackgroundClasses(index)"
:job-name="group.name" data-testid="stage-background"
:is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)" >
:is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)" <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
@on-mouse-enter="highlightNeeds" </div>
@on-mouse-leave="removeHighlightNeeds" <div
/> class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
>
<job-pill
v-for="group in stage.groups"
:key="group.name"
:job-name="group.name"
:pipeline-id="$options.PIPELINE_ID"
:is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
:is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
@on-mouse-enter="setHoveredJob"
@on-mouse-leave="removeHoveredJob"
/>
</div>
</div> </div>
</div> </links-layer>
</div> </div>
</div> </div>
</template> </template>
...@@ -98,6 +98,42 @@ export const pipelineData = { ...@@ -98,6 +98,42 @@ export const pipelineData = {
], ],
}; };
export const invalidNeedsData = {
stages: [
{
name: 'build',
groups: [
{
name: 'build_1',
jobs: [{ script: 'echo hello', stage: 'build' }],
},
],
},
{
name: 'test',
groups: [
{
name: 'test_1',
jobs: [{ script: 'yarn test', stage: 'test' }],
},
{
name: 'test_2',
jobs: [{ script: 'yarn karma', stage: 'test' }],
},
],
},
{
name: 'deploy',
groups: [
{
name: 'deploy_1',
jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['invalid_job'] }],
},
],
},
],
};
export const parallelNeedData = { export const parallelNeedData = {
stages: [ stages: [
{ {
......
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue';
import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants'; import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants';
import { pipelineData, singleStageData } from './mock_data'; import { invalidNeedsData, pipelineData, singleStageData } from './mock_data';
describe('pipeline graph component', () => { describe('pipeline graph component', () => {
const defaultProps = { pipelineData }; const defaultProps = { pipelineData };
...@@ -16,19 +18,28 @@ describe('pipeline graph component', () => { ...@@ -16,19 +18,28 @@ describe('pipeline graph component', () => {
propsData: { propsData: {
...props, ...props,
}, },
stubs: { LinksLayer, LinksInner },
data() {
return {
measurements: {
width: 1000,
height: 1000,
},
};
},
}); });
}; };
const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]'); const findAlert = () => wrapper.findComponent(GlAlert);
const findAlert = () => wrapper.find(GlAlert); const findAllJobPills = () => wrapper.findAll(JobPill);
const findAllStagePills = () => wrapper.findAll(StagePill);
const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]'); const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]');
const findAllStagePills = () => wrapper.findAllComponents(StagePill);
const findLinksLayer = () => wrapper.findComponent(LinksLayer);
const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]');
const findStageBackgroundElementAt = (index) => findAllStageBackgroundElements().at(index); const findStageBackgroundElementAt = (index) => findAllStageBackgroundElements().at(index);
const findAllJobPills = () => wrapper.findAll(JobPill);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('with no data', () => { describe('with no data', () => {
...@@ -36,7 +47,7 @@ describe('pipeline graph component', () => { ...@@ -36,7 +47,7 @@ describe('pipeline graph component', () => {
wrapper = createComponent({ pipelineData: {} }); wrapper = createComponent({ pipelineData: {} });
}); });
it('renders an empty section', () => { it('does not render the graph', () => {
expect(wrapper.text()).toBe(wrapper.vm.$options.errorTexts[EMPTY_PIPELINE_DATA]); expect(wrapper.text()).toBe(wrapper.vm.$options.errorTexts[EMPTY_PIPELINE_DATA]);
expect(findPipelineGraph().exists()).toBe(false); expect(findPipelineGraph().exists()).toBe(false);
expect(findAllStagePills()).toHaveLength(0); expect(findAllStagePills()).toHaveLength(0);
...@@ -74,10 +85,11 @@ describe('pipeline graph component', () => { ...@@ -74,10 +85,11 @@ describe('pipeline graph component', () => {
describe('with error while rendering the links with needs', () => { describe('with error while rendering the links with needs', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent({ pipelineData: invalidNeedsData });
}); });
it('renders the error that link could not be drawn', () => { it('renders the error that link could not be drawn', () => {
expect(findLinksLayer().exists()).toBe(true);
expect(findAlert().exists()).toBe(true); expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[DRAW_FAILURE]); expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[DRAW_FAILURE]);
}); });
......
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