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') => { ...@@ -21,6 +21,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
newMergeRequestPath, newMergeRequestPath,
lintHelpPagePath, lintHelpPagePath,
projectPath, projectPath,
ymlHelpPagePath,
} = el?.dataset; } = el?.dataset;
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -34,6 +35,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ...@@ -34,6 +35,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
apolloProvider, apolloProvider,
provide: { provide: {
lintHelpPagePath, lintHelpPagePath,
ymlHelpPagePath,
}, },
render(h) { render(h) {
return h(PipelineEditorApp, { return h(PipelineEditorApp, {
......
...@@ -9,6 +9,7 @@ import CiLint from './components/lint/ci_lint.vue'; ...@@ -9,6 +9,7 @@ import CiLint from './components/lint/ci_lint.vue';
import CommitForm from './components/commit/commit_form.vue'; import CommitForm from './components/commit/commit_form.vue';
import EditorTab from './components/ui/editor_tab.vue'; import EditorTab from './components/ui/editor_tab.vue';
import TextEditor from './components/text_editor.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 commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql';
import getBlobContent from './graphql/queries/blob_content.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql';
...@@ -35,6 +36,7 @@ export default { ...@@ -35,6 +36,7 @@ export default {
GlTabs, GlTabs,
PipelineGraph, PipelineGraph,
TextEditor, TextEditor,
ValidationSegment,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
props: { props: {
...@@ -302,6 +304,14 @@ export default { ...@@ -302,6 +304,14 @@ export default {
<div class="gl-mt-4"> <div class="gl-mt-4">
<gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" />
<div v-else class="file-editor gl-mb-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> <gl-tabs>
<editor-tab :lazy="true" :title="$options.i18n.tabEdit"> <editor-tab :lazy="true" :title="$options.i18n.tabEdit">
<text-editor <text-editor
......
...@@ -6,4 +6,5 @@ ...@@ -6,4 +6,5 @@
"commit-sha" => @project.commit ? @project.commit.sha : '', "commit-sha" => @project.commit ? @project.commit.sha : '',
"new-merge-request-path" => namespace_project_new_merge_request_path, "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'), "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 "" ...@@ -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." msgid "Pipelines|There was an error fetching the pipelines. Try again in a few moments or contact your support team."
msgstr "" msgstr ""
msgid "Pipelines|This GitLab CI configuration is invalid."
msgstr ""
msgid "Pipelines|This GitLab CI configuration is invalid:" msgid "Pipelines|This GitLab CI configuration is invalid:"
msgstr "" msgstr ""
msgid "Pipelines|This GitLab CI configuration is invalid: %{reason}."
msgstr ""
msgid "Pipelines|This GitLab CI configuration is valid." msgid "Pipelines|This GitLab CI configuration is valid."
msgstr "" 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'; ...@@ -2,17 +2,7 @@ import { shallowMount, mount } from '@vue/test-utils';
import { GlAlert, GlLink } from '@gitlab/ui'; import { GlAlert, GlLink } from '@gitlab/ui';
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import { mockCiConfigQueryResponse, mockLintHelpPagePath } from '../../mock_data'; import { mergeUnwrappedCiConfig, 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,
};
};
describe('~/pipeline_editor/components/lint/ci_lint.vue', () => { describe('~/pipeline_editor/components/lint/ci_lint.vue', () => {
let wrapper; let wrapper;
...@@ -23,7 +13,7 @@ describe('~/pipeline_editor/components/lint/ci_lint.vue', () => { ...@@ -23,7 +13,7 @@ describe('~/pipeline_editor/components/lint/ci_lint.vue', () => {
lintHelpPagePath: mockLintHelpPagePath, lintHelpPagePath: mockLintHelpPagePath,
}, },
propsData: { propsData: {
ciConfig: getCiConfig(), ciConfig: mergeUnwrappedCiConfig(),
...props, ...props,
}, },
}); });
...@@ -63,7 +53,7 @@ describe('~/pipeline_editor/components/lint/ci_lint.vue', () => { ...@@ -63,7 +53,7 @@ describe('~/pipeline_editor/components/lint/ci_lint.vue', () => {
it('displays invalid results', () => { it('displays invalid results', () => {
createComponent( createComponent(
{ {
ciConfig: getCiConfig({ ciConfig: mergeUnwrappedCiConfig({
status: CI_CONFIG_STATUS_INVALID, status: CI_CONFIG_STATUS_INVALID,
}), }),
}, },
......
import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
export const mockNamespace = 'user1'; export const mockNamespace = 'user1';
export const mockProjectName = 'project1'; export const mockProjectName = 'project1';
...@@ -8,6 +9,7 @@ export const mockNewMergeRequestPath = '/-/merge_requests/new'; ...@@ -8,6 +9,7 @@ export const mockNewMergeRequestPath = '/-/merge_requests/new';
export const mockCommitSha = 'aabbccdd'; export const mockCommitSha = 'aabbccdd';
export const mockCommitNextSha = 'eeffgghh'; export const mockCommitNextSha = 'eeffgghh';
export const mockLintHelpPagePath = '/-/lint-help'; export const mockLintHelpPagePath = '/-/lint-help';
export const mockYmlHelpPagePath = '/-/yml-help';
export const mockCommitMessage = 'My commit message'; export const mockCommitMessage = 'My commit message';
export const mockCiConfigPath = '.gitlab-ci.yml'; export const mockCiConfigPath = '.gitlab-ci.yml';
...@@ -111,6 +113,15 @@ export const mockCiConfigQueryResponse = { ...@@ -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 = { export const mockLintResponse = {
valid: true, valid: true,
errors: [], 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