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'; ...@@ -6,6 +6,33 @@ import GpgBadges from '~/gpg_badges';
import '~/sourcegraph/load'; import '~/sourcegraph/load';
import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; 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', () => { document.addEventListener('DOMContentLoaded', () => {
new BlobViewer(); // eslint-disable-line no-new new BlobViewer(); // eslint-disable-line no-new
initBlob(); initBlob();
...@@ -63,4 +90,8 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -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'; ...@@ -4,3 +4,46 @@ import { SUPPORTED_FILTER_PARAMETERS } from './constants';
export const validateParams = params => { 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);
}; };
/**
* 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 { ...@@ -1081,3 +1081,19 @@ button.mini-pipeline-graph-dropdown-toggle {
.progress-bar.bg-primary { .progress-bar.bg-primary {
background-color: $blue-500 !important; 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 ...@@ -35,6 +35,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action only: :show do before_action only: :show do
push_frontend_feature_flag(:code_navigation, @project, default_enabled: true) 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(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
push_frontend_feature_flag(:gitlab_ci_yml_preview, @project, default_enabled: false)
end end
track_redis_hll_event :create, :update, name: 'g_edit_by_sfe', feature: :track_editor_edit_actions, feature_default_enabled: true 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 - simple_viewer = blob.simple_viewer
- rich_viewer = blob.rich_viewer - rich_viewer = blob.rich_viewer
- rich_viewer_active = rich_viewer && params[:viewer] != 'simple' - 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 = 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 .line-numbers
- if blob.data.present? - if blob.data.present?
- link_icon = sprite_icon('link', size: 12) - 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 "" ...@@ -17002,6 +17002,9 @@ msgstr ""
msgid "No containers available" msgid "No containers available"
msgstr "" msgstr ""
msgid "No content to show"
msgstr ""
msgid "No contributions" msgid "No contributions"
msgstr "" msgstr ""
...@@ -28083,6 +28086,9 @@ 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." 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 "" msgstr ""
msgid "Visualization"
msgstr ""
msgid "Vulnerabilities" msgid "Vulnerabilities"
msgstr "" 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