Commit c68208dc authored by Frédéric Caplette's avatar Frédéric Caplette Committed by Sarah Groff Hennigh-Palermo

Add hover state on jobs for ci config preview

When a user hover a job in the ci config graph,
they will see their stage and jobs highlighted.
parent 6abe2c3b
...@@ -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">
<path <template>
v-for="link in links" <path
:key="link.path" v-for="link in links"
:d="link.path" :key="link.path"
class="gl-stroke-gray-200 gl-fill-transparent" :ref="link.ref"
:style="lineStyle" :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> </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';
describe('returns an empty array of stages and empty job objects if', () => {
it('no data is passed', () => {
expect(preparePipelineGraphData({})).toEqual(emptyResponse);
});
it('no stages are found', () => { const pipelineGraphData = {
expect(preparePipelineGraphData({ includes: 'template/myTemplate.gitlab-ci.yml' })).toEqual( stages: [
emptyResponse, {
); name: userDefinedStage,
}); groups: [],
}); },
{
describe('returns the correct array of stages and object of jobs', () => { name: job4.stage,
it('when multiple jobs are in the same stage', () => { groups: [
const expectedData = {
stages: [
{ {
name: job1[jobName1].stage, name: jobName4,
groups: [ jobs: [{ ...job4 }],
{ id: createUniqueJobId(job4.stage, jobName4),
name: jobName1,
jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }],
},
{
name: jobName2,
jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }],
},
],
}, },
], ],
jobs: { ...job1, ...job2 }, },
}; {
name: job1.stage,
expect(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData); groups: [
});
it('when stages are defined by the user', () => {
const userDefinedStage = 'myStage';
const userDefinedStage2 = 'myStage2';
const expectedData = {
stages: [
{ {
name: userDefinedStage, name: jobName1,
groups: [], jobs: [{ ...job1 }],
id: createUniqueJobId(job1.stage, jobName1),
}, },
{ {
name: userDefinedStage2, name: jobName2,
groups: [], jobs: [{ ...job2 }],
id: createUniqueJobId(job2.stage, jobName2),
}, },
], ],
jobs: {}, },
}; {
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) },
},
};
expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual( describe('preparePipelineGraphData', () => {
expectedData, describe('returns an empty array of stages and empty job objects if', () => {
); it('no data is passed', () => {
}); expect(preparePipelineGraphData({})).toEqual(emptyResponse);
});
it('by combining user defined stage and job stages, it preserves user defined order', () => { it('no stages are found', () => {
const userDefinedStage = 'myStage'; expect(preparePipelineGraphData({ includes: 'template/myTemplate.gitlab-ci.yml' })).toEqual(
const userDefinedStageThatOverlaps = 'deploy'; emptyResponse,
);
});
});
const expectedData = { describe('returns the correct array of stages and object of jobs', () => {
stages: [ it('when multiple jobs are in the same stage', () => {
{ const expectedData = {
name: userDefinedStage, stages: [
groups: [], {
}, name: job1.stage,
{ groups: [
name: job4[jobName4].stage, {
groups: [ name: jobName1,
{ jobs: [{ ...job1 }],
name: jobName4, id: createUniqueJobId(job1.stage, jobName1),
jobs: [{ script: job4[jobName4].script, stage: job4[jobName4].stage }], },
}, {
], name: jobName2,
}, jobs: [{ ...job2 }],
{ id: createUniqueJobId(job2.stage, jobName2),
name: job1[jobName1].stage, },
groups: [ ],
{ },
name: jobName1, ],
jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }], jobs: {
}, [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
{ [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) },
name: jobName2,
jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }],
},
],
}, },
{ };
name: job3[jobName3].stage, expect(
groups: [ preparePipelineGraphData({ [jobName1]: { ...job1 }, [jobName2]: { ...job2 } }),
{ ).toEqual(expectedData);
name: jobName3, });
jobs: [{ script: job3[jobName3].script, stage: job3[jobName3].stage }],
}, it('when stages are defined by the user', () => {
], const userDefinedStage2 = 'myStage2';
const expectedData = {
stages: [
{
name: userDefinedStage,
groups: [],
},
{
name: userDefinedStage2,
groups: [],
},
],
jobs: {},
};
expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual(
expectedData,
);
});
it('by combining user defined stage and job stages, it preserves user defined order', () => {
const userDefinedStageThatOverlaps = 'deploy';
expect(
preparePipelineGraphData({
stages: [userDefinedStage, userDefinedStageThatOverlaps],
[jobName1]: { ...job1 },
[jobName2]: { ...job2 },
[jobName3]: { ...job3 },
[jobName4]: { ...job4 },
}),
).toEqual(pipelineGraphData);
});
it('with only unique values', () => {
const expectedData = {
stages: [
{
name: job1.stage,
groups: [
{
name: jobName1,
jobs: [{ ...job1 }],
id: createUniqueJobId(job1.stage, jobName1),
},
],
},
],
jobs: {
[jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
}, },
], };
jobs: {
...job1,
...job2,
...job3,
...job4,
},
};
expect( expect(
preparePipelineGraphData({ preparePipelineGraphData({
stages: [userDefinedStage, userDefinedStageThatOverlaps], stages: ['build'],
...job1, [jobName1]: { ...job1 },
...job2, [jobName1]: { ...job1 },
...job3, }),
...job4, ).toEqual(expectedData);
}), });
).toEqual(expectedData);
}); });
});
it('with only unique values', () => { describe('generateJobNeedsDict', () => {
const expectedData = { it('generates an empty object if it receives no jobs', () => {
stages: [ expect(generateJobNeedsDict({ jobs: {} })).toEqual({});
{ });
name: job1[jobName1].stage,
groups: [ it('generates a dict with empty needs if there are no dependencies', () => {
{ const smallGraph = {
name: jobName1,
jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }],
},
],
},
],
jobs: { jobs: {
...job1, [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
[jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) },
}, },
}; };
expect( expect(generateJobNeedsDict(smallGraph)).toEqual({
preparePipelineGraphData({ [pipelineGraphData.jobs[jobName1].id]: [],
stages: ['build'], [pipelineGraphData.jobs[jobName2].id]: [],
...job1, });
...job1, });
}),
).toEqual(expectedData); 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