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';
export const generateLinksData = ({ links }, jobs, containerID) => {
const containerEl = document.getElementById(containerID);
return links.map(link => {
const path = d3.path();
// We can only have one unique job name per stage, so our selector
// is: ${stageName}-${jobName}
const sourceId = createUniqueJobId(jobs[link.source].stage, link.source);
const targetId = createUniqueJobId(jobs[link.target].stage, link.target);
const sourceId = jobs[link.source].id;
const targetId = jobs[link.target].id;
const sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId);
......@@ -80,6 +77,12 @@ export const generateLinksData = ({ links }, jobs, containerID) => {
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 {
type: String,
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>
......@@ -21,7 +57,10 @@ export default {
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
: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 }}
</div>
......
......@@ -7,7 +7,7 @@ import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils';
import { DRAW_FAILURE, DEFAULT } from '../../constants';
import { createUniqueJobId } from '../../utils';
import { generateJobNeedsDict } from '../../utils';
export default {
components: {
......@@ -31,7 +31,9 @@ export default {
data() {
return {
failureType: null,
highlightedJob: null,
links: [],
needsObject: null,
height: 0,
width: 0,
};
......@@ -43,6 +45,9 @@ export default {
hasError() {
return this.failureType;
},
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
failure() {
const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
......@@ -51,8 +56,27 @@ export default {
viewBox() {
return [0, 0, this.width, this.height];
},
lineStyle() {
return `stroke-width:${this.$options.STROKE_WIDTH}px;`;
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 [];
},
},
mounted() {
......@@ -62,9 +86,6 @@ export default {
}
},
methods: {
createJobId(stageName, jobName) {
return createUniqueJobId(stageName, jobName);
},
drawJobLinks() {
const { stages, jobs } = this.pipelineData;
const unwrappedGroups = this.unwrapPipelineData(stages);
......@@ -76,6 +97,18 @@ export default {
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) {
return stages
.map(({ name, groups }) => {
......@@ -95,6 +128,18 @@ export default {
resetFailure() {
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>
......@@ -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"
>
<svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
<template>
<path
v-for="link in links"
:key="link.path"
:ref="link.ref"
:d="link.path"
class="gl-stroke-gray-200 gl-fill-transparent"
:style="lineStyle"
class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease"
:class="getLinkClasses(link)"
:stroke-width="$options.STROKE_WIDTH"
/>
</template>
</svg>
<div
v-for="(stage, index) in pipelineData.stages"
......@@ -141,8 +190,12 @@ export default {
<job-pill
v-for="group in stage.groups"
:key="group.name"
:job-id="createJobId(stage.name, group.name)"
:job-id="group.id"
: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>
......
......@@ -5,6 +5,8 @@ export const validateParams = params => {
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
* file converted to json through `jsyaml` library. Because we
......@@ -21,7 +23,10 @@ export const preparePipelineGraphData = jsonData => {
// Creates an object with only the valid jobs
const jobs = jsonKeys.reduce((acc, val) => {
if (jobNames.includes(val)) {
return { ...acc, [val]: { ...jsonData[val] } };
return {
...acc,
[val]: { ...jsonData[val], id: createUniqueJobId(jsonData[val].stage, val) },
};
}
return { ...acc };
}, {});
......@@ -47,7 +52,11 @@ export const preparePipelineGraphData = jsonData => {
return {
name: stage,
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 => {
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:
- empty
- build
......@@ -39,18 +41,20 @@ deploy_a:
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 = {
stages: [
{
name: 'build',
groups: [],
},
{
name: 'build',
groups: [
{
name: 'build_1',
jobs: [{ script: 'echo hello', stage: 'build' }],
id: jobId1,
},
],
},
......@@ -60,10 +64,12 @@ export const pipelineData = {
{
name: 'test_1',
jobs: [{ script: 'yarn test', stage: 'test' }],
id: jobId2,
},
{
name: 'test_2',
jobs: [{ script: 'yarn karma', stage: 'test' }],
id: jobId3,
},
],
},
......@@ -73,8 +79,15 @@ export const pipelineData = {
{
name: 'deploy_1',
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 jobName1 = 'build_1';
const jobName2 = 'build_2';
const jobName3 = 'test_1';
const jobName4 = 'deploy_1';
const job1 = { [jobName1]: { script: 'echo hello', stage: 'build' } };
const job2 = { [jobName2]: { script: 'echo build', stage: 'build' } };
const job3 = { [jobName3]: { script: 'echo test', stage: 'test' } };
const job4 = { [jobName4]: { script: 'echo deploy', stage: 'deploy' } };
const job1 = { script: 'echo hello', stage: 'build' };
const job2 = { script: 'echo build', stage: 'build' };
const job3 = { script: 'echo test', stage: 'test', needs: [jobName1, jobName2] };
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', () => {
it('no data is passed', () => {
expect(preparePipelineGraphData({})).toEqual(emptyResponse);
......@@ -28,27 +84,32 @@ describe('preparePipelineGraphData', () => {
const expectedData = {
stages: [
{
name: job1[jobName1].stage,
name: job1.stage,
groups: [
{
name: jobName1,
jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }],
jobs: [{ ...job1 }],
id: createUniqueJobId(job1.stage, jobName1),
},
{
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(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData);
expect(
preparePipelineGraphData({ [jobName1]: { ...job1 }, [jobName2]: { ...job2 } }),
).toEqual(expectedData);
});
it('when stages are defined by the user', () => {
const userDefinedStage = 'myStage';
const userDefinedStage2 = 'myStage2';
const expectedData = {
......@@ -71,91 +132,80 @@ describe('preparePipelineGraphData', () => {
});
it('by combining user defined stage and job stages, it preserves user defined order', () => {
const userDefinedStage = 'myStage';
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(
preparePipelineGraphData({
stages: [userDefinedStage, userDefinedStageThatOverlaps],
...job1,
...job2,
...job3,
...job4,
[jobName1]: { ...job1 },
[jobName2]: { ...job2 },
[jobName3]: { ...job3 },
[jobName4]: { ...job4 },
}),
).toEqual(expectedData);
).toEqual(pipelineGraphData);
});
it('with only unique values', () => {
const expectedData = {
stages: [
{
name: job1[jobName1].stage,
name: job1.stage,
groups: [
{
name: jobName1,
jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }],
jobs: [{ ...job1 }],
id: createUniqueJobId(job1.stage, jobName1),
},
],
},
],
jobs: {
...job1,
[jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
},
};
expect(
preparePipelineGraphData({
stages: ['build'],
...job1,
...job1,
[jobName1]: { ...job1 },
[jobName1]: { ...job1 },
}),
).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