Commit 97f5e3f2 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo

Merge branch 'fc-draw-lines-for-yml-visualization' into 'master'

Implement the drawing algorithm for yaml viz

See merge request gitlab-org/gitlab!43614
parents bc514534 3128b799
......@@ -7,7 +7,7 @@ import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql'
import DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue';
import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
import { parseData } from './parsing_utils';
import { parseData } from '../parsing_utils';
import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants';
export default {
......
......@@ -10,7 +10,7 @@ import {
toggleLinkHighlight,
togglePathHighlights,
} from './interactions';
import { getMaxNodes, removeOrphanNodes } from './parsing_utils';
import { getMaxNodes, removeOrphanNodes } from '../parsing_utils';
import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
import { PARSE_FAILURE } from '../../constants';
......
import * as d3 from 'd3';
import { createUniqueJobId } from '../../utils';
/**
* This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`,
* which contains nodes and links. For each link,
* we find the nodes in the graph, calculate their coordinates and
* trace the lines that represent the needs of each job.
* @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 {ref} svg - Reference to the svg we draw in
* @returns {Array} Links that contain all the information about them
*/
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 sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId);
const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect();
const targetNodeCoordinates = targetNodeEl.getBoundingClientRect();
const containerCoordinates = containerEl.getBoundingClientRect();
// Because we add the svg dynamically and calculate the coordinates
// with plain JS and not D3, we need to account for the fact that
// the coordinates we are getting are absolutes, but we want to draw
// relative to the svg container, which starts at `containerCoordinates(x,y)`
// so we substract these from the total. We also need to remove the padding
// 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
// of the job pill.
const paddingLeft = Number(
window
.getComputedStyle(containerEl, null)
.getPropertyValue('padding-left')
.replace('px', ''),
);
const paddingTop = Number(
window
.getComputedStyle(containerEl, null)
.getPropertyValue('padding-top')
.replace('px', ''),
);
const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft;
const sourceNodeY =
sourceNodeCoordinates.top -
containerCoordinates.y -
paddingTop +
sourceNodeCoordinates.height / 2;
const targetNodeX = targetNodeCoordinates.x - containerCoordinates.x - paddingLeft;
const targetNodeY =
targetNodeCoordinates.y -
containerCoordinates.y -
paddingTop +
sourceNodeCoordinates.height / 2;
// Start point
path.moveTo(sourceNodeX, sourceNodeY);
// Add bezier curve. The first 4 coordinates are the 2 control
// points to create the curve, and the last one is the end point (x, y).
// We want our control points to be in the middle of the line
const controlPointX = sourceNodeX + (targetNodeX - sourceNodeX) / 2;
path.bezierCurveTo(
controlPointX,
sourceNodeY,
controlPointX,
targetNodeY,
targetNodeX,
targetNodeY,
);
return { ...link, path: path.toString() };
});
};
......@@ -10,13 +10,18 @@ export default {
type: String,
required: true,
},
jobId: {
type: String,
required: true,
},
},
};
</script>
<template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
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 pipeline-job-pill "
: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 "
>
{{ jobName }}
</div>
......
<script>
import { isEmpty } from 'lodash';
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
import JobPill from './job_pill.vue';
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';
export default {
components: {
......@@ -10,28 +15,112 @@ export default {
JobPill,
StagePill,
},
CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF',
CONTAINER_ID: 'pipeline-graph-container',
STROKE_WIDTH: 2,
errorTexts: {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
},
props: {
pipelineData: {
required: true,
type: Object,
},
},
data() {
return {
failureType: null,
links: [],
height: 0,
width: 0,
};
},
computed: {
isPipelineDataEmpty() {
return isEmpty(this.pipelineData);
},
emptyClass() {
return !this.isPipelineDataEmpty ? 'gl-py-7' : '';
hasError() {
return this.failureType;
},
failure() {
const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
return { text, variant: 'danger' };
},
viewBox() {
return [0, 0, this.width, this.height];
},
lineStyle() {
return `stroke-width:${this.$options.STROKE_WIDTH}px;`;
},
},
mounted() {
if (!this.isPipelineDataEmpty) {
this.getGraphDimensions();
this.drawJobLinks();
}
},
methods: {
createJobId(stageName, jobName) {
return createUniqueJobId(stageName, jobName);
},
drawJobLinks() {
const { stages, jobs } = this.pipelineData;
const unwrappedGroups = this.unwrapPipelineData(stages);
try {
const parsedData = parseData(unwrappedGroups);
this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID);
} catch {
this.reportFailure(DRAW_FAILURE);
}
},
unwrapPipelineData(stages) {
return stages
.map(({ name, groups }) => {
return groups.map(group => {
return { category: name, ...group };
});
})
.flat(2);
},
getGraphDimensions() {
this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}px`;
this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}px`;
},
reportFailure(errorType) {
this.failureType = errorType;
},
resetFailure() {
this.failureType = null;
},
},
};
</script>
<template>
<div class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto" :class="emptyClass">
<div>
<gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure">
{{ failure.text }}
</gl-alert>
<gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false">
{{ __('No content to show') }}
</gl-alert>
<template v-else>
<div
v-else
:id="$options.CONTAINER_ID"
:ref="$options.CONTAINER_REF"
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">
<path
v-for="link in links"
:key="link.path"
:d="link.path"
class="gl-stroke-gray-200 gl-fill-transparent"
:style="lineStyle"
/>
</svg>
<div
v-for="(stage, index) in pipelineData.stages"
:key="`${stage.name}-${index}`"
......@@ -49,9 +138,14 @@ export default {
<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" />
<job-pill
v-for="group in stage.groups"
:key="group.name"
:job-id="createJobId(stage.name, group.name)"
:job-name="group.name"
/>
</div>
</div>
</div>
</template>
</div>
</template>
......@@ -27,6 +27,7 @@ export const RAW_TEXT_WARNING = s__(
/* Error constants shared across graphs */
export const DEFAULT = 'default';
export const DELETE_FAILURE = 'delete_pipeline_failure';
export const DRAW_FAILURE = 'draw_failure';
export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure';
......
......@@ -18,6 +18,13 @@ export const validateParams = params => {
export const preparePipelineGraphData = jsonData => {
const jsonKeys = Object.keys(jsonData);
const jobNames = jsonKeys.filter(job => jsonData[job]?.stage);
// 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 };
}, {});
// We merge both the stages from the "stages" key in the yaml and the stage associated
// with each job to show the user both the stages they explicitly defined, and those
......@@ -45,5 +52,7 @@ export const preparePipelineGraphData = jsonData => {
};
});
return { stages: pipelineData };
return { stages: pipelineData, jobs };
};
export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`;
......@@ -7322,6 +7322,9 @@ msgstr ""
msgid "Could not delete wiki page"
msgstr ""
msgid "Could not draw the lines for job relationships"
msgstr ""
msgid "Could not find design."
msgstr ""
......
......@@ -3,7 +3,7 @@ import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants';
import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions';
import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import { removeOrphanNodes } from '~/pipelines/components/dag/parsing_utils';
import { removeOrphanNodes } from '~/pipelines/components/parsing_utils';
import { parsedData } from './mock_data';
describe('The DAG graph', () => {
......
import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import { parseData } from '~/pipelines/components/dag/parsing_utils';
import { parseData } from '~/pipelines/components/parsing_utils';
import { mockParsedGraphQLNodes } from './mock_data';
describe('DAG visualization drawing utilities', () => {
......
......@@ -5,7 +5,7 @@ import {
parseData,
removeOrphanNodes,
getMaxNodes,
} from '~/pipelines/components/dag/parsing_utils';
} from '~/pipelines/components/parsing_utils';
import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import { mockParsedGraphQLNodes } from './mock_data';
......
import { preparePipelineGraphData } from '~/pipelines/utils';
describe('preparePipelineGraphData', () => {
const emptyResponse = { stages: [] };
const emptyResponse = { stages: [], jobs: {} };
const jobName1 = 'build_1';
const jobName2 = 'build_2';
const jobName3 = 'test_1';
......@@ -11,7 +11,7 @@ describe('preparePipelineGraphData', () => {
const job3 = { [jobName3]: { script: 'echo test', stage: 'test' } };
const job4 = { [jobName4]: { script: 'echo deploy', stage: 'deploy' } };
describe('returns an object with an empty array of stages if', () => {
describe('returns an empty array of stages and empty job objects if', () => {
it('no data is passed', () => {
expect(preparePipelineGraphData({})).toEqual(emptyResponse);
});
......@@ -23,7 +23,7 @@ describe('preparePipelineGraphData', () => {
});
});
describe('returns the correct array of stages', () => {
describe('returns the correct array of stages and object of jobs', () => {
it('when multiple jobs are in the same stage', () => {
const expectedData = {
stages: [
......@@ -41,6 +41,7 @@ describe('preparePipelineGraphData', () => {
],
},
],
jobs: { ...job1, ...job2 },
};
expect(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData);
......@@ -61,6 +62,7 @@ describe('preparePipelineGraphData', () => {
groups: [],
},
],
jobs: {},
};
expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual(
......@@ -110,6 +112,12 @@ describe('preparePipelineGraphData', () => {
],
},
],
jobs: {
...job1,
...job2,
...job3,
...job4,
},
};
expect(
......@@ -136,6 +144,9 @@ describe('preparePipelineGraphData', () => {
],
},
],
jobs: {
...job1,
},
};
expect(
......
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