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

Merge branch 'fc-ci-viz-tab' into 'master'

Add visualization functionality to Authoring home

See merge request gitlab-org/gitlab!48793
parents d4f8d92b 66dbb733
export const CI_CONFIG_STATUS_VALID = 'VALID';
export const CI_CONFIG_STATUS_INVALID = 'INVALID';
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import { mergeUrlParams, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; import { mergeUrlParams, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import CommitForm from './components/commit/commit_form.vue'; import CommitForm from './components/commit/commit_form.vue';
...@@ -31,6 +32,7 @@ export default { ...@@ -31,6 +32,7 @@ export default {
PipelineGraph, PipelineGraph,
TextEditor, TextEditor,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
projectPath: { projectPath: {
type: String, type: String,
...@@ -115,6 +117,9 @@ export default { ...@@ -115,6 +117,9 @@ export default {
isBlobContentLoading() { isBlobContentLoading() {
return this.$apollo.queries.content.loading; return this.$apollo.queries.content.loading;
}, },
isVisualizationTabLoading() {
return this.$apollo.queries.ciConfigData.loading;
},
isVisualizeTabActive() { isVisualizeTabActive() {
return this.currentTabIndex === 1; return this.currentTabIndex === 1;
}, },
...@@ -266,8 +271,14 @@ export default { ...@@ -266,8 +271,14 @@ export default {
<text-editor v-model="contentModel" @editor-ready="editorIsReady = true" /> <text-editor v-model="contentModel" @editor-ready="editorIsReady = true" />
</gl-tab> </gl-tab>
<gl-tab :title="$options.i18n.tabGraph" :lazy="!isVisualizeTabActive"> <gl-tab
<pipeline-graph :pipeline-data="ciConfigData" /> v-if="glFeatures.ciConfigVisualizationTab"
:title="$options.i18n.tabGraph"
:lazy="!isVisualizeTabActive"
data-testid="visualization-tab"
>
<gl-loading-icon v-if="isVisualizationTabLoading" size="lg" class="gl-m-3" />
<pipeline-graph v-else :pipeline-data="ciConfigData" />
</gl-tab> </gl-tab>
</gl-tabs> </gl-tabs>
</div> </div>
......
import * as d3 from 'd3'; import * as d3 from 'd3';
import { createUniqueJobId } from '../../utils'; import { createUniqueLinkId } from '../../utils';
/** /**
* This function expects its first argument data structure * This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`, * to be the same shaped as the one generated by `parseData`,
...@@ -12,13 +12,13 @@ import { createUniqueJobId } from '../../utils'; ...@@ -12,13 +12,13 @@ import { createUniqueJobId } from '../../utils';
* @returns {Array} Links that contain all the information about them * @returns {Array} Links that contain all the information about them
*/ */
export const generateLinksData = ({ links }, jobs, containerID) => { export const generateLinksData = ({ links }, 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();
const sourceId = jobs[link.source].id; const sourceId = link.source;
const targetId = jobs[link.target].id; const targetId = link.target;
const sourceNodeEl = document.getElementById(sourceId); const sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId); const targetNodeEl = document.getElementById(targetId);
...@@ -89,7 +89,7 @@ export const generateLinksData = ({ links }, jobs, containerID) => { ...@@ -89,7 +89,7 @@ export const generateLinksData = ({ links }, jobs, containerID) => {
...link, ...link,
source: sourceId, source: sourceId,
target: targetId, target: targetId,
ref: createUniqueJobId(sourceId, targetId), ref: createUniqueLinkId(sourceId, targetId),
path: path.toString(), path: path.toString(),
}; };
}); });
......
...@@ -10,10 +10,6 @@ export default { ...@@ -10,10 +10,6 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
jobId: {
type: String,
required: true,
},
isHighlighted: { isHighlighted: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -45,7 +41,7 @@ export default { ...@@ -45,7 +41,7 @@ export default {
}, },
methods: { methods: {
onMouseEnter() { onMouseEnter() {
this.$emit('on-mouse-enter', this.jobId); this.$emit('on-mouse-enter', this.jobName);
}, },
onMouseLeave() { onMouseLeave() {
this.$emit('on-mouse-leave'); this.$emit('on-mouse-leave');
...@@ -56,7 +52,7 @@ export default { ...@@ -56,7 +52,7 @@ export default {
<template> <template>
<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="jobName"
class="gl-w-15 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="gl-w-15 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" :class="jobPillClasses"
@mouseover="onMouseEnter" @mouseover="onMouseEnter"
......
...@@ -6,8 +6,10 @@ import JobPill from './job_pill.vue'; ...@@ -6,8 +6,10 @@ import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue'; 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 { unwrapArrayOfJobs } from '../unwrapping_utils';
import { generateJobNeedsDict } from '../../utils'; import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
export default { export default {
components: { components: {
...@@ -22,6 +24,12 @@ export default { ...@@ -22,6 +24,12 @@ export default {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'), [DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'), [DEFAULT]: __('An unknown error occurred.'),
}, },
warningTexts: {
[EMPTY_PIPELINE_DATA]: __(
'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
),
[INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
},
props: { props: {
pipelineData: { pipelineData: {
required: true, required: true,
...@@ -40,18 +48,51 @@ export default { ...@@ -40,18 +48,51 @@ export default {
}, },
computed: { computed: {
isPipelineDataEmpty() { isPipelineDataEmpty() {
return isEmpty(this.pipelineData); return !this.isInvalidCiConfig && isEmpty(this.pipelineData?.stages);
},
isInvalidCiConfig() {
return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
},
showAlert() {
return this.hasError || this.hasWarning;
}, },
hasError() { hasError() {
return this.failureType; return this.failureType;
}, },
hasWarning() {
return this.warning;
},
hasHighlightedJob() { hasHighlightedJob() {
return Boolean(this.highlightedJob); return Boolean(this.highlightedJob);
}, },
alert() {
if (this.hasError) {
return this.failure;
}
return this.warning;
},
failure() { failure() {
const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT]; const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
return { text, variant: 'danger' }; return { text, variant: 'danger', dismissible: true };
},
warning() {
if (this.isPipelineDataEmpty) {
return {
text: this.$options.warningTexts[EMPTY_PIPELINE_DATA],
variant: 'tip',
dismissible: false,
};
} else if (this.isInvalidCiConfig) {
return {
text: this.$options.warningTexts[INVALID_CI_CONFIG],
variant: 'danger',
dismissible: false,
};
}
return null;
}, },
viewBox() { viewBox() {
return [0, 0, this.width, this.height]; return [0, 0, this.width, this.height];
...@@ -80,19 +121,21 @@ export default { ...@@ -80,19 +121,21 @@ export default {
}, },
}, },
mounted() { mounted() {
if (!this.isPipelineDataEmpty) { if (!this.isPipelineDataEmpty && !this.isInvalidCiConfig) {
// This guarantee that all sub-elements are rendered
// https://v3.vuejs.org/api/options-lifecycle-hooks.html#mounted
this.$nextTick(() => {
this.getGraphDimensions(); this.getGraphDimensions();
this.drawJobLinks(); this.prepareLinkData();
});
} }
}, },
methods: { methods: {
drawJobLinks() { prepareLinkData() {
const { stages, jobs } = this.pipelineData;
const unwrappedGroups = this.unwrapPipelineData(stages);
try { try {
const parsedData = parseData(unwrappedGroups); const arrayOfJobs = unwrapArrayOfJobs(this.pipelineData);
this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID); const parsedData = parseData(arrayOfJobs);
this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID);
} catch { } catch {
this.reportFailure(DRAW_FAILURE); this.reportFailure(DRAW_FAILURE);
} }
...@@ -119,7 +162,8 @@ export default { ...@@ -119,7 +162,8 @@ export default {
// The first time we hover, we create the object where // The first time we hover, we create the object where
// we store all the data to properly highlight the needs. // we store all the data to properly highlight the needs.
if (!this.needsObject) { if (!this.needsObject) {
this.needsObject = generateJobNeedsDict(this.pipelineData) ?? {}; const jobs = createJobsHash(this.pipelineData);
this.needsObject = generateJobNeedsDict(jobs) ?? {};
} }
this.highlightedJob = uniqueJobId; this.highlightedJob = uniqueJobId;
...@@ -127,18 +171,9 @@ export default { ...@@ -127,18 +171,9 @@ export default {
removeHighlightNeeds() { removeHighlightNeeds() {
this.highlightedJob = null; this.highlightedJob = null;
}, },
unwrapPipelineData(stages) {
return stages
.map(({ name, groups }) => {
return groups.map(group => {
return { category: name, ...group };
});
})
.flat(2);
},
getGraphDimensions() { getGraphDimensions() {
this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}px`; this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`;
this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}px`; this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`;
}, },
reportFailure(errorType) { reportFailure(errorType) {
this.failureType = errorType; this.failureType = errorType;
...@@ -163,21 +198,20 @@ export default { ...@@ -163,21 +198,20 @@ export default {
</script> </script>
<template> <template>
<div> <div>
<gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure"> <gl-alert
{{ failure.text }} v-if="showAlert"
</gl-alert> :variant="alert.variant"
<gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false"> :dismissible="alert.dismissible"
{{ @dismiss="alert.dismissible ? resetFailure : null"
__( >
'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', {{ alert.text }}
)
}}
</gl-alert> </gl-alert>
<div <div
v-else v-if="!hasWarning"
:id="$options.CONTAINER_ID" :id="$options.CONTAINER_ID"
:ref="$options.CONTAINER_REF" :ref="$options.CONTAINER_REF"
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"
data-testid="graph-container"
> >
<svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute"> <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
<template> <template>
...@@ -210,10 +244,9 @@ export default { ...@@ -210,10 +244,9 @@ 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="group.id"
:job-name="group.name" :job-name="group.name"
:is-highlighted="hasHighlightedJob && isJobHighlighted(group.id)" :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
:is-faded-out="hasHighlightedJob && !isJobHighlighted(group.id)" :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
@on-mouse-enter="highlightNeeds" @on-mouse-enter="highlightNeeds"
@on-mouse-leave="removeHighlightNeeds" @on-mouse-leave="removeHighlightNeeds"
/> />
......
/**
* This function takes the stages and add the stage name
* at the group level as `category` to have an easier
* implementation while constructions nodes with D3
* @param {Array} stages
* @returns {Array} - Array of stages with stage name at the group level as `category`
*/
export const unwrapArrayOfJobs = (stages = []) => {
return stages
.map(({ name, groups }) => {
return groups.map(group => {
return { category: name, ...group };
});
})
.flat(2);
};
const unwrapGroups = stages => { const unwrapGroups = stages => {
return stages.map(stage => { return stages.map(stage => {
const { const {
......
...@@ -28,6 +28,8 @@ export const RAW_TEXT_WARNING = s__( ...@@ -28,6 +28,8 @@ export const RAW_TEXT_WARNING = s__(
export const DEFAULT = 'default'; export const DEFAULT = 'default';
export const DELETE_FAILURE = 'delete_pipeline_failure'; export const DELETE_FAILURE = 'delete_pipeline_failure';
export const DRAW_FAILURE = 'draw_failure'; export const DRAW_FAILURE = 'draw_failure';
export const EMPTY_PIPELINE_DATA = 'empty_data';
export const INVALID_CI_CONFIG = 'invalid_ci_config';
export const LOAD_FAILURE = 'load_failure'; export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure'; export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure'; export const POST_FAILURE = 'post_failure';
......
...@@ -5,9 +5,42 @@ export const validateParams = params => { ...@@ -5,9 +5,42 @@ 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}`; export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`;
export const generateJobNeedsDict = ({ jobs }) => { /**
* This function takes the stages array and transform it
* into a hash where each key is a job name and the job data
* is associated to that key.
* @param {Array} stages
* @returns {Object} - Hash of jobs
*/
export const createJobsHash = (stages = []) => {
const jobsHash = {};
stages.forEach(stage => {
if (stage.groups.length > 0) {
stage.groups.forEach(group => {
group.jobs.forEach(job => {
jobsHash[job.name] = job;
});
});
}
});
return jobsHash;
};
/**
* This function takes the jobs hash generated by
* `createJobsHash` function and returns an easier
* structure to work with for needs relationship
* where the key is the job name and the value is an
* array of all the needs this job has recursively
* (includes the needs of the needs)
* @param {Object} jobs
* @returns {Object} - Hash of jobs and array of needs
*/
export const generateJobNeedsDict = (jobs = {}) => {
const arrOfJobNames = Object.keys(jobs); const arrOfJobNames = Object.keys(jobs);
return arrOfJobNames.reduce((acc, value) => { return arrOfJobNames.reduce((acc, value) => {
...@@ -18,13 +51,12 @@ export const generateJobNeedsDict = ({ jobs }) => { ...@@ -18,13 +51,12 @@ export const generateJobNeedsDict = ({ jobs }) => {
return jobs[jobName].needs return jobs[jobName].needs
.map(job => { .map(job => {
const { id } = jobs[job];
// If we already have the needs of a job in the accumulator, // If we already have the needs of a job in the accumulator,
// then we use the memoized data instead of the recursive call // then we use the memoized data instead of the recursive call
// to save some performance. // to save some performance.
const newNeeds = acc[id] ?? recursiveNeeds(job); const newNeeds = acc[job] ?? recursiveNeeds(job);
return [id, ...newNeeds]; return [job, ...newNeeds];
}) })
.flat(Infinity); .flat(Infinity);
}; };
...@@ -34,6 +66,6 @@ export const generateJobNeedsDict = ({ jobs }) => { ...@@ -34,6 +66,6 @@ export const generateJobNeedsDict = ({ jobs }) => {
// duplicates from the array. // duplicates from the array.
const uniqueValues = Array.from(new Set(recursiveNeeds(value))); const uniqueValues = Array.from(new Set(recursiveNeeds(value)));
return { ...acc, [jobs[value].id]: uniqueValues }; return { ...acc, [value]: uniqueValues };
}, {}); }, {});
}; };
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
class Projects::Ci::PipelineEditorController < Projects::ApplicationController class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate! before_action :check_can_collaborate!
before_action do
push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: false)
end
feature_category :pipeline_authoring feature_category :pipeline_authoring
......
---
name: ci_config_visualization_tab
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48793
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/290117
milestone: '13.7'
type: development
group: group::pipeline authoring
default_enabled: false
...@@ -7,6 +7,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -7,6 +7,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Visualize your CI/CD configuration # Visualize your CI/CD configuration
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/241722) in GitLab 13.5. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/241722) in GitLab 13.5.
> - [Moved to **CI/CD > Editor**](https://gitlab.com/gitlab-org/gitlab/-/issues/263141) in GitLab 13.7.
> - It's [deployed behind a feature flag](../../user/feature_flags.md), disabled by default. > - It's [deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
> - It's disabled on GitLab.com. > - It's disabled on GitLab.com.
> - It's not recommended for production use. > - It's not recommended for production use.
...@@ -15,16 +16,15 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -15,16 +16,15 @@ info: To determine the technical writer assigned to the Stage/Group associated w
WARNING: WARNING:
This feature might not be available to you. Check the **version history** note above for details. This feature might not be available to you. Check the **version history** note above for details.
To see a visualization of your `gitlab-ci.yml` configuration, navigate to any CI/CD To see a visualization of your `gitlab-ci.yml` configuration, navigate to **CI/CD > Editor**
configuration file and click on the `Visualization` tab. The visualization shows and select the `Visualization` tab. The visualization shows all stages and jobs.
all stages and jobs. [`needs`](README.md#needs) relationships are displayed as lines [`needs`](README.md#needs) relationships are displayed as lines connecting jobs together, showing the hierarchy of execution:
connecting jobs together, showing the hierarchy of execution:
![CI Configuration Visualization](img/ci_config_visualization_v13_5.png) ![CI Config Visualization](img/ci_config_visualization_v13_7.png)
Hovering on a job highlights its `needs` relationships: Hovering on a job highlights its `needs` relationships:
![CI Configuration Visualization on hover](img/ci_config_visualization_hover_v13_5.png) ![CI Config Visualization on hover](img/ci_config_visualization_hover_v13_7.png)
If the configuration does not have any `needs` relationships, then no lines are drawn because If the configuration does not have any `needs` relationships, then no lines are drawn because
each job depends only on the previous stage being completed successfully. each job depends only on the previous stage being completed successfully.
...@@ -42,11 +42,11 @@ can enable it. ...@@ -42,11 +42,11 @@ can enable it.
To enable it: To enable it:
```ruby ```ruby
Feature.enable(:gitlab_ci_yml_preview) Feature.enable(:ci_config_visualization_tab)
``` ```
To disable it: To disable it:
```ruby ```ruby
Feature.disable(:gitlab_ci_yml_preview) Feature.disable(:ci_config_visualization_tab)
``` ```
...@@ -32062,6 +32062,9 @@ msgstr "" ...@@ -32062,6 +32062,9 @@ msgstr ""
msgid "Your %{strong}%{plan_name}%{strong_close} subscription will expire on %{strong}%{expires_on}%{strong_close}. After that, you will not be able to create issues or merge requests as well as many other features." msgid "Your %{strong}%{plan_name}%{strong_close} subscription will expire on %{strong}%{expires_on}%{strong_close}. After that, you will not be able to create issues or merge requests as well as many other features."
msgstr "" msgstr ""
msgid "Your CI configuration file is invalid."
msgstr ""
msgid "Your CSV export has started. It will be emailed to %{email} when complete." msgid "Your CSV export has started. It will be emailed to %{email} when complete."
msgstr "" msgstr ""
......
...@@ -51,9 +51,15 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -51,9 +51,15 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
const createComponent = ({ const createComponent = ({
props = {}, props = {},
loading = false, blobLoading = false,
lintLoading = false,
options = {}, options = {},
mountFn = shallowMount, mountFn = shallowMount,
provide = {
glFeatures: {
ciConfigVisualizationTab: true,
},
},
} = {}) => { } = {}) => {
mockMutate = jest.fn().mockResolvedValue({ mockMutate = jest.fn().mockResolvedValue({
data: { data: {
...@@ -73,6 +79,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -73,6 +79,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
newMergeRequestPath: mockNewMergeRequestPath, newMergeRequestPath: mockNewMergeRequestPath,
...props, ...props,
}, },
provide,
stubs: { stubs: {
GlTabs, GlTabs,
GlButton, GlButton,
...@@ -86,7 +93,10 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -86,7 +93,10 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
$apollo: { $apollo: {
queries: { queries: {
content: { content: {
loading, loading: blobLoading,
},
ciConfigData: {
loading: lintLoading,
}, },
}, },
mutate: mockMutate, mutate: mockMutate,
...@@ -124,9 +134,12 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -124,9 +134,12 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findAlert = () => wrapper.find(GlAlert); const findAlert = () => wrapper.find(GlAlert);
const findBlobFailureAlert = () => wrapper.find(GlAlert);
const findTabAt = i => wrapper.findAll(GlTab).at(i); const findTabAt = i => wrapper.findAll(GlTab).at(i);
const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]');
const findTextEditor = () => wrapper.find(TextEditor); const findTextEditor = () => wrapper.find(TextEditor);
const findCommitForm = () => wrapper.find(CommitForm); const findCommitForm = () => wrapper.find(CommitForm);
const findPipelineGraph = () => wrapper.find(PipelineGraph);
const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').find(GlLoadingIcon); const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').find(GlLoadingIcon);
beforeEach(() => { beforeEach(() => {
...@@ -145,32 +158,28 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -145,32 +158,28 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
wrapper = null; wrapper = null;
}); });
it('displays a loading icon if the query is loading', () => { it('displays a loading icon if the blob query is loading', () => {
createComponent({ loading: true }); createComponent({ blobLoading: true });
expect(findLoadingIcon().exists()).toBe(true); expect(findLoadingIcon().exists()).toBe(true);
expect(findTextEditor().exists()).toBe(false); expect(findTextEditor().exists()).toBe(false);
}); });
describe('tabs', () => { describe('tabs', () => {
describe('editor tab', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
}); });
it('displays tabs and their content', async () => { it('displays the tab and its content', async () => {
expect( expect(
findTabAt(0) findTabAt(0)
.find(TextEditor) .find(TextEditor)
.exists(), .exists(),
).toBe(true); ).toBe(true);
expect(
findTabAt(1)
.find(PipelineGraph)
.exists(),
).toBe(true);
}); });
it('displays editor tab lazily, until editor is ready', async () => { it('displays tab lazily, until editor is ready', async () => {
expect(findTabAt(0).attributes('lazy')).toBe('true'); expect(findTabAt(0).attributes('lazy')).toBe('true');
findTextEditor().vm.$emit('editor-ready'); findTextEditor().vm.$emit('editor-ready');
...@@ -181,6 +190,36 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -181,6 +190,36 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
}); });
}); });
describe('visualization tab', () => {
describe('with feature flag on', () => {
beforeEach(() => {
createComponent();
});
it('display the tab', () => {
expect(findVisualizationTab().exists()).toBe(true);
});
it('displays a loading icon if the lint query is loading', () => {
createComponent({ lintLoading: true });
expect(findLoadingIcon().exists()).toBe(true);
expect(findPipelineGraph().exists()).toBe(false);
});
});
describe('with feature flag off', () => {
beforeEach(() => {
createComponent({ provide: { glFeatures: { ciConfigVisualizationTab: false } } });
});
it('does not display the tab', () => {
expect(findVisualizationTab().exists()).toBe(false);
});
});
});
});
describe('when data is set', () => { describe('when data is set', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent({ mountFn: mount }); createComponent({ mountFn: mount });
...@@ -359,7 +398,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -359,7 +398,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises(); await waitForPromises();
expect(findAlert().exists()).toBe(false); expect(findBlobFailureAlert().exists()).toBe(false);
expect(findTextEditor().attributes('value')).toBe(mockCiYml); expect(findTextEditor().attributes('value')).toBe(mockCiYml);
}); });
...@@ -373,7 +412,9 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -373,7 +412,9 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises(); await waitForPromises();
expect(findAlert().text()).toMatch('No CI file found in this repository, please add one.'); expect(findBlobFailureAlert().text()).toBe(
'No CI file found in this repository, please add one.',
);
}); });
it('shows a 400 error message', async () => { it('shows a 400 error message', async () => {
...@@ -386,7 +427,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -386,7 +427,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises(); await waitForPromises();
expect(findAlert().text()).toMatch( expect(findBlobFailureAlert().text()).toBe(
'Repository does not have a default branch, please set one.', 'Repository does not have a default branch, please set one.',
); );
}); });
...@@ -396,7 +437,9 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -396,7 +437,9 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
createComponentWithApollo(); createComponentWithApollo();
await waitForPromises(); await waitForPromises();
expect(findAlert().text()).toMatch('The CI configuration was not loaded, please try again.'); expect(findBlobFailureAlert().text()).toBe(
'The CI configuration was not loaded, please try again.',
);
}); });
}); });
}); });
import { createUniqueJobId } from '~/pipelines/utils'; import { createUniqueLinkId } from '~/pipelines/utils';
export const yamlString = `stages: export const yamlString = `stages:
- empty - empty
...@@ -41,10 +41,10 @@ deploy_a: ...@@ -41,10 +41,10 @@ deploy_a:
script: echo hello script: echo hello
`; `;
const jobId1 = createUniqueJobId('build', 'build_1'); const jobId1 = createUniqueLinkId('build', 'build_1');
const jobId2 = createUniqueJobId('test', 'test_1'); const jobId2 = createUniqueLinkId('test', 'test_1');
const jobId3 = createUniqueJobId('test', 'test_2'); const jobId3 = createUniqueLinkId('test', 'test_2');
const jobId4 = createUniqueJobId('deploy', 'deploy_1'); const jobId4 = createUniqueLinkId('deploy', 'deploy_1');
export const pipelineData = { export const pipelineData = {
stages: [ stages: [
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlAlert } from '@gitlab/ui';
import { pipelineData, singleStageData } from './mock_data'; import { pipelineData, singleStageData } from './mock_data';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue';
import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
...@@ -8,15 +11,16 @@ describe('pipeline graph component', () => { ...@@ -8,15 +11,16 @@ describe('pipeline graph component', () => {
const defaultProps = { pipelineData }; const defaultProps = { pipelineData };
let wrapper; let wrapper;
const createComponent = props => { const createComponent = (props = defaultProps) => {
return shallowMount(PipelineGraph, { return shallowMount(PipelineGraph, {
propsData: { propsData: {
...defaultProps,
...props, ...props,
}, },
}); });
}; };
const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]');
const findAlert = () => wrapper.find(GlAlert);
const findAllStagePills = () => wrapper.findAll(StagePill); const findAllStagePills = () => wrapper.findAll(StagePill);
const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]'); const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]');
const findStageBackgroundElementAt = index => findAllStageBackgroundElements().at(index); const findStageBackgroundElementAt = index => findAllStageBackgroundElements().at(index);
...@@ -33,54 +37,92 @@ describe('pipeline graph component', () => { ...@@ -33,54 +37,92 @@ describe('pipeline graph component', () => {
}); });
it('renders an empty section', () => { it('renders an empty section', () => {
expect(wrapper.text()).toContain( expect(wrapper.text()).toBe(wrapper.vm.$options.warningTexts[EMPTY_PIPELINE_DATA]);
'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', expect(findPipelineGraph().exists()).toBe(false);
);
expect(findAllStagePills()).toHaveLength(0); expect(findAllStagePills()).toHaveLength(0);
expect(findAllJobPills()).toHaveLength(0); expect(findAllJobPills()).toHaveLength(0);
}); });
}); });
describe('with data', () => { describe('with `INVALID` status', () => {
beforeEach(() => {
wrapper = createComponent({ pipelineData: { status: CI_CONFIG_STATUS_INVALID } });
});
it('renders an error message and does not render the graph', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(wrapper.vm.$options.warningTexts[INVALID_CI_CONFIG]);
expect(findPipelineGraph().exists()).toBe(false);
});
});
describe('without `INVALID` status', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
}); });
it('renders the graph with no status error', () => {
expect(findAlert().text()).not.toBe(wrapper.vm.$options.warningTexts[INVALID_CI_CONFIG]);
expect(findPipelineGraph().exists()).toBe(true);
});
});
describe('with error while rendering the links', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the error that link could not be drawn', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[DRAW_FAILURE]);
});
});
describe('with only one stage', () => {
beforeEach(() => {
wrapper = createComponent({ pipelineData: singleStageData });
});
it('renders the right number of stage pills', () => { it('renders the right number of stage pills', () => {
const expectedStagesLength = pipelineData.stages.length; const expectedStagesLength = singleStageData.stages.length;
expect(findAllStagePills()).toHaveLength(expectedStagesLength); expect(findAllStagePills()).toHaveLength(expectedStagesLength);
}); });
it('renders the right number of job pills', () => {
// We count the number of jobs in the mock data
const expectedJobsLength = singleStageData.stages.reduce((acc, val) => {
return acc + val.groups.length;
}, 0);
expect(findAllJobPills()).toHaveLength(expectedJobsLength);
});
describe('rounds corner', () => {
it.each` it.each`
cssClass | expectedState cssClass | expectedState
${'gl-rounded-bottom-left-6'} | ${true} ${'gl-rounded-bottom-left-6'} | ${true}
${'gl-rounded-top-left-6'} | ${true} ${'gl-rounded-top-left-6'} | ${true}
${'gl-rounded-top-right-6'} | ${false} ${'gl-rounded-top-right-6'} | ${true}
${'gl-rounded-bottom-right-6'} | ${false} ${'gl-rounded-bottom-right-6'} | ${true}
`( `('$cssClass should be $expectedState on the only element', ({ cssClass, expectedState }) => {
'rounds corner: $class should be $expectedState on the first element',
({ cssClass, expectedState }) => {
const classes = findStageBackgroundElementAt(0).classes(); const classes = findStageBackgroundElementAt(0).classes();
expect(classes.includes(cssClass)).toBe(expectedState); expect(classes.includes(cssClass)).toBe(expectedState);
}, });
); });
});
it.each` describe('with multiple stages and jobs', () => {
cssClass | expectedState beforeEach(() => {
${'gl-rounded-bottom-left-6'} | ${false} wrapper = createComponent();
${'gl-rounded-top-left-6'} | ${false} });
${'gl-rounded-top-right-6'} | ${true}
${'gl-rounded-bottom-right-6'} | ${true}
`(
'rounds corner: $class should be $expectedState on the last element',
({ cssClass, expectedState }) => {
const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes();
expect(classes.includes(cssClass)).toBe(expectedState); 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', () => { it('renders the right number of job pills', () => {
// We count the number of jobs in the mock data // We count the number of jobs in the mock data
...@@ -90,26 +132,34 @@ describe('pipeline graph component', () => { ...@@ -90,26 +132,34 @@ describe('pipeline graph component', () => {
expect(findAllJobPills()).toHaveLength(expectedJobsLength); expect(findAllJobPills()).toHaveLength(expectedJobsLength);
}); });
});
describe('with only one stage', () => {
beforeEach(() => {
wrapper = createComponent({ pipelineData: singleStageData });
});
describe('rounds corner', () => {
it.each` it.each`
cssClass | expectedState cssClass | expectedState
${'gl-rounded-bottom-left-6'} | ${true} ${'gl-rounded-bottom-left-6'} | ${true}
${'gl-rounded-top-left-6'} | ${true} ${'gl-rounded-top-left-6'} | ${true}
${'gl-rounded-top-right-6'} | ${true} ${'gl-rounded-top-right-6'} | ${false}
${'gl-rounded-bottom-right-6'} | ${true} ${'gl-rounded-bottom-right-6'} | ${false}
`( `(
'rounds corner: $class should be $expectedState on the only element', '$cssClass should be $expectedState on the first element',
({ cssClass, expectedState }) => { ({ cssClass, expectedState }) => {
const classes = findStageBackgroundElementAt(0).classes(); const classes = findStageBackgroundElementAt(0).classes();
expect(classes.includes(cssClass)).toBe(expectedState); expect(classes.includes(cssClass)).toBe(expectedState);
}, },
); );
it.each`
cssClass | expectedState
${'gl-rounded-bottom-left-6'} | ${false}
${'gl-rounded-top-left-6'} | ${false}
${'gl-rounded-top-right-6'} | ${true}
${'gl-rounded-bottom-right-6'} | ${true}
`('$cssClass should be $expectedState on the last element', ({ cssClass, expectedState }) => {
const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes();
expect(classes.includes(cssClass)).toBe(expectedState);
});
});
}); });
}); });
import { createUniqueJobId, generateJobNeedsDict } from '~/pipelines/utils'; import { createJobsHash, generateJobNeedsDict } from '~/pipelines/utils';
describe('utils functions', () => { describe('utils functions', () => {
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 = { script: 'echo hello', stage: 'build' }; const job1 = { name: jobName1, script: 'echo hello', stage: 'build' };
const job2 = { script: 'echo build', stage: 'build' }; const job2 = { name: jobName2, script: 'echo build', stage: 'build' };
const job3 = { script: 'echo test', stage: 'test', needs: [jobName1, jobName2] }; const job3 = {
const job4 = { script: 'echo deploy', stage: 'deploy', needs: [jobName3] }; name: jobName3,
script: 'echo test',
stage: 'test',
needs: [jobName1, jobName2],
};
const job4 = {
name: jobName4,
script: 'echo deploy',
stage: 'deploy',
needs: [jobName3],
};
const userDefinedStage = 'myStage'; const userDefinedStage = 'myStage';
const pipelineGraphData = { const pipelineGraphData = {
...@@ -23,7 +33,6 @@ describe('utils functions', () => { ...@@ -23,7 +33,6 @@ describe('utils functions', () => {
{ {
name: jobName4, name: jobName4,
jobs: [{ ...job4 }], jobs: [{ ...job4 }],
id: createUniqueJobId(job4.stage, jobName4),
}, },
], ],
}, },
...@@ -33,12 +42,10 @@ describe('utils functions', () => { ...@@ -33,12 +42,10 @@ describe('utils functions', () => {
{ {
name: jobName1, name: jobName1,
jobs: [{ ...job1 }], jobs: [{ ...job1 }],
id: createUniqueJobId(job1.stage, jobName1),
}, },
{ {
name: jobName2, name: jobName2,
jobs: [{ ...job2 }], jobs: [{ ...job2 }],
id: createUniqueJobId(job2.stage, jobName2),
}, },
], ],
}, },
...@@ -48,49 +55,59 @@ describe('utils functions', () => { ...@@ -48,49 +55,59 @@ describe('utils functions', () => {
{ {
name: jobName3, name: jobName3,
jobs: [{ ...job3 }], 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('createJobsHash', () => {
it('returns an empty object if there are no jobs received as argument', () => {
expect(createJobsHash([])).toEqual({});
});
it('returns a hash with the jobname as key and all its data as value', () => {
const jobs = {
[jobName1]: job1,
[jobName2]: job2,
[jobName3]: job3,
[jobName4]: job4,
};
expect(createJobsHash(pipelineGraphData.stages)).toEqual(jobs);
});
});
describe('generateJobNeedsDict', () => { describe('generateJobNeedsDict', () => {
it('generates an empty object if it receives no jobs', () => { it('generates an empty object if it receives no jobs', () => {
expect(generateJobNeedsDict({ jobs: {} })).toEqual({}); expect(generateJobNeedsDict({})).toEqual({});
}); });
it('generates a dict with empty needs if there are no dependencies', () => { it('generates a dict with empty needs if there are no dependencies', () => {
const smallGraph = { const smallGraph = {
jobs: { [jobName1]: job1,
[jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, [jobName2]: job2,
[jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) },
},
}; };
expect(generateJobNeedsDict(smallGraph)).toEqual({ expect(generateJobNeedsDict(smallGraph)).toEqual({
[pipelineGraphData.jobs[jobName1].id]: [], [jobName1]: [],
[pipelineGraphData.jobs[jobName2].id]: [], [jobName2]: [],
}); });
}); });
it('generates a dict where key is the a job and its value is an array of all its needs', () => { 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 jobsWithNeeds = {
const uniqueJobName2 = pipelineGraphData.jobs[jobName2].id; [jobName1]: job1,
const uniqueJobName3 = pipelineGraphData.jobs[jobName3].id; [jobName2]: job2,
const uniqueJobName4 = pipelineGraphData.jobs[jobName4].id; [jobName3]: job3,
[jobName4]: job4,
};
expect(generateJobNeedsDict(pipelineGraphData)).toEqual({ expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({
[uniqueJobName1]: [], [jobName1]: [],
[uniqueJobName2]: [], [jobName2]: [],
[uniqueJobName3]: [uniqueJobName1, uniqueJobName2], [jobName3]: [jobName1, jobName2],
[uniqueJobName4]: [uniqueJobName3, uniqueJobName1, uniqueJobName2], [jobName4]: [jobName3, jobName1, jobName2],
}); });
}); });
}); });
......
import { import {
unwrapArrayOfJobs,
unwrapGroups, unwrapGroups,
unwrapNodesWithName, unwrapNodesWithName,
unwrapStagesWithNeeds, unwrapStagesWithNeeds,
...@@ -94,6 +95,29 @@ const completeMock = [ ...@@ -94,6 +95,29 @@ const completeMock = [
]; ];
describe('Shared pipeline unwrapping utils', () => { describe('Shared pipeline unwrapping utils', () => {
describe('unwrapArrayOfJobs', () => {
it('returns an empty array if the input is an empty undefined', () => {
expect(unwrapArrayOfJobs(undefined)).toEqual([]);
});
it('returns an empty array if the input is an empty array', () => {
expect(unwrapArrayOfJobs([])).toEqual([]);
});
it('returns a flatten array of each job with their data and stage name', () => {
expect(
unwrapArrayOfJobs([
{ name: 'build', groups: [{ name: 'job_a_1' }, { name: 'job_a_2' }] },
{ name: 'test', groups: [{ name: 'job_b' }] },
]),
).toMatchObject([
{ category: 'build', name: 'job_a_1' },
{ category: 'build', name: 'job_a_2' },
{ category: 'test', name: 'job_b' },
]);
});
});
describe('unwrapGroups', () => { describe('unwrapGroups', () => {
it('takes stages without nodes and returns the unwrapped groups', () => { it('takes stages without nodes and returns the unwrapped groups', () => {
expect(unwrapGroups(stagesAndGroups)[0].groups).toEqual(groupsArray); expect(unwrapGroups(stagesAndGroups)[0].groups).toEqual(groupsArray);
......
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