Commit c3b8a8a3 authored by Miguel Rincon's avatar Miguel Rincon Committed by David O'Regan

Add CI config validation message on editor

This change adds a validation message similar to the one found in the
blo viewer to validate the CI config as the user modifies it.
parent 28a2f781
<script>
import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { CI_CONFIG_STATUS_VALID } from '../../constants';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
export const i18n = {
learnMore: __('Learn more'),
loading: s__('Pipelines|Validating GitLab CI configuration…'),
invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'),
valid: s__('Pipelines|This GitLab CI configuration is valid.'),
};
export default {
i18n,
components: {
GlIcon,
GlLink,
GlLoadingIcon,
TooltipOnTruncate,
},
inject: {
ymlHelpPagePath: {
default: '',
},
},
props: {
ciConfig: {
type: Object,
required: false,
default: () => ({}),
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isValid() {
return this.ciConfig?.status === CI_CONFIG_STATUS_VALID;
},
icon() {
if (this.isValid) {
return 'check';
}
return 'warning-solid';
},
message() {
if (this.isValid) {
return this.$options.i18n.valid;
}
// Only display first error as a reason
const [reason] = this.ciConfig?.errors || [];
if (reason) {
return sprintf(this.$options.i18n.invalidWithReason, { reason }, false);
}
return this.$options.i18n.invalid;
},
},
};
</script>
<template>
<div>
<template v-if="loading">
<gl-loading-icon inline />
{{ $options.i18n.loading }}
</template>
<span v-else class="gl-display-inline-flex gl-white-space-nowrap gl-max-w-full">
<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">
<gl-link data-testid="learnMoreLink" :href="ymlHelpPagePath">
{{ $options.i18n.learnMore }}
</gl-link>
</span>
</span>
</div>
</template>
......@@ -21,6 +21,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
newMergeRequestPath,
lintHelpPagePath,
projectPath,
ymlHelpPagePath,
} = el?.dataset;
Vue.use(VueApollo);
......@@ -34,6 +35,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
apolloProvider,
provide: {
lintHelpPagePath,
ymlHelpPagePath,
},
render(h) {
return h(PipelineEditorApp, {
......
......@@ -9,6 +9,7 @@ import CiLint from './components/lint/ci_lint.vue';
import CommitForm from './components/commit/commit_form.vue';
import EditorTab from './components/ui/editor_tab.vue';
import TextEditor from './components/text_editor.vue';
import ValidationSegment from './components/info/validation_segment.vue';
import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql';
import getBlobContent from './graphql/queries/blob_content.graphql';
......@@ -35,6 +36,7 @@ export default {
GlTabs,
PipelineGraph,
TextEditor,
ValidationSegment,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -302,6 +304,14 @@ export default {
<div class="gl-mt-4">
<gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" />
<div v-else class="file-editor gl-mb-3">
<div class="info-well gl-display-none gl-display-sm-block">
<validation-segment
class="well-segment"
:loading="isCiConfigDataLoading"
:ci-config="ciConfigData"
/>
</div>
<gl-tabs>
<editor-tab :lazy="true" :title="$options.i18n.tabEdit">
<text-editor
......
......@@ -6,4 +6,5 @@
"commit-sha" => @project.commit ? @project.commit.sha : '',
"new-merge-request-path" => namespace_project_new_merge_request_path,
"lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'),
"yml-help-page-path" => help_page_path('ci/yaml/README'),
} }
......@@ -20698,9 +20698,15 @@ msgstr ""
msgid "Pipelines|There was an error fetching the pipelines. Try again in a few moments or contact your support team."
msgstr ""
msgid "Pipelines|This GitLab CI configuration is invalid."
msgstr ""
msgid "Pipelines|This GitLab CI configuration is invalid:"
msgstr ""
msgid "Pipelines|This GitLab CI configuration is invalid: %{reason}."
msgstr ""
msgid "Pipelines|This GitLab CI configuration is valid."
msgstr ""
......
import { escape } from 'lodash';
import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
import { extendedWrapper } from 'jest/helpers/vue_test_utils_helper';
import { sprintf } from '~/locale';
import ValidationSegment, { i18n } from '~/pipeline_editor/components/info/validation_segment.vue';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import { mockYmlHelpPagePath, mergeUnwrappedCiConfig } from '../../mock_data';
describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = extendedWrapper(
shallowMount(ValidationSegment, {
provide: {
ymlHelpPagePath: mockYmlHelpPagePath,
},
propsData: {
ciConfig: mergeUnwrappedCiConfig(),
loading: false,
...props,
},
}),
);
};
const findIcon = () => wrapper.findComponent(GlIcon);
const findLearnMoreLink = () => wrapper.findByTestId('learnMoreLink');
const findValidationMsg = () => wrapper.findByTestId('validationMsg');
it('shows the loading state', () => {
createComponent({ loading: true });
expect(wrapper.text()).toBe(i18n.loading);
});
describe('when config is valid', () => {
beforeEach(() => {
createComponent({});
});
it('has check icon', () => {
expect(findIcon().props('name')).toBe('check');
});
it('shows a message for valid state', () => {
expect(findValidationMsg().text()).toContain(i18n.valid);
});
it('shows the learn more link', () => {
expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath);
expect(findLearnMoreLink().text()).toBe(i18n.learnMore);
});
});
describe('when config is not valid', () => {
beforeEach(() => {
createComponent({
ciConfig: mergeUnwrappedCiConfig({
status: CI_CONFIG_STATUS_INVALID,
}),
});
});
it('has warning icon', () => {
expect(findIcon().props('name')).toBe('warning-solid');
});
it('has message for invalid state', () => {
expect(findValidationMsg().text()).toBe(i18n.invalid);
});
it('shows an invalid state with an error', () => {
const firstError = 'First Error';
const secondError = 'Second Error';
createComponent({
ciConfig: mergeUnwrappedCiConfig({
status: CI_CONFIG_STATUS_INVALID,
errors: [firstError, secondError],
}),
});
// Test the error is shown _and_ the string matches
expect(findValidationMsg().text()).toContain(firstError);
expect(findValidationMsg().text()).toBe(
sprintf(i18n.invalidWithReason, { reason: firstError }),
);
});
it('shows an invalid state with an error while preventing XSS', () => {
const evilError = '<script>evil();</script>';
createComponent({
ciConfig: mergeUnwrappedCiConfig({
status: CI_CONFIG_STATUS_INVALID,
errors: [evilError],
}),
});
const { innerHTML } = findValidationMsg().element;
expect(innerHTML).not.toContain(evilError);
expect(innerHTML).toContain(escape(evilError));
});
it('shows the learn more link', () => {
expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath);
expect(findLearnMoreLink().text()).toBe('Learn more');
});
});
});
......@@ -2,17 +2,7 @@ import { shallowMount, mount } from '@vue/test-utils';
import { GlAlert, GlLink } from '@gitlab/ui';
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import { mockCiConfigQueryResponse, mockLintHelpPagePath } from '../../mock_data';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
const getCiConfig = (mergedConfig) => {
const { ciConfig } = mockCiConfigQueryResponse.data;
return {
...ciConfig,
stages: unwrapStagesWithNeeds(ciConfig.stages.nodes),
...mergedConfig,
};
};
import { mergeUnwrappedCiConfig, mockLintHelpPagePath } from '../../mock_data';
describe('~/pipeline_editor/components/lint/ci_lint.vue', () => {
let wrapper;
......@@ -23,7 +13,7 @@ describe('~/pipeline_editor/components/lint/ci_lint.vue', () => {
lintHelpPagePath: mockLintHelpPagePath,
},
propsData: {
ciConfig: getCiConfig(),
ciConfig: mergeUnwrappedCiConfig(),
...props,
},
});
......@@ -63,7 +53,7 @@ describe('~/pipeline_editor/components/lint/ci_lint.vue', () => {
it('displays invalid results', () => {
createComponent(
{
ciConfig: getCiConfig({
ciConfig: mergeUnwrappedCiConfig({
status: CI_CONFIG_STATUS_INVALID,
}),
},
......
import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
export const mockNamespace = 'user1';
export const mockProjectName = 'project1';
......@@ -8,6 +9,7 @@ export const mockNewMergeRequestPath = '/-/merge_requests/new';
export const mockCommitSha = 'aabbccdd';
export const mockCommitNextSha = 'eeffgghh';
export const mockLintHelpPagePath = '/-/lint-help';
export const mockYmlHelpPagePath = '/-/yml-help';
export const mockCommitMessage = 'My commit message';
export const mockCiConfigPath = '.gitlab-ci.yml';
......@@ -111,6 +113,15 @@ export const mockCiConfigQueryResponse = {
},
};
export const mergeUnwrappedCiConfig = (mergedConfig) => {
const { ciConfig } = mockCiConfigQueryResponse.data;
return {
...ciConfig,
stages: unwrapStagesWithNeeds(ciConfig.stages.nodes),
...mergedConfig,
};
};
export const mockLintResponse = {
valid: true,
errors: [],
......
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