Commit 3448bb5d authored by Frédéric Caplette's avatar Frédéric Caplette Committed by Sarah Groff Hennigh-Palermo

Add ability to create new CI config file from empty state

- Adds a CTA button to the empty state screen in Pipeline Editor
= Create a new text editor when user selects to start working
- Allows to commit the new file to the repository
- Creates the repository if needed
parent e367975a
......@@ -31,6 +31,10 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
ciFileContent: {
type: String,
required: true,
},
ciConfigData: {
type: Object,
required: true,
......@@ -60,6 +64,7 @@ export default {
<validation-segment
:class="validationStyling"
:loading="isCiConfigDataLoading"
:ci-file-content="ciFileContent"
:ci-config="ciConfigData"
/>
</div>
......
......@@ -5,6 +5,9 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { CI_CONFIG_STATUS_VALID } from '../../constants';
export const i18n = {
empty: __(
"We'll continuously validate your pipeline configuration. The validation results will appear here.",
),
learnMore: __('Learn more'),
loading: s__('Pipelines|Validating GitLab CI configuration…'),
invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
......@@ -26,6 +29,10 @@ export default {
},
},
props: {
ciFileContent: {
type: String,
required: true,
},
ciConfig: {
type: Object,
required: false,
......@@ -38,17 +45,22 @@ export default {
},
},
computed: {
isEmpty() {
return !this.ciFileContent;
},
isValid() {
return this.ciConfig?.status === CI_CONFIG_STATUS_VALID;
},
icon() {
if (this.isValid) {
if (this.isValid || this.isEmpty) {
return 'check';
}
return 'warning-solid';
},
message() {
if (this.isValid) {
if (this.isEmpty) {
return this.$options.i18n.empty;
} else if (this.isValid) {
return this.$options.i18n.valid;
}
......@@ -74,7 +86,7 @@ export default {
<tooltip-on-truncate :title="message" class="gl-text-truncate">
<gl-icon :name="icon" /> <span data-testid="validationMsg">{{ message }}</span>
</tooltip-on-truncate>
<span class="gl-flex-shrink-0 gl-pl-2">
<span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2">
<gl-link data-testid="learnMoreLink" :href="ymlHelpPagePath">
{{ $options.i18n.learnMore }}
</gl-link>
......
<script>
import { GlSprintf } from '@gitlab/ui';
import { GlButton, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
GlButton,
GlSprintf,
},
i18n: {
......@@ -11,24 +13,44 @@ export default {
body: __(
'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.',
),
btnText: __('Create new CI/CD pipeline'),
},
mixins: [glFeatureFlagsMixin()],
inject: {
emptyStateIllustrationPath: {
default: '',
},
},
computed: {
showCTAButton() {
return this.glFeatures.pipelineEditorEmptyStateAction;
},
},
methods: {
createEmptyConfigFile() {
this.$emit('createEmptyConfigFile');
},
},
};
</script>
<template>
<div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
<img :src="emptyStateIllustrationPath" />
<h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
<p>
<p class="gl-mt-3">
<gl-sprintf :message="$options.i18n.body">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
<gl-button
v-if="showCTAButton"
variant="confirm"
class="gl-mt-3"
@click="createEmptyConfigFile"
>
{{ $options.i18n.btnText }}
</gl-button>
</div>
</template>
......@@ -36,7 +36,8 @@ export default {
// Success and failure state
failureType: null,
failureReasons: [],
hasNoCiConfigFile: false,
showStartScreen: false,
isNewConfigFile: false,
initialCiFileContent: '',
lastCommittedContent: '',
currentCiFileContent: '',
......@@ -48,6 +49,11 @@ export default {
apollo: {
initialCiFileContent: {
query: getBlobContent,
// If we are working off a new file, we don't want to fetch
// the base data as there is nothing to fetch.
skip({ isNewConfigFile }) {
return isNewConfigFile;
},
variables() {
return {
projectPath: this.projectFullPath,
......@@ -157,7 +163,7 @@ export default {
response?.status === httpStatusCodes.NOT_FOUND ||
response?.status === httpStatusCodes.BAD_REQUEST
) {
this.hasNoCiConfigFile = true;
this.showStartScreen = true;
} else {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
}
......@@ -183,6 +189,10 @@ export default {
resetContent() {
this.currentCiFileContent = this.lastCommittedContent;
},
setNewEmptyCiConfigFile() {
this.showStartScreen = false;
this.isNewConfigFile = true;
},
showErrorAlert({ type, reasons = [] }) {
this.reportFailure(type, reasons);
},
......@@ -202,7 +212,10 @@ export default {
<template>
<div class="gl-mt-4 gl-relative">
<gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" />
<pipeline-editor-empty-state v-else-if="hasNoCiConfigFile" />
<pipeline-editor-empty-state
v-else-if="showStartScreen"
@createEmptyConfigFile="setNewEmptyCiConfigFile"
/>
<div v-else>
<gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess">
{{ success.text }}
......
......@@ -45,6 +45,7 @@ export default {
<template>
<div>
<pipeline-editor-header
:ci-file-content="ciFileContent"
:ci-config-data="ciConfigData"
:is-ci-config-data-loading="isCiConfigDataLoading"
/>
......
......@@ -6,6 +6,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_status_for_pipeline_editor, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml)
end
feature_category :pipeline_authoring
......
---
name: pipeline_editor_empty_state_action
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55414
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323229
milestone: '13.10'
type: development
group: group::pipeline authoring
default_enabled: false
......@@ -8670,6 +8670,9 @@ msgstr ""
msgid "Create new %{name} by email"
msgstr ""
msgid "Create new CI/CD pipeline"
msgstr ""
msgid "Create new Value Stream"
msgstr ""
......@@ -33443,6 +33446,9 @@ msgstr ""
msgid "We would like to inform you that your subscription GitLab Enterprise Edition %{plan_name} is nearing its user limit. You have %{active_user_count} active users, which is almost at the user limit of %{maximum_user_count}."
msgstr ""
msgid "We'll continuously validate your pipeline configuration. The validation results will appear here."
msgstr ""
msgid "We've found no vulnerabilities"
msgstr ""
......
......@@ -3,7 +3,7 @@ import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_e
import PipelineStatus from '~/pipeline_editor/components/header/pipeline_status.vue';
import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue';
import { mockLintResponse } from '../../mock_data';
import { mockCiYml, mockLintResponse } from '../../mock_data';
describe('Pipeline editor header', () => {
let wrapper;
......@@ -19,8 +19,9 @@ describe('Pipeline editor header', () => {
...mockProvide,
...provide,
},
props: {
propsData: {
ciConfigData: mockLintResponse,
ciFileContent: mockCiYml,
isCiConfigDataLoading: false,
},
});
......
......@@ -7,9 +7,9 @@ import ValidationSegment, {
i18n,
} from '~/pipeline_editor/components/header/validation_segment.vue';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import { mockYmlHelpPagePath, mergeUnwrappedCiConfig } from '../../mock_data';
import { mockYmlHelpPagePath, mergeUnwrappedCiConfig, mockCiYml } from '../../mock_data';
describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
describe('Validation segment component', () => {
let wrapper;
const createComponent = (props = {}) => {
......@@ -20,6 +20,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
},
propsData: {
ciConfig: mergeUnwrappedCiConfig(),
ciFileContent: mockCiYml,
loading: false,
...props,
},
......@@ -42,6 +43,20 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
expect(wrapper.text()).toBe(i18n.loading);
});
describe('when config is empty', () => {
beforeEach(() => {
createComponent({ ciFileContent: '' });
});
it('has check icon', () => {
expect(findIcon().props('name')).toBe('check');
});
it('shows a message for empty state', () => {
expect(findValidationMsg().text()).toBe(i18n.empty);
});
});
describe('when config is valid', () => {
beforeEach(() => {
createComponent({});
......@@ -61,7 +76,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
});
});
describe('when config is not valid', () => {
describe('when config is invalid', () => {
beforeEach(() => {
createComponent({
ciConfig: mergeUnwrappedCiConfig({
......
import { GlSprintf } from '@gitlab/ui';
import { GlButton, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
describe('Pipeline editor empty state', () => {
let wrapper;
const defaultProvide = {
glFeatures: {
pipelineEditorEmptyStateAction: false,
},
emptyStateIllustrationPath: 'my/svg/path',
};
const createComponent = () => {
const createComponent = ({ provide } = {}) => {
wrapper = shallowMount(PipelineEditorEmptyState, {
provide: defaultProvide,
provide: { ...defaultProvide, ...provide },
});
};
const findSvgImage = () => wrapper.find('img');
const findTitle = () => wrapper.find('h1');
const findConfirmButton = () => wrapper.findComponent(GlButton);
const findDescription = () => wrapper.findComponent(GlSprintf);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders an svg image', () => {
expect(findSvgImage().exists()).toBe(true);
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders a title', () => {
expect(findTitle().exists()).toBe(true);
expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title);
it('renders an svg image', () => {
expect(findSvgImage().exists()).toBe(true);
});
it('renders a title', () => {
expect(findTitle().exists()).toBe(true);
expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title);
});
it('renders a description', () => {
expect(findDescription().exists()).toBe(true);
expect(findDescription().html()).toContain(wrapper.vm.$options.i18n.body);
});
describe('with feature flag off', () => {
it('does not renders a CTA button', () => {
expect(findConfirmButton().exists()).toBe(false);
});
});
});
it('renders a description', () => {
expect(findDescription().exists()).toBe(true);
expect(findDescription().html()).toContain(wrapper.vm.$options.i18n.body);
describe('with feature flag on', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: {
pipelineEditorEmptyStateAction: true,
},
},
});
});
it('renders a CTA button', () => {
expect(findConfirmButton().exists()).toBe(true);
expect(findConfirmButton().text()).toBe(wrapper.vm.$options.i18n.btnText);
});
it('emits an event when clicking on the CTA', async () => {
const expectedEvent = 'createEmptyConfigFile';
expect(wrapper.emitted(expectedEvent)).toBeUndefined();
await findConfirmButton().vm.$emit('click');
expect(wrapper.emitted(expectedEvent)).toHaveLength(1);
});
});
});
......@@ -7,6 +7,7 @@ import httpStatusCodes from '~/lib/utils/http_status';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants';
import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql';
......@@ -30,6 +31,9 @@ const MockEditorLite = {
const mockProvide = {
ciConfigPath: mockCiConfigPath,
defaultBranch: mockDefaultBranch,
glFeatures: {
pipelineEditorEmptyStateAction: false,
},
projectFullPath: mockProjectFullPath,
};
......@@ -40,14 +44,17 @@ describe('Pipeline editor app component', () => {
let mockBlobContentData;
let mockCiConfigData;
const createComponent = ({ blobLoading = false, options = {} } = {}) => {
const createComponent = ({ blobLoading = false, options = {}, provide = {} } = {}) => {
wrapper = shallowMount(PipelineEditorApp, {
provide: mockProvide,
provide: { ...mockProvide, ...provide },
stubs: {
GlTabs,
GlButton,
CommitForm,
PipelineEditorHome,
PipelineEditorTabs,
EditorLite: MockEditorLite,
PipelineEditorEmptyState,
},
mocks: {
$apollo: {
......@@ -65,7 +72,7 @@ describe('Pipeline editor app component', () => {
});
};
const createComponentWithApollo = ({ props = {} } = {}) => {
const createComponentWithApollo = ({ props = {}, provide = {} } = {}) => {
const handlers = [[getCiConfigData, mockCiConfigData]];
const resolvers = {
Query: {
......@@ -86,7 +93,7 @@ describe('Pipeline editor app component', () => {
apolloProvider: mockApollo,
};
createComponent({ props, options });
createComponent({ props, provide, options });
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
......@@ -94,6 +101,8 @@ describe('Pipeline editor app component', () => {
const findEditorHome = () => wrapper.findComponent(PipelineEditorHome);
const findTextEditor = () => wrapper.findComponent(TextEditor);
const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState);
const findEmptyStateButton = () =>
wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton);
beforeEach(() => {
mockBlobContentData = jest.fn();
......@@ -105,7 +114,6 @@ describe('Pipeline editor app component', () => {
mockCiConfigData.mockReset();
wrapper.destroy();
wrapper = null;
});
it('displays a loading icon if the blob query is loading', () => {
......@@ -196,6 +204,34 @@ describe('Pipeline editor app component', () => {
});
});
describe('when landing on the empty state with feature flag on', () => {
it('user can click on CTA button and see an empty editor', async () => {
mockBlobContentData.mockRejectedValueOnce({
response: {
status: httpStatusCodes.NOT_FOUND,
},
});
createComponentWithApollo({
provide: {
glFeatures: {
pipelineEditorEmptyStateAction: true,
},
},
});
await waitForPromises();
expect(findEmptyState().exists()).toBe(true);
expect(findTextEditor().exists()).toBe(false);
await findEmptyStateButton().vm.$emit('click');
expect(findEmptyState().exists()).toBe(false);
expect(findTextEditor().exists()).toBe(true);
});
});
describe('when the user commits', () => {
const updateFailureMessage = 'The GitLab CI configuration could not be updated.';
......
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