Commit 9b22eb48 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'show-merged-version-of-ci-config' into 'master'

Show merged version of ci config [RUN AS-IF-FOSS] [RUN ALL RSPEC]

See merge request gitlab-org/gitlab!53299
parents 8eb330d8 2c2e5351
<script>
import { uniqueId } from 'lodash';
import { GlAlert, GlIcon } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { DEFAULT, INVALID_CI_CONFIG } from '~/pipelines/constants';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
export default {
i18n: {
viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
},
errorTexts: {
[INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
[DEFAULT]: __('An unknown error occurred.'),
},
components: {
EditorLite,
GlAlert,
GlIcon,
},
inject: ['ciConfigPath'],
props: {
ciConfigData: {
type: Object,
required: true,
},
},
data() {
return {
failureType: null,
};
},
computed: {
failure() {
switch (this.failureType) {
case INVALID_CI_CONFIG:
return this.$options.errorTexts[INVALID_CI_CONFIG];
default:
return this.$options.errorTexts[DEFAULT];
}
},
fileGlobalId() {
return `${this.ciConfigPath}-${uniqueId()}`;
},
hasError() {
return this.failureType;
},
isInvalidConfiguration() {
return this.ciConfigData.status === CI_CONFIG_STATUS_INVALID;
},
mergedYaml() {
return this.ciConfigData.mergedYaml;
},
},
watch: {
ciConfigData: {
immediate: true,
handler() {
if (this.isInvalidConfiguration) {
this.reportFailure(INVALID_CI_CONFIG);
} else if (this.hasError) {
this.resetFailure();
}
},
},
},
methods: {
reportFailure(errorType) {
this.failureType = errorType;
},
resetFailure() {
this.failureType = null;
},
},
};
</script>
<template>
<div>
<gl-alert v-if="hasError" variant="danger" :dismissible="false">
{{ failure }}
</gl-alert>
<div v-else>
<div class="gl-display-flex gl-align-items-center">
<gl-icon :size="18" name="lock" class="gl-text-gray-500 gl-mr-3" />
{{ $options.i18n.viewOnlyMessage }}
</div>
<div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
<editor-lite
ref="editor"
:value="mergedYaml"
:file-name="ciConfigPath"
:file-global-id="fileGlobalId"
:editor-options="{ readOnly: true }"
v-on="$listeners"
/>
</div>
</div>
</div>
</template>
......@@ -2,7 +2,7 @@
import EditorLite from '~/vue_shared/components/editor_lite.vue';
import { CiSchemaExtension } from '~/editor/extensions/editor_ci_schema_ext';
import { EDITOR_READY_EVENT } from '~/editor/constants';
import getCommitSha from '../graphql/queries/client/commit_sha.graphql';
import getCommitSha from '../../graphql/queries/client/commit_sha.graphql';
export default {
components: {
......
<script>
import { GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
CI_CONFIG_STATUS_INVALID,
CREATE_TAB,
LINT_TAB,
MERGED_TAB,
VISUALIZE_TAB,
} from '../constants';
import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
import CiLint from './lint/ci_lint.vue';
import EditorTab from './ui/editor_tab.vue';
import TextEditor from './text_editor.vue';
import TextEditor from './editor/text_editor.vue';
export default {
i18n: {
tabEdit: s__('Pipelines|Write pipeline configuration'),
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
tabMergedYaml: s__('Pipelines|View merged YAML'),
},
errorTexts: {
loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
},
tabConstants: {
CREATE_TAB,
LINT_TAB,
MERGED_TAB,
VISUALIZE_TAB,
},
components: {
CiConfigMergedPreview,
CiLint,
EditorTab,
GlAlert,
GlLoadingIcon,
GlTab,
GlTabs,
......@@ -38,25 +58,64 @@ export default {
default: false,
},
},
computed: {
hasMergedYamlLoadError() {
return (
!this.ciConfigData?.mergedYaml && this.ciConfigData.status !== CI_CONFIG_STATUS_INVALID
);
},
},
methods: {
setCurrentTab(tabName) {
this.$emit('set-current-tab', tabName);
},
},
};
</script>
<template>
<gl-tabs class="file-editor gl-mb-3">
<editor-tab :title="$options.i18n.tabEdit" lazy data-testid="editor-tab">
<editor-tab
class="gl-mb-3"
:title="$options.i18n.tabEdit"
lazy
data-testid="editor-tab"
@click="setCurrentTab($options.tabConstants.CREATE_TAB)"
>
<text-editor :value="ciFileContent" v-on="$listeners" />
</editor-tab>
<gl-tab
v-if="glFeatures.ciConfigVisualizationTab"
class="gl-mb-3"
:title="$options.i18n.tabGraph"
lazy
data-testid="visualization-tab"
@click="setCurrentTab($options.tabConstants.VISUALIZE_TAB)"
>
<gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
<pipeline-graph v-else :pipeline-data="ciConfigData" />
</gl-tab>
<editor-tab :title="$options.i18n.tabLint" data-testid="lint-tab">
<editor-tab
class="gl-mb-3"
:title="$options.i18n.tabLint"
data-testid="lint-tab"
@click="setCurrentTab($options.tabConstants.LINT_TAB)"
>
<gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
<ci-lint v-else :ci-config="ciConfigData" />
</editor-tab>
<gl-tab
v-if="glFeatures.ciConfigMergedTab"
class="gl-mb-3"
:title="$options.i18n.tabMergedYaml"
lazy
data-testid="merged-tab"
@click="setCurrentTab($options.tabConstants.MERGED_TAB)"
>
<gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
<gl-alert v-else-if="hasMergedYamlLoadError" variant="danger" :dismissible="false">
{{ $options.errorTexts.loadMergedYaml }}
</gl-alert>
<ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" />
</gl-tab>
</gl-tabs>
</template>
......@@ -7,3 +7,10 @@ export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
export const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
export const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE';
export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
export const CREATE_TAB = 'CREATE_TAB';
export const LINT_TAB = 'LINT_TAB';
export const MERGED_TAB = 'MERGED_TAB';
export const VISUALIZE_TAB = 'VISUALIZE_TAB';
export const TABS_WITH_COMMIT_FORM = [CREATE_TAB, LINT_TAB, VISUALIZE_TAB];
......@@ -3,6 +3,7 @@
query getCiConfigData($projectPath: ID!, $content: String!) {
ciConfig(projectPath: $projectPath, content: $content) {
errors
mergedYaml
status
stages {
...PipelineStagesConnection
......
......@@ -2,6 +2,7 @@
import CommitSection from './components/commit/commit_section.vue';
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants';
export default {
components: {
......@@ -23,6 +24,21 @@ export default {
required: true,
},
},
data() {
return {
currentTab: CREATE_TAB,
};
},
computed: {
showCommitForm() {
return TABS_WITH_COMMIT_FORM.includes(this.currentTab);
},
},
methods: {
setCurrentTab(tabName) {
this.currentTab = tabName;
},
},
};
</script>
......@@ -37,7 +53,8 @@ export default {
:ci-file-content="ciFileContent"
:is-ci-config-data-loading="isCiConfigDataLoading"
v-on="$listeners"
@set-current-tab="setCurrentTab"
/>
<commit-section :ci-file-content="ciFileContent" v-on="$listeners" />
<commit-section v-if="showCommitForm" :ci-file-content="ciFileContent" v-on="$listeners" />
</div>
</template>
......@@ -4,6 +4,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
before_action do
push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml)
end
feature_category :pipeline_authoring
......
---
name: ci_config_merged_tab
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53299
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/301103
milestone: '13.9'
type: development
group: group::pipeline authoring
default_enabled: false
......@@ -25,6 +25,7 @@ From the pipeline editor page you can:
- Do a deeper [lint](#lint-ci-configuration) of your configuration, that verifies it with any configuration
added with the [`include`](../yaml/README.md#include) keyword.
- See a [visualization](#visualize-ci-configuration) of the current configuration.
- View an [expanded](#view-expanded-configuration) version of your configuration.
- [Commit](#commit-changes-to-ci-configuration) the changes to a specific branch.
NOTE:
......@@ -101,6 +102,40 @@ To enable it:
Feature.enable(:ci_config_visualization_tab)
```
## View expanded configuration
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/246801) in GitLab 13.9.
> - It is [deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-expanded-configuration). **(FREE SELF)**
To view the fully expanded CI/CD configuration as one combined file, go to the
pipeline editor's **View merged YAML** tab. This tab displays an expanded configuration
where:
- Configuration imported with [`include`](../yaml/README.md#include) is copied into the view.
- Jobs that use [`extends`](../yaml/README.md#extends) display with the
[extended configuration merged into the job](../yaml/README.md#merge-details).
- YAML anchors are [replaced with the linked configuration](../yaml/README.md#anchors).
### Enable or disable expanded configuration **(FREE SELF)**
Expanded CI/CD configuration is under development and not ready for production use.
It is deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can opt to enable it.
To enable it:
```ruby
Feature.enable(:ci_config_visualization_tab)
```
To disable it:
```ruby
Feature.disable(:ci_config_visualization_tab)
```
## Commit changes to CI configuration
The commit form appears at the bottom of each tab in the editor so you can commit
......
......@@ -21672,6 +21672,9 @@ msgstr ""
msgid "Pipelines|Copy trigger token"
msgstr ""
msgid "Pipelines|Could not load merged YAML content"
msgstr ""
msgid "Pipelines|Description"
msgstr ""
......@@ -21708,6 +21711,9 @@ msgstr ""
msgid "Pipelines|Loading Pipelines"
msgstr ""
msgid "Pipelines|Merged YAML is view only"
msgstr ""
msgid "Pipelines|More Information"
msgstr ""
......@@ -21780,6 +21786,9 @@ msgstr ""
msgid "Pipelines|Validating GitLab CI configuration…"
msgstr ""
msgid "Pipelines|View merged YAML"
msgstr ""
msgid "Pipelines|Visualize"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlAlert, GlIcon } from '@gitlab/ui';
import { EDITOR_READY_EVENT } from '~/editor/constants';
import { INVALID_CI_CONFIG } from '~/pipelines/constants';
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import { mockLintResponse, mockCiConfigPath } from '../../mock_data';
describe('Text editor component', () => {
let wrapper;
const MockEditorLite = {
template: '<div/>',
props: ['value', 'fileName', 'editorOptions'],
mounted() {
this.$emit(EDITOR_READY_EVENT);
},
};
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(CiConfigMergedPreview, {
propsData: {
ciConfigData: mockLintResponse,
...props,
},
provide: {
ciConfigPath: mockCiConfigPath,
},
stubs: {
EditorLite: MockEditorLite,
},
});
};
const findAlert = () => wrapper.findComponent(GlAlert);
const findIcon = () => wrapper.findComponent(GlIcon);
const findEditor = () => wrapper.findComponent(MockEditorLite);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when status is invalid', () => {
beforeEach(() => {
createComponent({ props: { ciConfigData: { status: CI_CONFIG_STATUS_INVALID } } });
});
it('show an error message', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[INVALID_CI_CONFIG]);
});
it('hides the editor', () => {
expect(findEditor().exists()).toBe(false);
});
});
describe('when status is valid', () => {
beforeEach(() => {
createComponent();
});
it('shows an information message that the section is not editable', () => {
expect(findIcon().exists()).toBe(true);
expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.viewOnlyMessage);
});
it('contains an editor', () => {
expect(findEditor().exists()).toBe(true);
});
it('editor contains the value provided', () => {
expect(findEditor().props('value')).toBe(mockLintResponse.mergedYaml);
});
it('editor is configured for the CI config path', () => {
expect(findEditor().props('fileName')).toBe(mockCiConfigPath);
});
it('editor is readonly', () => {
expect(findEditor().props('editorOptions')).toMatchObject({
readOnly: true,
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { EDITOR_READY_EVENT } from '~/editor/constants';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
import {
mockCiConfigPath,
mockCiYml,
mockCommitSha,
mockProjectPath,
mockProjectNamespace,
} from '../mock_data';
} from '../../mock_data';
describe('Pipeline Editor | Text editor component', () => {
let wrapper;
......
import { nextTick } from 'vue';
import { shallowMount, mount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
import { mockLintResponse, mockCiYml } from '../mock_data';
......@@ -15,6 +16,7 @@ describe('Pipeline editor tabs component', () => {
const mockProvide = {
glFeatures: {
ciConfigVisualizationTab: true,
ciConfigMergedTab: true,
},
};
......@@ -35,18 +37,21 @@ describe('Pipeline editor tabs component', () => {
const findEditorTab = () => wrapper.find('[data-testid="editor-tab"]');
const findLintTab = () => wrapper.find('[data-testid="lint-tab"]');
const findMergedTab = () => wrapper.find('[data-testid="merged-tab"]');
const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]');
const findAlert = () => wrapper.findComponent(GlAlert);
const findCiLint = () => wrapper.findComponent(CiLint);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPipelineGraph = () => wrapper.findComponent(PipelineGraph);
const findTextEditor = () => wrapper.findComponent(MockTextEditor);
const findMergedPreview = () => wrapper.findComponent(CiConfigMergedPreview);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('tabs', () => {
describe('editor tab', () => {
it('displays editor only after the tab is mounted', async () => {
createComponent({ mountFn: mount });
......@@ -125,5 +130,54 @@ describe('Pipeline editor tabs component', () => {
});
});
});
describe('merged tab', () => {
describe('with feature flag on', () => {
describe('while loading', () => {
beforeEach(() => {
createComponent({ props: { isCiConfigDataLoading: true } });
});
it('displays a loading icon if the lint query is loading', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('when `mergedYaml` is undefined', () => {
beforeEach(() => {
createComponent({ props: { ciConfigData: {} } });
});
it('show an error message', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts.loadMergedYaml);
});
it('does not render the `meged_preview` component', () => {
expect(findMergedPreview().exists()).toBe(false);
});
});
describe('after loading', () => {
beforeEach(() => {
createComponent();
});
it('display the tab and the merged preview component', () => {
expect(findMergedTab().exists()).toBe(true);
expect(findMergedPreview().exists()).toBe(true);
});
});
});
describe('with feature flag off', () => {
beforeEach(() => {
createComponent({ provide: { glFeatures: { ciConfigMergedTab: false } } });
});
it('does not display the merged tab', () => {
expect(findMergedTab().exists()).toBe(false);
expect(findMergedPreview().exists()).toBe(false);
});
});
});
});
......@@ -54,6 +54,7 @@ export const mockCiConfigQueryResponse = {
data: {
ciConfig: {
errors: [],
mergedYaml: mockCiYml,
status: CI_CONFIG_STATUS_VALID,
stages: {
__typename: 'CiConfigStageConnection',
......@@ -139,6 +140,8 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => {
export const mockLintResponse = {
valid: true,
mergedYaml: mockCiYml,
status: CI_CONFIG_STATUS_VALID,
errors: [],
warnings: [],
jobs: [
......
......@@ -3,7 +3,7 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
import httpStatusCodes from '~/lib/utils/http_status';
......
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue';
import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue';
import { MERGED_TAB, VISUALIZE_TAB } from '~/pipeline_editor/constants';
import { mockLintResponse, mockCiYml } from './mock_data';
......@@ -21,9 +23,9 @@ describe('Pipeline editor home wrapper', () => {
});
};
const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorTabs);
const findPipelineEditorTabs = () => wrapper.findComponent(CommitSection);
const findCommitSection = () => wrapper.findComponent(PipelineEditorHeader);
const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
const findCommitSection = () => wrapper.findComponent(CommitSection);
afterEach(() => {
wrapper.destroy();
......@@ -43,7 +45,33 @@ describe('Pipeline editor home wrapper', () => {
expect(findPipelineEditorTabs().exists()).toBe(true);
});
it('shows the commit section', () => {
it('shows the commit section by default', () => {
expect(findCommitSection().exists()).toBe(true);
});
});
describe('commit form toggle', () => {
beforeEach(() => {
createComponent();
});
it('hides the commit form when in the merged tab', async () => {
expect(findCommitSection().exists()).toBe(true);
findPipelineEditorTabs().vm.$emit('set-current-tab', MERGED_TAB);
await nextTick();
expect(findCommitSection().exists()).toBe(false);
});
it('shows the form again when leaving the merged tab', async () => {
expect(findCommitSection().exists()).toBe(true);
findPipelineEditorTabs().vm.$emit('set-current-tab', MERGED_TAB);
await nextTick();
expect(findCommitSection().exists()).toBe(false);
findPipelineEditorTabs().vm.$emit('set-current-tab', VISUALIZE_TAB);
await nextTick();
expect(findCommitSection().exists()).toBe(true);
});
});
......
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