Commit 1be89f35 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo

Merge branch '241722-hover-on-jobs' into 'master'

Add hover highlights to CI config graph for needs jobs

See merge request gitlab-org/gitlab!44471
parents 7690ea25 c68208dc
...@@ -14,14 +14,11 @@ import { createUniqueJobId } from '../../utils'; ...@@ -14,14 +14,11 @@ import { createUniqueJobId } from '../../utils';
export const generateLinksData = ({ links }, jobs, containerID) => { export const generateLinksData = ({ links }, jobs, containerID) => {
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();
// We can only have one unique job name per stage, so our selector const sourceId = jobs[link.source].id;
// is: ${stageName}-${jobName} const targetId = jobs[link.target].id;
const sourceId = createUniqueJobId(jobs[link.source].stage, link.source);
const targetId = createUniqueJobId(jobs[link.target].stage, link.target);
const sourceNodeEl = document.getElementById(sourceId); const sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId); const targetNodeEl = document.getElementById(targetId);
...@@ -80,6 +77,12 @@ export const generateLinksData = ({ links }, jobs, containerID) => { ...@@ -80,6 +77,12 @@ export const generateLinksData = ({ links }, jobs, containerID) => {
targetNodeY, targetNodeY,
); );
return { ...link, path: path.toString() }; return {
...link,
source: sourceId,
target: targetId,
ref: createUniqueJobId(sourceId, targetId),
path: path.toString(),
};
}); });
}; };
...@@ -14,6 +14,42 @@ export default { ...@@ -14,6 +14,42 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isHighlighted: {
type: Boolean,
required: false,
default: false,
},
isFadedOut: {
type: Boolean,
required: false,
default: false,
},
handleMouseOver: {
type: Function,
required: false,
default: () => {},
},
handleMouseLeave: {
type: Function,
required: false,
default: () => {},
},
},
computed: {
jobPillClasses() {
return [
{ 'gl-opacity-3': this.isFadedOut },
this.isHighlighted ? 'gl-shadow-blue-200-x0-y0-b4-s2' : 'gl-inset-border-2-green-400',
];
},
},
methods: {
onMouseEnter() {
this.$emit('on-mouse-enter', this.jobId);
},
onMouseLeave() {
this.$emit('on-mouse-leave');
},
}, },
}; };
</script> </script>
...@@ -21,7 +57,10 @@ export default { ...@@ -21,7 +57,10 @@ export default {
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div <div
:id="jobId" :id="jobId"
class="gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-inset-border-1-green-600 gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 pipeline-job-pill " class="pipeline-job-pill 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"
@mouseover="onMouseEnter"
@mouseleave="onMouseLeave"
> >
{{ jobName }} {{ jobName }}
</div> </div>
......
...@@ -7,7 +7,7 @@ import StagePill from './stage_pill.vue'; ...@@ -7,7 +7,7 @@ import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils'; import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils'; import { parseData } from '../parsing_utils';
import { DRAW_FAILURE, DEFAULT } from '../../constants'; import { DRAW_FAILURE, DEFAULT } from '../../constants';
import { createUniqueJobId } from '../../utils'; import { generateJobNeedsDict } from '../../utils';
export default { export default {
components: { components: {
...@@ -31,7 +31,9 @@ export default { ...@@ -31,7 +31,9 @@ export default {
data() { data() {
return { return {
failureType: null, failureType: null,
highlightedJob: null,
links: [], links: [],
needsObject: null,
height: 0, height: 0,
width: 0, width: 0,
}; };
...@@ -43,6 +45,9 @@ export default { ...@@ -43,6 +45,9 @@ export default {
hasError() { hasError() {
return this.failureType; return this.failureType;
}, },
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
failure() { failure() {
const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT]; const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
...@@ -51,8 +56,27 @@ export default { ...@@ -51,8 +56,27 @@ export default {
viewBox() { viewBox() {
return [0, 0, this.width, this.height]; return [0, 0, this.width, this.height];
}, },
lineStyle() { highlightedJobs() {
return `stroke-width:${this.$options.STROKE_WIDTH}px;`; // 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 [];
}, },
}, },
mounted() { mounted() {
...@@ -62,9 +86,6 @@ export default { ...@@ -62,9 +86,6 @@ export default {
} }
}, },
methods: { methods: {
createJobId(stageName, jobName) {
return createUniqueJobId(stageName, jobName);
},
drawJobLinks() { drawJobLinks() {
const { stages, jobs } = this.pipelineData; const { stages, jobs } = this.pipelineData;
const unwrappedGroups = this.unwrapPipelineData(stages); const unwrappedGroups = this.unwrapPipelineData(stages);
...@@ -76,6 +97,18 @@ export default { ...@@ -76,6 +97,18 @@ export default {
this.reportFailure(DRAW_FAILURE); this.reportFailure(DRAW_FAILURE);
} }
}, },
highlightNeeds(uniqueJobId) {
// The first time we hover, we create the object where
// we store all the data to properly highlight the needs.
if (!this.needsObject) {
this.needsObject = generateJobNeedsDict(this.pipelineData) ?? {};
}
this.highlightedJob = uniqueJobId;
},
removeHighlightNeeds() {
this.highlightedJob = null;
},
unwrapPipelineData(stages) { unwrapPipelineData(stages) {
return stages return stages
.map(({ name, groups }) => { .map(({ name, groups }) => {
...@@ -95,6 +128,18 @@ export default { ...@@ -95,6 +128,18 @@ export default {
resetFailure() { resetFailure() {
this.failureType = null; this.failureType = null;
}, },
isJobHighlighted(jobName) {
return this.highlightedJobs.includes(jobName);
},
isLinkHighlighted(linkRef) {
return this.highlightedLinks.includes(linkRef);
},
getLinkClasses(link) {
return [
this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : 'gl-stroke-gray-200',
{ 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) },
];
},
}, },
}; };
</script> </script>
...@@ -113,13 +158,17 @@ export default { ...@@ -113,13 +158,17 @@ export default {
class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7" class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7"
> >
<svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute"> <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
<template>
<path <path
v-for="link in links" v-for="link in links"
:key="link.path" :key="link.path"
:ref="link.ref"
:d="link.path" :d="link.path"
class="gl-stroke-gray-200 gl-fill-transparent" class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease"
:style="lineStyle" :class="getLinkClasses(link)"
:stroke-width="$options.STROKE_WIDTH"
/> />
</template>
</svg> </svg>
<div <div
v-for="(stage, index) in pipelineData.stages" v-for="(stage, index) in pipelineData.stages"
...@@ -141,8 +190,12 @@ export default { ...@@ -141,8 +190,12 @@ export default {
<job-pill <job-pill
v-for="group in stage.groups" v-for="group in stage.groups"
:key="group.name" :key="group.name"
:job-id="createJobId(stage.name, group.name)" :job-id="group.id"
:job-name="group.name" :job-name="group.name"
:is-highlighted="hasHighlightedJob && isJobHighlighted(group.id)"
:is-faded-out="hasHighlightedJob && !isJobHighlighted(group.id)"
@on-mouse-enter="highlightNeeds"
@on-mouse-leave="removeHighlightNeeds"
/> />
</div> </div>
</div> </div>
......
...@@ -5,6 +5,8 @@ export const validateParams = params => { ...@@ -5,6 +5,8 @@ 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 createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`;
/** /**
* This function takes a json payload that comes from a yml * This function takes a json payload that comes from a yml
* file converted to json through `jsyaml` library. Because we * file converted to json through `jsyaml` library. Because we
...@@ -21,7 +23,10 @@ export const preparePipelineGraphData = jsonData => { ...@@ -21,7 +23,10 @@ export const preparePipelineGraphData = jsonData => {
// Creates an object with only the valid jobs // Creates an object with only the valid jobs
const jobs = jsonKeys.reduce((acc, val) => { const jobs = jsonKeys.reduce((acc, val) => {
if (jobNames.includes(val)) { if (jobNames.includes(val)) {
return { ...acc, [val]: { ...jsonData[val] } }; return {
...acc,
[val]: { ...jsonData[val], id: createUniqueJobId(jsonData[val].stage, val) },
};
} }
return { ...acc }; return { ...acc };
}, {}); }, {});
...@@ -47,7 +52,11 @@ export const preparePipelineGraphData = jsonData => { ...@@ -47,7 +52,11 @@ export const preparePipelineGraphData = jsonData => {
return { return {
name: stage, name: stage,
groups: stageJobs.map(job => { groups: stageJobs.map(job => {
return { name: job, jobs: [{ ...jsonData[job] }] }; return {
name: job,
jobs: [{ ...jsonData[job] }],
id: createUniqueJobId(stage, job),
};
}), }),
}; };
}); });
...@@ -55,4 +64,33 @@ export const preparePipelineGraphData = jsonData => { ...@@ -55,4 +64,33 @@ export const preparePipelineGraphData = jsonData => {
return { stages: pipelineData, jobs }; return { stages: pipelineData, jobs };
}; };
export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`; export const generateJobNeedsDict = ({ jobs }) => {
const arrOfJobNames = Object.keys(jobs);
return arrOfJobNames.reduce((acc, value) => {
const recursiveNeeds = jobName => {
if (!jobs[jobName]?.needs) {
return [];
}
return jobs[jobName].needs
.map(job => {
const { id } = jobs[job];
// If we already have the needs of a job in the accumulator,
// then we use the memoized data instead of the recursive call
// to save some performance.
const newNeeds = acc[id] ?? recursiveNeeds(job);
return [id, ...newNeeds];
})
.flat(Infinity);
};
// To ensure we don't have duplicates job relationship when 2 jobs
// needed by another both depends on the same jobs, we remove any
// duplicates from the array.
const uniqueValues = Array.from(new Set(recursiveNeeds(value)));
return { ...acc, [jobs[value].id]: uniqueValues };
}, {});
};
import { createUniqueJobId } from '~/pipelines/utils';
export const yamlString = `stages: export const yamlString = `stages:
- empty - empty
- build - build
...@@ -39,18 +41,20 @@ deploy_a: ...@@ -39,18 +41,20 @@ deploy_a:
script: echo hello script: echo hello
`; `;
const jobId1 = createUniqueJobId('build', 'build_1');
const jobId2 = createUniqueJobId('test', 'test_1');
const jobId3 = createUniqueJobId('test', 'test_2');
const jobId4 = createUniqueJobId('deploy', 'deploy_1');
export const pipelineData = { export const pipelineData = {
stages: [ stages: [
{
name: 'build',
groups: [],
},
{ {
name: 'build', name: 'build',
groups: [ groups: [
{ {
name: 'build_1', name: 'build_1',
jobs: [{ script: 'echo hello', stage: 'build' }], jobs: [{ script: 'echo hello', stage: 'build' }],
id: jobId1,
}, },
], ],
}, },
...@@ -60,10 +64,12 @@ export const pipelineData = { ...@@ -60,10 +64,12 @@ export const pipelineData = {
{ {
name: 'test_1', name: 'test_1',
jobs: [{ script: 'yarn test', stage: 'test' }], jobs: [{ script: 'yarn test', stage: 'test' }],
id: jobId2,
}, },
{ {
name: 'test_2', name: 'test_2',
jobs: [{ script: 'yarn karma', stage: 'test' }], jobs: [{ script: 'yarn karma', stage: 'test' }],
id: jobId3,
}, },
], ],
}, },
...@@ -73,8 +79,15 @@ export const pipelineData = { ...@@ -73,8 +79,15 @@ export const pipelineData = {
{ {
name: 'deploy_1', name: 'deploy_1',
jobs: [{ script: 'yarn magick', stage: 'deploy' }], jobs: [{ script: 'yarn magick', stage: 'deploy' }],
id: jobId4,
}, },
], ],
}, },
], ],
jobs: {
[jobId1]: {},
[jobId2]: {},
[jobId3]: {},
[jobId4]: {},
},
}; };
import { preparePipelineGraphData } from '~/pipelines/utils'; import {
preparePipelineGraphData,
createUniqueJobId,
generateJobNeedsDict,
} from '~/pipelines/utils';
describe('preparePipelineGraphData', () => { describe('utils functions', () => {
const emptyResponse = { stages: [], jobs: {} }; const emptyResponse = { stages: [], jobs: {} };
const jobName1 = 'build_1'; const jobName1 = 'build_1';
const jobName2 = 'build_2'; const jobName2 = 'build_2';
const jobName3 = 'test_1'; const jobName3 = 'test_1';
const jobName4 = 'deploy_1'; const jobName4 = 'deploy_1';
const job1 = { [jobName1]: { script: 'echo hello', stage: 'build' } }; const job1 = { script: 'echo hello', stage: 'build' };
const job2 = { [jobName2]: { script: 'echo build', stage: 'build' } }; const job2 = { script: 'echo build', stage: 'build' };
const job3 = { [jobName3]: { script: 'echo test', stage: 'test' } }; const job3 = { script: 'echo test', stage: 'test', needs: [jobName1, jobName2] };
const job4 = { [jobName4]: { script: 'echo deploy', stage: 'deploy' } }; const job4 = { script: 'echo deploy', stage: 'deploy', needs: [jobName3] };
const userDefinedStage = 'myStage';
const pipelineGraphData = {
stages: [
{
name: userDefinedStage,
groups: [],
},
{
name: job4.stage,
groups: [
{
name: jobName4,
jobs: [{ ...job4 }],
id: createUniqueJobId(job4.stage, jobName4),
},
],
},
{
name: job1.stage,
groups: [
{
name: jobName1,
jobs: [{ ...job1 }],
id: createUniqueJobId(job1.stage, jobName1),
},
{
name: jobName2,
jobs: [{ ...job2 }],
id: createUniqueJobId(job2.stage, jobName2),
},
],
},
{
name: job3.stage,
groups: [
{
name: jobName3,
jobs: [{ ...job3 }],
id: createUniqueJobId(job3.stage, jobName3),
},
],
},
],
jobs: {
[jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
[jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) },
[jobName3]: { ...job3, id: createUniqueJobId(job3.stage, jobName3) },
[jobName4]: { ...job4, id: createUniqueJobId(job4.stage, jobName4) },
},
};
describe('preparePipelineGraphData', () => {
describe('returns an empty array of stages and empty job objects if', () => { describe('returns an empty array of stages and empty job objects if', () => {
it('no data is passed', () => { it('no data is passed', () => {
expect(preparePipelineGraphData({})).toEqual(emptyResponse); expect(preparePipelineGraphData({})).toEqual(emptyResponse);
...@@ -28,27 +84,32 @@ describe('preparePipelineGraphData', () => { ...@@ -28,27 +84,32 @@ describe('preparePipelineGraphData', () => {
const expectedData = { const expectedData = {
stages: [ stages: [
{ {
name: job1[jobName1].stage, name: job1.stage,
groups: [ groups: [
{ {
name: jobName1, name: jobName1,
jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }], jobs: [{ ...job1 }],
id: createUniqueJobId(job1.stage, jobName1),
}, },
{ {
name: jobName2, name: jobName2,
jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }], jobs: [{ ...job2 }],
id: createUniqueJobId(job2.stage, jobName2),
}, },
], ],
}, },
], ],
jobs: { ...job1, ...job2 }, jobs: {
[jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
[jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) },
},
}; };
expect(
expect(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData); preparePipelineGraphData({ [jobName1]: { ...job1 }, [jobName2]: { ...job2 } }),
).toEqual(expectedData);
}); });
it('when stages are defined by the user', () => { it('when stages are defined by the user', () => {
const userDefinedStage = 'myStage';
const userDefinedStage2 = 'myStage2'; const userDefinedStage2 = 'myStage2';
const expectedData = { const expectedData = {
...@@ -71,91 +132,80 @@ describe('preparePipelineGraphData', () => { ...@@ -71,91 +132,80 @@ describe('preparePipelineGraphData', () => {
}); });
it('by combining user defined stage and job stages, it preserves user defined order', () => { it('by combining user defined stage and job stages, it preserves user defined order', () => {
const userDefinedStage = 'myStage';
const userDefinedStageThatOverlaps = 'deploy'; const userDefinedStageThatOverlaps = 'deploy';
const expectedData = {
stages: [
{
name: userDefinedStage,
groups: [],
},
{
name: job4[jobName4].stage,
groups: [
{
name: jobName4,
jobs: [{ script: job4[jobName4].script, stage: job4[jobName4].stage }],
},
],
},
{
name: job1[jobName1].stage,
groups: [
{
name: jobName1,
jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }],
},
{
name: jobName2,
jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }],
},
],
},
{
name: job3[jobName3].stage,
groups: [
{
name: jobName3,
jobs: [{ script: job3[jobName3].script, stage: job3[jobName3].stage }],
},
],
},
],
jobs: {
...job1,
...job2,
...job3,
...job4,
},
};
expect( expect(
preparePipelineGraphData({ preparePipelineGraphData({
stages: [userDefinedStage, userDefinedStageThatOverlaps], stages: [userDefinedStage, userDefinedStageThatOverlaps],
...job1, [jobName1]: { ...job1 },
...job2, [jobName2]: { ...job2 },
...job3, [jobName3]: { ...job3 },
...job4, [jobName4]: { ...job4 },
}), }),
).toEqual(expectedData); ).toEqual(pipelineGraphData);
}); });
it('with only unique values', () => { it('with only unique values', () => {
const expectedData = { const expectedData = {
stages: [ stages: [
{ {
name: job1[jobName1].stage, name: job1.stage,
groups: [ groups: [
{ {
name: jobName1, name: jobName1,
jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }], jobs: [{ ...job1 }],
id: createUniqueJobId(job1.stage, jobName1),
}, },
], ],
}, },
], ],
jobs: { jobs: {
...job1, [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
}, },
}; };
expect( expect(
preparePipelineGraphData({ preparePipelineGraphData({
stages: ['build'], stages: ['build'],
...job1, [jobName1]: { ...job1 },
...job1, [jobName1]: { ...job1 },
}), }),
).toEqual(expectedData); ).toEqual(expectedData);
}); });
}); });
});
describe('generateJobNeedsDict', () => {
it('generates an empty object if it receives no jobs', () => {
expect(generateJobNeedsDict({ jobs: {} })).toEqual({});
});
it('generates a dict with empty needs if there are no dependencies', () => {
const smallGraph = {
jobs: {
[jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
[jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) },
},
};
expect(generateJobNeedsDict(smallGraph)).toEqual({
[pipelineGraphData.jobs[jobName1].id]: [],
[pipelineGraphData.jobs[jobName2].id]: [],
});
});
it('generates a dict where key is the a job and its value is an array of all its needs', () => {
const uniqueJobName1 = pipelineGraphData.jobs[jobName1].id;
const uniqueJobName2 = pipelineGraphData.jobs[jobName2].id;
const uniqueJobName3 = pipelineGraphData.jobs[jobName3].id;
const uniqueJobName4 = pipelineGraphData.jobs[jobName4].id;
expect(generateJobNeedsDict(pipelineGraphData)).toEqual({
[uniqueJobName1]: [],
[uniqueJobName2]: [],
[uniqueJobName3]: [uniqueJobName1, uniqueJobName2],
[uniqueJobName4]: [uniqueJobName3, uniqueJobName1, uniqueJobName2],
});
});
});
}); });
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