Commit a140300d authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo

Merge branch '241722-visualization-of-gitlab-ci-yml-static-file-mvc' into 'master'

Visualization of .gitlab-ci.yml static file (MVC)

Closes #241722

See merge request gitlab-org/gitlab!40880
parents 5ea92bd6 16792919
......@@ -6,6 +6,33 @@ import GpgBadges from '~/gpg_badges';
import '~/sourcegraph/load';
import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
const createGitlabCiYmlVisualization = (containerId = '#js-blob-toggle-graph-preview') => {
const el = document.querySelector(containerId);
const { filename, blobData } = el?.dataset;
const nameRegexp = /\.gitlab-ci.yml/;
if (!el || !nameRegexp.test(filename)) {
return;
}
// eslint-disable-next-line no-new
new Vue({
el,
components: {
GitlabCiYamlVisualization: () =>
import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'),
},
render(createElement) {
return createElement('gitlabCiYamlVisualization', {
props: {
blobData,
},
});
},
});
};
document.addEventListener('DOMContentLoaded', () => {
new BlobViewer(); // eslint-disable-line no-new
initBlob();
......@@ -63,4 +90,8 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
}
if (gon?.features?.gitlabCiYmlPreview) {
createGitlabCiYmlVisualization();
}
});
<script>
import { GlTab, GlTabs } from '@gitlab/ui';
import jsYaml from 'js-yaml';
import PipelineGraph from './pipeline_graph.vue';
import { preparePipelineGraphData } from '../../utils';
export default {
FILE_CONTENT_SELECTOR: '#blob-content',
EMPTY_FILE_SELECTOR: '.nothing-here-block',
components: {
GlTab,
GlTabs,
PipelineGraph,
},
props: {
blobData: {
required: true,
type: String,
},
},
data() {
return {
selectedTabIndex: 0,
pipelineData: {},
};
},
computed: {
isVisualizationTab() {
return this.selectedTabIndex === 1;
},
},
async created() {
if (this.blobData) {
// The blobData in this case represents the gitlab-ci.yml data
const json = await jsYaml.load(this.blobData);
this.pipelineData = preparePipelineGraphData(json);
}
},
methods: {
// This is used because the blob page still uses haml, and we can't make
// our haml hide the unused section so we resort to a standard query here.
toggleFileContent({ isFileTab }) {
const el = document.querySelector(this.$options.FILE_CONTENT_SELECTOR);
const emptySection = document.querySelector(this.$options.EMPTY_FILE_SELECTOR);
const elementToHide = el || emptySection;
if (!elementToHide) {
return;
}
// Checking for the current style display prevents user
// from toggling visiblity on and off when clicking on the tab
if (!isFileTab && elementToHide.style.display !== 'none') {
elementToHide.style.display = 'none';
}
if (isFileTab && elementToHide.style.display === 'none') {
elementToHide.style.display = 'block';
}
},
},
};
</script>
<template>
<div>
<div>
<gl-tabs v-model="selectedTabIndex">
<gl-tab :title="__('File')" @click="toggleFileContent({ isFileTab: true })" />
<gl-tab :title="__('Visualization')" @click="toggleFileContent({ isFileTab: false })" />
</gl-tabs>
</div>
<pipeline-graph v-if="isVisualizationTab" :pipeline-data="pipelineData" />
</div>
</template>
<script>
import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
export default {
components: {
tooltipOnTruncate,
},
props: {
jobName: {
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 "
>
{{ jobName }}
</div>
</tooltip-on-truncate>
</template>
<script>
import { isEmpty } from 'lodash';
import { GlAlert } from '@gitlab/ui';
import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
export default {
components: {
GlAlert,
JobPill,
StagePill,
},
props: {
pipelineData: {
required: true,
type: Object,
},
},
computed: {
isPipelineDataEmpty() {
return isEmpty(this.pipelineData);
},
emptyClass() {
return !this.isPipelineDataEmpty ? 'gl-py-7' : '';
},
},
};
</script>
<template>
<div class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto" :class="emptyClass">
<gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false">
{{ __('No content to show') }}
</gl-alert>
<template v-else>
<div
v-for="(stage, index) in pipelineData.stages"
:key="`${stage.name}-${index}`"
class="gl-flex-direction-column"
>
<div
class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5"
:class="{
'stage-left-rounded': index === 0,
'stage-right-rounded': index === pipelineData.stages.length - 1,
}"
>
<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 v-for="group in stage.groups" :key="group.name" :job-name="group.name" />
</div>
</div>
</template>
</div>
</template>
<script>
import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
export default {
components: {
tooltipOnTruncate,
},
props: {
stageName: {
type: String,
required: true,
},
isEmpty: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
emptyClass() {
return this.isEmpty ? 'gl-bg-gray-200' : 'gl-bg-gray-600';
},
},
};
</script>
<template>
<tooltip-on-truncate :title="stageName" truncate-target="child" placement="top">
<div
class="gl-px-5 gl-py-2 gl-text-white gl-text-center gl-text-truncate gl-rounded-pill pipeline-stage-pill"
:class="emptyClass"
>
{{ stageName }}
</div>
</tooltip-on-truncate>
</template>
......@@ -4,3 +4,46 @@ import { SUPPORTED_FILTER_PARAMETERS } from './constants';
export const validateParams = params => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
};
/**
* This function takes a json payload that comes from a yml
* file converted to json through `jsyaml` library. Because we
* naively convert the entire yaml to json, some keys (like `includes`)
* are irrelevant to rendering the graph and must be removed. We also
* restructure the data to have the structure from an API response for the
* pipeline data.
* @param {Object} jsonData
* @returns {Array} - Array of stages containing all jobs
*/
export const preparePipelineGraphData = jsonData => {
const jsonKeys = Object.keys(jsonData);
const jobNames = jsonKeys.filter(job => jsonData[job]?.stage);
// 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
// that they added under jobs. We also remove duplicates.
const jobStages = jobNames.map(job => jsonData[job].stage);
const userDefinedStages = jsonData?.stages ?? [];
// The order is important here. We always show the stages in order they were
// defined in the `stages` key first, and then stages that are under the jobs.
const stages = Array.from(new Set([...userDefinedStages, ...jobStages]));
const arrayOfJobsByStage = stages.map(val => {
return jobNames.filter(job => {
return jsonData[job].stage === val;
});
});
const pipelineData = stages.map((stage, index) => {
const stageJobs = arrayOfJobsByStage[index];
return {
name: stage,
groups: stageJobs.map(job => {
return { name: job, jobs: [{ ...jsonData[job] }] };
}),
};
});
return { stages: pipelineData };
};
......@@ -1081,3 +1081,19 @@ button.mini-pipeline-graph-dropdown-toggle {
.progress-bar.bg-primary {
background-color: $blue-500 !important;
}
.pipeline-stage-pill {
width: 10rem;
}
.pipeline-job-pill {
width: 8rem;
}
.stage-left-rounded {
border-radius: 2rem 0 0 2rem;
}
.stage-right-rounded {
border-radius: 0 2rem 2rem 0;
}
......@@ -35,6 +35,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action only: :show do
push_frontend_feature_flag(:code_navigation, @project, default_enabled: true)
push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
push_frontend_feature_flag(:gitlab_ci_yml_preview, @project, default_enabled: false)
end
track_redis_hll_event :create, :update, name: 'g_edit_by_sfe', feature: :track_editor_edit_actions, feature_default_enabled: true
......
- simple_viewer = blob.simple_viewer
- rich_viewer = blob.rich_viewer
- rich_viewer_active = rich_viewer && params[:viewer] != 'simple'
- blob_data = defined?(@blob) ? @blob.data : {}
- filename = defined?(@blob) ? @blob.name : ''
#js-blob-toggle-graph-preview{ data: { blob_data: blob_data, filename: filename } }
= render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active
......
.file-content.code.js-syntax-highlight
#blob-content.file-content.code.js-syntax-highlight
.line-numbers
- if blob.data.present?
- link_icon = sprite_icon('link', size: 12)
......
---
name: gitlab_ci_yml_preview
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40880
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/244905
group: group::ci
type: development
default_enabled: false
......@@ -17002,6 +17002,9 @@ msgstr ""
msgid "No containers available"
msgstr ""
msgid "No content to show"
msgstr ""
msgid "No contributions"
msgstr ""
......@@ -28083,6 +28086,9 @@ msgstr ""
msgid "VisualReviewApp|Steps 1 and 2 (and sometimes 3) are performed once by the developer before requesting feedback. Steps 3 (if necessary), 4 is performed by the reviewer each time they perform a review."
msgstr ""
msgid "Visualization"
msgstr ""
msgid "Vulnerabilities"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlTab } from '@gitlab/ui';
import { yamlString } from './mock_data';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import GitlabCiYamlVisualization from '~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue';
describe('gitlab yaml visualization component', () => {
const defaultProps = { blobData: yamlString };
let wrapper;
const createComponent = props => {
return shallowMount(GitlabCiYamlVisualization, {
propsData: {
...defaultProps,
...props,
},
});
};
const findGlTabComponents = () => wrapper.findAll(GlTab);
const findPipelineGraph = () => wrapper.find(PipelineGraph);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('tabs component', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the file and visualization tabs', () => {
expect(findGlTabComponents()).toHaveLength(2);
});
});
describe('graph component', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('is hidden by default', () => {
expect(findPipelineGraph().exists()).toBe(false);
});
});
});
export const yamlString = `stages:
- empty
- build
- test
- deploy
- final
include:
- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml'
build_a:
stage: build
script: echo hello
build_b:
stage: build
script: echo hello
build_c:
stage: build
script: echo hello
build_d:
stage: Queen
script: echo hello
test_a:
stage: test
script: ls
needs: [build_a, build_b, build_c]
test_b:
stage: test
script: ls
needs: [build_a, build_b, build_d]
test_c:
stage: test
script: ls
needs: [build_a, build_b, build_c]
deploy_a:
stage: deploy
script: echo hello
`;
export const pipelineData = {
stages: [
{
name: 'build',
groups: [],
},
{
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' }],
},
],
},
],
};
import { shallowMount } from '@vue/test-utils';
import { pipelineData } from './mock_data';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue';
import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
describe('pipeline graph component', () => {
const defaultProps = { pipelineData };
let wrapper;
const createComponent = props => {
return shallowMount(PipelineGraph, {
propsData: {
...defaultProps,
...props,
},
});
};
const findAllStagePills = () => wrapper.findAll(StagePill);
const findAllJobPills = () => wrapper.findAll(JobPill);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with no data', () => {
beforeEach(() => {
wrapper = createComponent({ pipelineData: {} });
});
it('renders an empty section', () => {
expect(wrapper.text()).toContain('No content to show');
expect(findAllStagePills()).toHaveLength(0);
expect(findAllJobPills()).toHaveLength(0);
});
});
describe('with data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the right number of stage pills', () => {
const expectedStagesLength = pipelineData.stages.length;
expect(findAllStagePills()).toHaveLength(expectedStagesLength);
});
it('renders the right number of job pills', () => {
// We count the number of jobs in the mock data
const expectedJobsLength = pipelineData.stages.reduce((acc, val) => {
return acc + val.groups.length;
}, 0);
expect(findAllJobPills()).toHaveLength(expectedJobsLength);
});
});
});
import { preparePipelineGraphData } from '~/pipelines/utils';
describe('preparePipelineGraphData', () => {
const emptyResponse = { stages: [] };
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' } };
describe('returns an object with an empty array of stages if', () => {
it('no data is passed', () => {
expect(preparePipelineGraphData({})).toEqual(emptyResponse);
});
it('no stages are found', () => {
expect(preparePipelineGraphData({ includes: 'template/myTemplate.gitlab-ci.yml' })).toEqual(
emptyResponse,
);
});
});
describe('returns the correct array of stages', () => {
it('when multiple jobs are in the same stage', () => {
const expectedData = {
stages: [
{
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 }],
},
],
},
],
};
expect(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData);
});
it('when stages are defined by the user', () => {
const userDefinedStage = 'myStage';
const userDefinedStage2 = 'myStage2';
const expectedData = {
stages: [
{
name: userDefinedStage,
groups: [],
},
{
name: userDefinedStage2,
groups: [],
},
],
};
expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual(
expectedData,
);
});
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 }],
},
],
},
],
};
expect(
preparePipelineGraphData({
stages: [userDefinedStage, userDefinedStageThatOverlaps],
...job1,
...job2,
...job3,
...job4,
}),
).toEqual(expectedData);
});
it('with only unique values', () => {
const expectedData = {
stages: [
{
name: job1[jobName1].stage,
groups: [
{
name: jobName1,
jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }],
},
],
},
],
};
expect(
preparePipelineGraphData({
stages: ['build'],
...job1,
...job1,
}),
).toEqual(expectedData);
});
});
});
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