Commit 9903c502 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '292930-breakdown-pipeline_editor_app-into-smaller-components' into 'master'

Merge feature branch for Pipeline Editor app refactor

See merge request gitlab-org/gitlab!52200
parents bc27d459 80a8670e
<script>
import CommitForm from './commit_form.vue';
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
import getCommitSha from '../../graphql/queries/client/commit_sha.graphql';
import { COMMIT_FAILURE, COMMIT_SUCCESS } from '../../constants';
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
export default {
alertTexts: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
},
i18n: {
defaultCommitMessage: __('Update %{sourcePath} file'),
},
components: {
CommitForm,
},
inject: ['projectFullPath', 'ciConfigPath', 'defaultBranch', 'newMergeRequestPath'],
props: {
ciFileContent: {
type: String,
required: true,
},
},
data() {
return {
commit: {},
isSaving: false,
};
},
apollo: {
commitSha: {
query: getCommitSha,
},
},
computed: {
defaultCommitMessage() {
return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath });
},
},
methods: {
redirectToNewMergeRequest(sourceBranch) {
const url = mergeUrlParams(
{
[MR_SOURCE_BRANCH]: sourceBranch,
[MR_TARGET_BRANCH]: this.defaultBranch,
},
this.newMergeRequestPath,
);
redirectTo(url);
},
async onCommitSubmit({ message, branch, openMergeRequest }) {
this.isSaving = true;
try {
const {
data: {
commitCreate: { errors },
},
} = await this.$apollo.mutate({
mutation: commitCIFile,
variables: {
projectPath: this.projectFullPath,
branch,
startBranch: this.defaultBranch,
message,
filePath: this.ciConfigPath,
content: this.ciFileContent,
lastCommitId: this.commitSha,
},
update(store, { data }) {
const commitSha = data?.commitCreate?.commit?.sha;
if (commitSha) {
store.writeQuery({ query: getCommitSha, data: { commitSha } });
}
},
});
if (errors?.length) {
this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors });
} else if (openMergeRequest) {
this.redirectToNewMergeRequest(branch);
} else {
this.$emit('commit', { type: COMMIT_SUCCESS });
}
} catch (error) {
this.$emit('showError', { type: COMMIT_FAILURE, reasons: [error?.message] });
} finally {
this.isSaving = false;
}
},
onCommitCancel() {
this.$emit('resetContent');
},
},
};
</script>
<template>
<commit-form
:default-branch="defaultBranch"
:default-message="defaultCommitMessage"
:is-saving="isSaving"
@cancel="onCommitCancel"
@submit="onCommitSubmit"
/>
</template>
<script>
import ValidationSegment from './validation_segment.vue';
export default {
validationSegmentClasses:
'gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base',
components: {
ValidationSegment,
},
props: {
ciConfigData: {
type: Object,
required: true,
},
isCiConfigDataLoading: {
type: Boolean,
required: true,
},
},
};
</script>
<template>
<div class="gl-mb-5">
<validation-segment
:class="$options.validationSegmentClasses"
:loading="isCiConfigDataLoading"
:ci-config="ciConfigData"
/>
</div>
</template>
<script>
import { GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CiLint from './lint/ci_lint.vue';
import EditorTab from './ui/editor_tab.vue';
import TextEditor from './text_editor.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
export default {
i18n: {
tabEdit: s__('Pipelines|Write pipeline configuration'),
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
},
components: {
CiLint,
EditorTab,
GlLoadingIcon,
GlTab,
GlTabs,
PipelineGraph,
TextEditor,
},
mixins: [glFeatureFlagsMixin()],
props: {
ciConfigData: {
type: Object,
required: true,
},
ciFileContent: {
type: String,
required: true,
},
isCiConfigDataLoading: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<gl-tabs class="file-editor gl-mb-3">
<editor-tab :title="$options.i18n.tabEdit" lazy data-testid="editor-tab">
<text-editor :value="ciFileContent" v-on="$listeners" />
</editor-tab>
<gl-tab
v-if="glFeatures.ciConfigVisualizationTab"
:title="$options.i18n.tabGraph"
lazy
data-testid="visualization-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">
<gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
<ci-lint v-else :ci-config="ciConfigData" />
</editor-tab>
</gl-tabs>
</template>
...@@ -2,26 +2,29 @@ ...@@ -2,26 +2,29 @@
import EditorLite from '~/vue_shared/components/editor_lite.vue'; import EditorLite from '~/vue_shared/components/editor_lite.vue';
import { CiSchemaExtension } from '~/editor/extensions/editor_ci_schema_ext'; import { CiSchemaExtension } from '~/editor/extensions/editor_ci_schema_ext';
import { EDITOR_READY_EVENT } from '~/editor/constants'; import { EDITOR_READY_EVENT } from '~/editor/constants';
import getCommitSha from '../graphql/queries/client/commit_sha.graphql';
export default { export default {
components: { components: {
EditorLite, EditorLite,
}, },
inject: ['projectPath', 'projectNamespace'], inject: ['ciConfigPath', 'projectPath', 'projectNamespace'],
inheritAttrs: false, inheritAttrs: false,
props: { data() {
ciConfigPath: { return {
type: String, commitSha: '',
required: true, };
}, },
apollo: {
commitSha: { commitSha: {
type: String, query: getCommitSha,
required: false,
default: null,
}, },
}, },
methods: { methods: {
onEditorReady() { onCiConfigUpdate(content) {
this.$emit('updateCiConfig', content);
},
registerCiSchema() {
const editorInstance = this.$refs.editor.getEditor(); const editorInstance = this.$refs.editor.getEditor();
editorInstance.use(new CiSchemaExtension()); editorInstance.use(new CiSchemaExtension());
...@@ -41,7 +44,8 @@ export default { ...@@ -41,7 +44,8 @@ export default {
ref="editor" ref="editor"
:file-name="ciConfigPath" :file-name="ciConfigPath"
v-bind="$attrs" v-bind="$attrs"
@[$options.readyEvent]="onEditorReady" @[$options.readyEvent]="registerCiSchema"
@input="onCiConfigUpdate"
v-on="$listeners" v-on="$listeners"
/> />
</div> </div>
......
export const CI_CONFIG_STATUS_VALID = 'VALID'; export const CI_CONFIG_STATUS_VALID = 'VALID';
export const CI_CONFIG_STATUS_INVALID = 'INVALID'; export const CI_CONFIG_STATUS_INVALID = 'INVALID';
export const COMMIT_FAILURE = 'COMMIT_FAILURE';
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';
mutation commitCIFileMutation( mutation commitCIFile(
$projectPath: ID! $projectPath: ID!
$branch: String! $branch: String!
$startBranch: String $startBranch: String
......
...@@ -15,14 +15,13 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ...@@ -15,14 +15,13 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
} }
const { const {
// props // Add to apollo cache as it can be updated by future queries
ciConfigPath,
commitSha, commitSha,
// Add to provide/inject API for static values
ciConfigPath,
defaultBranch, defaultBranch,
newMergeRequestPath,
// `provide/inject` data
lintHelpPagePath, lintHelpPagePath,
newMergeRequestPath,
projectFullPath, projectFullPath,
projectPath, projectPath,
projectNamespace, projectNamespace,
...@@ -35,25 +34,27 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ...@@ -35,25 +34,27 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
defaultClient: createDefaultClient(resolvers, { typeDefs }), defaultClient: createDefaultClient(resolvers, { typeDefs }),
}); });
apolloProvider.clients.defaultClient.cache.writeData({
data: {
commitSha,
},
});
return new Vue({ return new Vue({
el, el,
apolloProvider, apolloProvider,
provide: { provide: {
ciConfigPath,
defaultBranch,
lintHelpPagePath, lintHelpPagePath,
newMergeRequestPath,
projectFullPath, projectFullPath,
projectPath, projectPath,
projectNamespace, projectNamespace,
ymlHelpPagePath, ymlHelpPagePath,
}, },
render(h) { render(h) {
return h(PipelineEditorApp, { return h(PipelineEditorApp);
props: {
ciConfigPath,
commitSha,
defaultBranch,
newMergeRequestPath,
},
});
}, },
}); });
}; };
<script> <script>
import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
import CiLint from './components/lint/ci_lint.vue';
import CommitForm from './components/commit/commit_form.vue';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
import EditorTab from './components/ui/editor_tab.vue'; import {
import TextEditor from './components/text_editor.vue'; COMMIT_FAILURE,
import ValidationSegment from './components/info/validation_segment.vue'; COMMIT_SUCCESS,
DEFAULT_FAILURE,
import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql'; LOAD_FAILURE_NO_FILE,
LOAD_FAILURE_UNKNOWN,
} from './constants';
import getBlobContent from './graphql/queries/blob_content.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql';
import getCiConfigData from './graphql/queries/ci_config.graphql'; import getCiConfigData from './graphql/queries/ci_config.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
const COMMIT_FAILURE = 'COMMIT_FAILURE';
const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE';
const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
export default { export default {
components: { components: {
CiLint,
CommitForm,
ConfirmUnsavedChangesDialog, ConfirmUnsavedChangesDialog,
EditorTab,
GlAlert, GlAlert,
GlLoadingIcon, GlLoadingIcon,
GlTabs, PipelineEditorHome,
GlTab,
PipelineGraph,
TextEditor,
ValidationSegment,
}, },
mixins: [glFeatureFlagsMixin()], inject: {
inject: ['projectFullPath'], ciConfigPath: {
props: { default: '',
defaultBranch: {
type: String,
required: false,
default: null,
}, },
commitSha: { defaultBranch: {
type: String,
required: false,
default: null, default: null,
}, },
ciConfigPath: { projectFullPath: {
type: String, default: '',
required: true,
},
newMergeRequestPath: {
type: String,
required: true,
}, },
}, },
data() { data() {
return { return {
ciConfigData: {}, ciConfigData: {},
content: '',
contentModel: '',
lastCommittedContent: '',
lastCommitSha: this.commitSha,
isSaving: false,
// Success and failure state // Success and failure state
failureType: null, failureType: null,
showFailureAlert: false,
failureReasons: [], failureReasons: [],
successType: null, initialCiFileContent: '',
lastCommittedContent: '',
currentCiFileContent: '',
showFailureAlert: false,
showSuccessAlert: false, showSuccessAlert: false,
successType: null,
}; };
}, },
apollo: { apollo: {
content: { initialCiFileContent: {
query: getBlobContent, query: getBlobContent,
variables() { variables() {
return { return {
...@@ -91,12 +59,13 @@ export default { ...@@ -91,12 +59,13 @@ export default {
}; };
}, },
update(data) { update(data) {
const content = data?.blobContent?.rawData; return data?.blobContent?.rawData;
this.lastCommittedContent = content;
return content;
}, },
result({ data }) { result({ data }) {
this.contentModel = data?.blobContent?.rawData ?? ''; const fileContent = data?.blobContent?.rawData ?? '';
this.lastCommittedContent = fileContent;
this.currentCiFileContent = fileContent;
}, },
error(error) { error(error) {
this.handleBlobContentError(error); this.handleBlobContentError(error);
...@@ -105,13 +74,13 @@ export default { ...@@ -105,13 +74,13 @@ export default {
ciConfigData: { ciConfigData: {
query: getCiConfigData, query: getCiConfigData,
// If content is not loaded, we can't lint the data // If content is not loaded, we can't lint the data
skip: ({ contentModel }) => { skip: ({ currentCiFileContent }) => {
return !contentModel; return !currentCiFileContent;
}, },
variables() { variables() {
return { return {
projectPath: this.projectFullPath, projectPath: this.projectFullPath,
content: this.contentModel, content: this.currentCiFileContent,
}; };
}, },
update(data) { update(data) {
...@@ -128,10 +97,10 @@ export default { ...@@ -128,10 +97,10 @@ export default {
}, },
computed: { computed: {
hasUnsavedChanges() { hasUnsavedChanges() {
return this.lastCommittedContent !== this.contentModel; return this.lastCommittedContent !== this.currentCiFileContent;
}, },
isBlobContentLoading() { isBlobContentLoading() {
return this.$apollo.queries.content.loading; return this.$apollo.queries.initialCiFileContent.loading;
}, },
isBlobContentError() { isBlobContentError() {
return this.failureType === LOAD_FAILURE_NO_FILE; return this.failureType === LOAD_FAILURE_NO_FILE;
...@@ -139,62 +108,60 @@ export default { ...@@ -139,62 +108,60 @@ export default {
isCiConfigDataLoading() { isCiConfigDataLoading() {
return this.$apollo.queries.ciConfigData.loading; return this.$apollo.queries.ciConfigData.loading;
}, },
defaultCommitMessage() {
return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath });
},
success() {
switch (this.successType) {
case COMMIT_SUCCESS:
return {
text: this.$options.alertTexts[COMMIT_SUCCESS],
variant: 'info',
};
default:
return null;
}
},
failure() { failure() {
switch (this.failureType) { switch (this.failureType) {
case LOAD_FAILURE_NO_FILE: case LOAD_FAILURE_NO_FILE:
return { return {
text: sprintf(this.$options.alertTexts[LOAD_FAILURE_NO_FILE], { text: sprintf(this.$options.errorTexts[LOAD_FAILURE_NO_FILE], {
filePath: this.ciConfigPath, filePath: this.ciConfigPath,
}), }),
variant: 'danger', variant: 'danger',
}; };
case LOAD_FAILURE_UNKNOWN: case LOAD_FAILURE_UNKNOWN:
return { return {
text: this.$options.alertTexts[LOAD_FAILURE_UNKNOWN], text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
variant: 'danger', variant: 'danger',
}; };
case COMMIT_FAILURE: case COMMIT_FAILURE:
return { return {
text: this.$options.alertTexts[COMMIT_FAILURE], text: this.$options.errorTexts[COMMIT_FAILURE],
variant: 'danger', variant: 'danger',
}; };
default: default:
return { return {
text: this.$options.alertTexts[DEFAULT_FAILURE], text: this.$options.errorTexts[DEFAULT_FAILURE],
variant: 'danger', variant: 'danger',
}; };
} }
}, },
success() {
switch (this.successType) {
case COMMIT_SUCCESS:
return {
text: this.$options.successTexts[COMMIT_SUCCESS],
variant: 'info',
};
default:
return null;
}
},
}, },
i18n: { i18n: {
defaultCommitMessage: __('Update %{sourcePath} file'),
tabEdit: s__('Pipelines|Write pipeline configuration'), tabEdit: s__('Pipelines|Write pipeline configuration'),
tabGraph: s__('Pipelines|Visualize'), tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'), tabLint: s__('Pipelines|Lint'),
}, },
alertTexts: { errorTexts: {
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
[DEFAULT_FAILURE]: __('Something went wrong on our end.'), [DEFAULT_FAILURE]: __('Something went wrong on our end.'),
[LOAD_FAILURE_NO_FILE]: s__( [LOAD_FAILURE_NO_FILE]: s__(
'Pipelines|There is no %{filePath} file in this repository, please add one and visit the Pipeline Editor again.', 'Pipelines|There is no %{filePath} file in this repository, please add one and visit the Pipeline Editor again.',
), ),
[LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
}, },
successTexts: {
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
},
methods: { methods: {
handleBlobContentError(error = {}) { handleBlobContentError(error = {}) {
const { networkError } = error; const { networkError } = error;
...@@ -215,73 +182,32 @@ export default { ...@@ -215,73 +182,32 @@ export default {
dismissFailure() { dismissFailure() {
this.showFailureAlert = false; this.showFailureAlert = false;
}, },
dismissSuccess() {
this.showSuccessAlert = false;
},
reportFailure(type, reasons = []) { reportFailure(type, reasons = []) {
this.showFailureAlert = true; this.showFailureAlert = true;
this.failureType = type; this.failureType = type;
this.failureReasons = reasons; this.failureReasons = reasons;
}, },
dismissSuccess() {
this.showSuccessAlert = false;
},
reportSuccess(type) { reportSuccess(type) {
this.showSuccessAlert = true; this.showSuccessAlert = true;
this.successType = type; this.successType = type;
}, },
resetContent() {
redirectToNewMergeRequest(sourceBranch) { this.currentCiFileContent = this.lastCommittedContent;
const url = mergeUrlParams(
{
[MR_SOURCE_BRANCH]: sourceBranch,
[MR_TARGET_BRANCH]: this.defaultBranch,
},
this.newMergeRequestPath,
);
redirectTo(url);
}, },
async onCommitSubmit(event) { showErrorAlert({ type, reasons = [] }) {
this.isSaving = true; this.reportFailure(type, reasons);
const { message, branch, openMergeRequest } = event;
try {
const {
data: {
commitCreate: { errors, commit },
},
} = await this.$apollo.mutate({
mutation: commitCiFileMutation,
variables: {
projectPath: this.projectFullPath,
branch,
startBranch: this.defaultBranch,
message,
filePath: this.ciConfigPath,
content: this.contentModel,
lastCommitId: this.lastCommitSha,
},
});
if (errors?.length) {
this.reportFailure(COMMIT_FAILURE, errors);
return;
}
if (openMergeRequest) {
this.redirectToNewMergeRequest(branch);
} else {
this.reportSuccess(COMMIT_SUCCESS);
// Update latest commit
this.lastCommitSha = commit.sha;
this.lastCommittedContent = this.contentModel;
}
} catch (error) {
this.reportFailure(COMMIT_FAILURE, [error?.message]);
} finally {
this.isSaving = false;
}
}, },
onCommitCancel() { updateCiConfig(ciFileContent) {
this.contentModel = this.content; this.currentCiFileContent = ciFileContent;
},
updateOnCommit({ type }) {
this.reportSuccess(type);
// Keep track of the latest commited content to know
// if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent;
}, },
}, },
}; };
...@@ -289,20 +215,10 @@ export default { ...@@ -289,20 +215,10 @@ export default {
<template> <template>
<div class="gl-mt-4"> <div class="gl-mt-4">
<gl-alert <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess">
v-if="showSuccessAlert"
:variant="success.variant"
:dismissible="true"
@dismiss="dismissSuccess"
>
{{ success.text }} {{ success.text }}
</gl-alert> </gl-alert>
<gl-alert <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure">
v-if="showFailureAlert"
:variant="failure.variant"
:dismissible="true"
@dismiss="dismissFailure"
>
{{ failure.text }} {{ failure.text }}
<ul v-if="failureReasons.length" class="gl-mb-0"> <ul v-if="failureReasons.length" class="gl-mb-0">
<li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
...@@ -310,45 +226,14 @@ export default { ...@@ -310,45 +226,14 @@ export default {
</gl-alert> </gl-alert>
<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-if="!isBlobContentError" class="gl-mt-4"> <div v-else-if="!isBlobContentError" class="gl-mt-4">
<div class="file-editor gl-mb-3"> <pipeline-editor-home
<div class="info-well gl-display-none gl-sm-display-block"> :is-ci-config-data-loading="isCiConfigDataLoading"
<validation-segment :ci-config-data="ciConfigData"
class="well-segment" :ci-file-content="currentCiFileContent"
:loading="isCiConfigDataLoading" @commit="updateOnCommit"
:ci-config="ciConfigData" @resetContent="resetContent"
/> @showError="showErrorAlert"
</div> @updateCiConfig="updateCiConfig"
<gl-tabs>
<editor-tab :lazy="true" :title="$options.i18n.tabEdit">
<text-editor
v-model="contentModel"
:ci-config-path="ciConfigPath"
:commit-sha="lastCommitSha"
/>
</editor-tab>
<gl-tab
v-if="glFeatures.ciConfigVisualizationTab"
:lazy="true"
:title="$options.i18n.tabGraph"
data-testid="visualization-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">
<gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
<ci-lint v-else :ci-config="ciConfigData" />
</editor-tab>
</gl-tabs>
</div>
<commit-form
:default-branch="defaultBranch"
:default-message="defaultCommitMessage"
:is-saving="isSaving"
@cancel="onCommitCancel"
@submit="onCommitSubmit"
/> />
</div> </div>
<confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" />
......
<script>
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';
export default {
components: {
CommitSection,
PipelineEditorHeader,
PipelineEditorTabs,
},
props: {
ciConfigData: {
type: Object,
required: true,
},
ciFileContent: {
type: String,
required: true,
},
isCiConfigDataLoading: {
type: Boolean,
required: true,
},
},
};
</script>
<template>
<div>
<pipeline-editor-header
:ci-config-data="ciConfigData"
:is-ci-config-data-loading="isCiConfigDataLoading"
/>
<pipeline-editor-tabs
:ci-config-data="ciConfigData"
:ci-file-content="ciFileContent"
:is-ci-config-data-loading="isCiConfigDataLoading"
v-on="$listeners"
/>
<commit-section :ci-file-content="ciFileContent" v-on="$listeners" />
</div>
</template>
...@@ -5,7 +5,7 @@ import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; ...@@ -5,7 +5,7 @@ import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import { mockCommitMessage, mockDefaultBranch } from '../../mock_data'; import { mockCommitMessage, mockDefaultBranch } from '../../mock_data';
describe('~/pipeline_editor/pipeline_editor_app.vue', () => { describe('Pipeline Editor | Commit Form', () => {
let wrapper; let wrapper;
const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
...@@ -21,8 +21,8 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -21,8 +21,8 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
}); });
}; };
const findCommitTextarea = () => wrapper.find(GlFormTextarea); const findCommitTextarea = () => wrapper.findComponent(GlFormTextarea);
const findBranchInput = () => wrapper.find(GlFormInput); const findBranchInput = () => wrapper.findComponent(GlFormInput);
const findNewMrCheckbox = () => wrapper.find('[data-testid="new-mr-checkbox"]'); const findNewMrCheckbox = () => wrapper.find('[data-testid="new-mr-checkbox"]');
const findSubmitBtn = () => wrapper.find('[type="submit"]'); const findSubmitBtn = () => wrapper.find('[type="submit"]');
const findCancelBtn = () => wrapper.find('[type="reset"]'); const findCancelBtn = () => wrapper.find('[type="reset"]');
......
import { mount } from '@vue/test-utils';
import { GlFormTextarea, GlFormInput, GlLoadingIcon } from '@gitlab/ui';
import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import { objectToQuery, redirectTo } from '~/lib/utils/url_utility';
import commitCreate from '~/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql';
import {
mockCiConfigPath,
mockCiYml,
mockCommitSha,
mockCommitNextSha,
mockCommitMessage,
mockDefaultBranch,
mockProjectFullPath,
mockNewMergeRequestPath,
} from '../../mock_data';
import { COMMIT_SUCCESS } from '~/pipeline_editor/constants';
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
refreshCurrentPage: jest.fn(),
objectToQuery: jest.requireActual('~/lib/utils/url_utility').objectToQuery,
mergeUrlParams: jest.requireActual('~/lib/utils/url_utility').mergeUrlParams,
}));
const mockVariables = {
projectPath: mockProjectFullPath,
startBranch: mockDefaultBranch,
message: mockCommitMessage,
filePath: mockCiConfigPath,
content: mockCiYml,
lastCommitId: mockCommitSha,
};
const mockProvide = {
ciConfigPath: mockCiConfigPath,
defaultBranch: mockDefaultBranch,
projectFullPath: mockProjectFullPath,
newMergeRequestPath: mockNewMergeRequestPath,
};
describe('Pipeline Editor | Commit section', () => {
let wrapper;
let mockMutate;
const defaultProps = { ciFileContent: mockCiYml };
const createComponent = ({ props = {}, options = {}, provide = {} } = {}) => {
mockMutate = jest.fn().mockResolvedValue({
data: {
commitCreate: {
errors: [],
commit: {
sha: mockCommitNextSha,
},
},
},
});
wrapper = mount(CommitSection, {
propsData: { ...defaultProps, ...props },
provide: { ...mockProvide, ...provide },
data() {
return {
commitSha: mockCommitSha,
};
},
mocks: {
$apollo: {
mutate: mockMutate,
},
},
attachTo: document.body,
...options,
});
};
const findCommitForm = () => wrapper.findComponent(CommitForm);
const findCommitBtnLoadingIcon = () =>
wrapper.find('[type="submit"]').findComponent(GlLoadingIcon);
const submitCommit = async ({
message = mockCommitMessage,
branch = mockDefaultBranch,
openMergeRequest = false,
} = {}) => {
await findCommitForm().findComponent(GlFormTextarea).setValue(message);
await findCommitForm().findComponent(GlFormInput).setValue(branch);
if (openMergeRequest) {
await findCommitForm().find('[data-testid="new-mr-checkbox"]').setChecked(openMergeRequest);
}
await findCommitForm().find('[type="submit"]').trigger('click');
// Simulate the write to local cache that occurs after a commit
await wrapper.setData({ commitSha: mockCommitNextSha });
};
const cancelCommitForm = async () => {
const findCancelBtn = () => wrapper.find('[type="reset"]');
await findCancelBtn().trigger('click');
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
mockMutate.mockReset();
wrapper.destroy();
wrapper = null;
});
describe('when the user commits changes to the current branch', () => {
beforeEach(async () => {
await submitCommit();
});
it('calls the mutation with the default branch', () => {
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith({
mutation: commitCreate,
update: expect.any(Function),
variables: {
...mockVariables,
branch: mockDefaultBranch,
},
});
});
it('emits an event to communicate the commit was successful', () => {
expect(wrapper.emitted('commit')).toHaveLength(1);
expect(wrapper.emitted('commit')[0]).toEqual([{ type: COMMIT_SUCCESS }]);
});
it('shows no saving state', () => {
expect(findCommitBtnLoadingIcon().exists()).toBe(false);
});
it('a second commit submits the latest sha, keeping the form updated', async () => {
await submitCommit();
expect(mockMutate).toHaveBeenCalledTimes(2);
expect(mockMutate).toHaveBeenCalledWith({
mutation: commitCreate,
update: expect.any(Function),
variables: {
...mockVariables,
lastCommitId: mockCommitNextSha,
branch: mockDefaultBranch,
},
});
});
});
describe('when the user commits changes to a new branch', () => {
const newBranch = 'new-branch';
beforeEach(async () => {
await submitCommit({
branch: newBranch,
});
});
it('calls the mutation with the new branch', () => {
expect(mockMutate).toHaveBeenCalledWith({
mutation: commitCreate,
update: expect.any(Function),
variables: {
...mockVariables,
branch: newBranch,
},
});
});
});
describe('when the user commits changes to open a new merge request', () => {
const newBranch = 'new-branch';
beforeEach(async () => {
await submitCommit({
branch: newBranch,
openMergeRequest: true,
});
});
it('redirects to the merge request page with source and target branches', () => {
const branchesQuery = objectToQuery({
'merge_request[source_branch]': newBranch,
'merge_request[target_branch]': mockDefaultBranch,
});
expect(redirectTo).toHaveBeenCalledWith(`${mockNewMergeRequestPath}?${branchesQuery}`);
});
});
describe('when the commit is ocurring', () => {
it('shows a saving state', async () => {
mockMutate.mockImplementationOnce(() => {
expect(findCommitBtnLoadingIcon().exists()).toBe(true);
return Promise.resolve();
});
await submitCommit({
message: mockCommitMessage,
branch: mockDefaultBranch,
openMergeRequest: false,
});
});
});
describe('when the commit form is cancelled', () => {
beforeEach(async () => {
createComponent();
});
it('emits an event so that it cab be reseted', async () => {
await cancelCommitForm();
expect(wrapper.emitted('resetContent')).toHaveLength(1);
});
});
});
import { shallowMount } from '@vue/test-utils';
import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue';
import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue';
import { mockLintResponse } from '../../mock_data';
describe('Pipeline editor header', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(PipelineEditorHeader, {
props: {
ciConfigData: mockLintResponse,
isCiConfigDataLoading: false,
},
});
};
const findValidationSegment = () => wrapper.findComponent(ValidationSegment);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders the validation segment', () => {
expect(findValidationSegment().exists()).toBe(true);
});
});
});
...@@ -3,7 +3,9 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -3,7 +3,9 @@ import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui'; import { GlIcon } from '@gitlab/ui';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import ValidationSegment, { i18n } from '~/pipeline_editor/components/info/validation_segment.vue'; import ValidationSegment, {
i18n,
} from '~/pipeline_editor/components/header/validation_segment.vue';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import { mockYmlHelpPagePath, mergeUnwrappedCiConfig } from '../../mock_data'; import { mockYmlHelpPagePath, mergeUnwrappedCiConfig } from '../../mock_data';
...@@ -29,6 +31,11 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => { ...@@ -29,6 +31,11 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
const findLearnMoreLink = () => wrapper.findByTestId('learnMoreLink'); const findLearnMoreLink = () => wrapper.findByTestId('learnMoreLink');
const findValidationMsg = () => wrapper.findByTestId('validationMsg'); const findValidationMsg = () => wrapper.findByTestId('validationMsg');
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows the loading state', () => { it('shows the loading state', () => {
createComponent({ loading: true }); createComponent({ loading: true });
......
import { nextTick } from 'vue';
import { shallowMount, mount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
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 { mockLintResponse, mockCiYml } from '../mock_data';
describe('Pipeline editor tabs component', () => {
let wrapper;
const MockTextEditor = {
template: '<div />',
};
const mockProvide = {
glFeatures: {
ciConfigVisualizationTab: true,
},
};
const createComponent = ({ props = {}, provide = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(PipelineEditorTabs, {
propsData: {
ciConfigData: mockLintResponse,
ciFileContent: mockCiYml,
isCiConfigDataLoading: false,
...props,
},
provide: { ...mockProvide, ...provide },
stubs: {
TextEditor: MockTextEditor,
},
});
};
const findEditorTab = () => wrapper.find('[data-testid="editor-tab"]');
const findLintTab = () => wrapper.find('[data-testid="lint-tab"]');
const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]');
const findCiLint = () => wrapper.findComponent(CiLint);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPipelineGraph = () => wrapper.findComponent(PipelineGraph);
const findTextEditor = () => wrapper.findComponent(MockTextEditor);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('tabs', () => {
describe('editor tab', () => {
it('displays editor only after the tab is mounted', async () => {
createComponent({ mountFn: mount });
expect(findTextEditor().exists()).toBe(false);
await nextTick();
expect(findTextEditor().exists()).toBe(true);
expect(findEditorTab().exists()).toBe(true);
});
});
describe('visualization 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);
expect(findPipelineGraph().exists()).toBe(false);
});
});
describe('after loading', () => {
beforeEach(() => {
createComponent();
});
it('display the tab and visualization', () => {
expect(findVisualizationTab().exists()).toBe(true);
expect(findPipelineGraph().exists()).toBe(true);
});
});
});
describe('with feature flag off', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: { ciConfigVisualizationTab: false },
},
});
});
it('does not display the tab or component', () => {
expect(findVisualizationTab().exists()).toBe(false);
expect(findPipelineGraph().exists()).toBe(false);
});
});
});
describe('lint tab', () => {
describe('while loading', () => {
beforeEach(() => {
createComponent({ props: { isCiConfigDataLoading: true } });
});
it('displays a loading icon if the lint query is loading', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not display the lint component', () => {
expect(findCiLint().exists()).toBe(false);
});
});
describe('after loading', () => {
beforeEach(() => {
createComponent();
});
it('display the tab and the lint component', () => {
expect(findLintTab().exists()).toBe(true);
expect(findCiLint().exists()).toBe(true);
});
});
});
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { EDITOR_READY_EVENT } from '~/editor/constants';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
import { import {
mockCiConfigPath, mockCiConfigPath,
mockCiYml, mockCiYml,
...@@ -10,7 +8,10 @@ import { ...@@ -10,7 +8,10 @@ import {
mockProjectNamespace, mockProjectNamespace,
} from '../mock_data'; } from '../mock_data';
describe('~/pipeline_editor/components/text_editor.vue', () => { import { EDITOR_READY_EVENT } from '~/editor/constants';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
describe('Pipeline Editor | Text editor component', () => {
let wrapper; let wrapper;
let editorReadyListener; let editorReadyListener;
...@@ -36,14 +37,17 @@ describe('~/pipeline_editor/components/text_editor.vue', () => { ...@@ -36,14 +37,17 @@ describe('~/pipeline_editor/components/text_editor.vue', () => {
provide: { provide: {
projectPath: mockProjectPath, projectPath: mockProjectPath,
projectNamespace: mockProjectNamespace, projectNamespace: mockProjectNamespace,
},
propsData: {
ciConfigPath: mockCiConfigPath, ciConfigPath: mockCiConfigPath,
commitSha: mockCommitSha,
}, },
attrs: { attrs: {
value: mockCiYml, value: mockCiYml,
}, },
// Simulate graphQL client query result
data() {
return {
commitSha: mockCommitSha,
};
},
listeners: { listeners: {
[EDITOR_READY_EVENT]: editorReadyListener, [EDITOR_READY_EVENT]: editorReadyListener,
}, },
...@@ -54,41 +58,64 @@ describe('~/pipeline_editor/components/text_editor.vue', () => { ...@@ -54,41 +58,64 @@ describe('~/pipeline_editor/components/text_editor.vue', () => {
}); });
}; };
const findEditor = () => wrapper.find(MockEditorLite); const findEditor = () => wrapper.findComponent(MockEditorLite);
beforeEach(() => { afterEach(() => {
editorReadyListener = jest.fn(); wrapper.destroy();
mockUse = jest.fn(); wrapper = null;
mockRegisterCiSchema = jest.fn();
createComponent(); mockUse.mockClear();
mockRegisterCiSchema.mockClear();
}); });
it('contains an editor', () => { describe('template', () => {
expect(findEditor().exists()).toBe(true); beforeEach(() => {
}); editorReadyListener = jest.fn();
mockUse = jest.fn();
mockRegisterCiSchema = jest.fn();
it('editor contains the value provided', () => { createComponent();
expect(findEditor().props('value')).toBe(mockCiYml); });
});
it('editor is configured for the CI config path', () => { it('contains an editor', () => {
expect(findEditor().props('fileName')).toBe(mockCiConfigPath); expect(findEditor().exists()).toBe(true);
}); });
it('editor is configured with syntax highligting', async () => { it('editor contains the value provided', () => {
expect(mockUse).toHaveBeenCalledTimes(1); expect(findEditor().props('value')).toBe(mockCiYml);
expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); });
expect(mockRegisterCiSchema).toHaveBeenCalledWith({
projectNamespace: mockProjectNamespace, it('editor is configured for the CI config path', () => {
projectPath: mockProjectPath, expect(findEditor().props('fileName')).toBe(mockCiConfigPath);
ref: mockCommitSha, });
it('bubbles up events', () => {
findEditor().vm.$emit(EDITOR_READY_EVENT);
expect(editorReadyListener).toHaveBeenCalled();
}); });
}); });
it('bubbles up events', () => { describe('register CI schema', () => {
findEditor().vm.$emit(EDITOR_READY_EVENT); beforeEach(async () => {
createComponent();
// Since the editor will have already mounted, the event will have fired.
// To ensure we properly test this, we clear the mock and re-remit the event.
mockRegisterCiSchema.mockClear();
mockUse.mockClear();
expect(editorReadyListener).toHaveBeenCalled(); findEditor().vm.$emit(EDITOR_READY_EVENT);
});
it('configures editor with syntax highlight', async () => {
expect(mockUse).toHaveBeenCalledTimes(1);
expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1);
expect(mockRegisterCiSchema).toHaveBeenCalledWith({
projectNamespace: mockProjectNamespace,
projectPath: mockProjectPath,
ref: mockCommitSha,
});
});
}); });
}); });
import { nextTick } from 'vue'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { GlAlert, GlButton, GlFormInput, GlFormTextarea, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import { objectToQuery, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
import { import {
mockCiConfigPath, mockCiConfigPath,
mockCiConfigQueryResponse, mockCiConfigQueryResponse,
mockCiYml, mockCiYml,
mockCommitSha,
mockCommitNextSha,
mockCommitMessage,
mockDefaultBranch, mockDefaultBranch,
mockProjectPath,
mockProjectFullPath, mockProjectFullPath,
mockProjectNamespace,
mockNewMergeRequestPath,
} from './mock_data'; } from './mock_data';
import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
refreshCurrentPage: jest.fn(),
objectToQuery: jest.requireActual('~/lib/utils/url_utility').objectToQuery,
mergeUrlParams: jest.requireActual('~/lib/utils/url_utility').mergeUrlParams,
}));
const MockEditorLite = { const MockEditorLite = {
template: '<div/>', template: '<div/>',
}; };
const mockProvide = { const mockProvide = {
ciConfigPath: mockCiConfigPath,
defaultBranch: mockDefaultBranch,
projectFullPath: mockProjectFullPath, projectFullPath: mockProjectFullPath,
projectPath: mockProjectPath,
projectNamespace: mockProjectNamespace,
glFeatures: {
ciConfigVisualizationTab: true,
},
}; };
describe('~/pipeline_editor/pipeline_editor_app.vue', () => { describe('Pipeline editor app component', () => {
let wrapper; let wrapper;
let mockApollo; let mockApollo;
let mockBlobContentData; let mockBlobContentData;
let mockCiConfigData; let mockCiConfigData;
let mockMutate;
const createComponent = ({
props = {},
blobLoading = false,
lintLoading = false,
options = {},
mountFn = shallowMount,
provide = mockProvide,
} = {}) => {
mockMutate = jest.fn().mockResolvedValue({
data: {
commitCreate: {
errors: [],
commit: {
sha: mockCommitNextSha,
},
},
},
});
wrapper = mountFn(PipelineEditorApp, { const createComponent = ({ blobLoading = false, options = {} } = {}) => {
propsData: { wrapper = shallowMount(PipelineEditorApp, {
ciConfigPath: mockCiConfigPath, provide: mockProvide,
commitSha: mockCommitSha,
defaultBranch: mockDefaultBranch,
newMergeRequestPath: mockNewMergeRequestPath,
...props,
},
provide,
stubs: { stubs: {
GlTabs, GlTabs,
GlButton, GlButton,
CommitForm, CommitForm,
EditorLite: MockEditorLite, EditorLite: MockEditorLite,
TextEditor,
}, },
mocks: { mocks: {
$apollo: { $apollo: {
queries: { queries: {
content: { initialCiFileContent: {
loading: blobLoading, loading: blobLoading,
}, },
ciConfigData: { ciConfigData: {
loading: lintLoading, loading: false,
}, },
}, },
mutate: mockMutate,
}, },
}, },
// attachTo is required for input/submit events
attachTo: mountFn === mount ? document.body : null,
...options, ...options,
}); });
}; };
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { const createComponentWithApollo = ({ props = {} } = {}) => {
const handlers = [[getCiConfigData, mockCiConfigData]]; const handlers = [[getCiConfigData, mockCiConfigData]];
const resolvers = { const resolvers = {
Query: { Query: {
...@@ -134,18 +86,13 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -134,18 +86,13 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
apolloProvider: mockApollo, apolloProvider: mockApollo,
}; };
createComponent({ props, options }, mountFn); createComponent({ props, options });
}; };
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAlert = () => wrapper.find(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
const findTabAt = (i) => wrapper.findAll(EditorTab).at(i); const findEditorHome = () => wrapper.findComponent(PipelineEditorHome);
const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]'); const findTextEditor = () => wrapper.findComponent(TextEditor);
const findTextEditor = () => wrapper.find(TextEditor);
const findEditorLite = () => wrapper.find(MockEditorLite);
const findCommitForm = () => wrapper.find(CommitForm);
const findPipelineGraph = () => wrapper.find(PipelineGraph);
const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').find(GlLoadingIcon);
beforeEach(() => { beforeEach(() => {
mockBlobContentData = jest.fn(); mockBlobContentData = jest.fn();
...@@ -155,9 +102,6 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -155,9 +102,6 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
afterEach(() => { afterEach(() => {
mockBlobContentData.mockReset(); mockBlobContentData.mockReset();
mockCiConfigData.mockReset(); mockCiConfigData.mockReset();
refreshCurrentPage.mockReset();
redirectTo.mockReset();
mockMutate.mockReset();
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
...@@ -170,245 +114,6 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -170,245 +114,6 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
expect(findTextEditor().exists()).toBe(false); expect(findTextEditor().exists()).toBe(false);
}); });
describe('tabs', () => {
describe('editor tab', () => {
it('displays editor only after the tab is mounted', async () => {
createComponent({ mountFn: mount });
expect(findTabAt(0).find(TextEditor).exists()).toBe(false);
await nextTick();
expect(findTabAt(0).find(TextEditor).exists()).toBe(true);
});
});
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: {
...mockProvide,
glFeatures: { ciConfigVisualizationTab: false },
},
});
});
it('does not display the tab', () => {
expect(findVisualizationTab().exists()).toBe(false);
});
});
});
});
describe('when data is set', () => {
beforeEach(async () => {
createComponent({ mountFn: mount });
wrapper.setData({
content: mockCiYml,
contentModel: mockCiYml,
});
await waitForPromises();
});
it('displays content after the query loads', () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findEditorLite().attributes('value')).toBe(mockCiYml);
expect(findEditorLite().attributes('file-name')).toBe(mockCiConfigPath);
});
it('configures text editor', () => {
expect(findTextEditor().props('commitSha')).toBe(mockCommitSha);
});
describe('commit form', () => {
const mockVariables = {
content: mockCiYml,
filePath: mockCiConfigPath,
lastCommitId: mockCommitSha,
message: mockCommitMessage,
projectPath: mockProjectFullPath,
startBranch: mockDefaultBranch,
};
const findInForm = (selector) => findCommitForm().find(selector);
const submitCommit = async ({
message = mockCommitMessage,
branch = mockDefaultBranch,
openMergeRequest = false,
} = {}) => {
await findInForm(GlFormTextarea).setValue(message);
await findInForm(GlFormInput).setValue(branch);
if (openMergeRequest) {
await findInForm('[data-testid="new-mr-checkbox"]').setChecked(openMergeRequest);
}
await findInForm('[type="submit"]').trigger('click');
};
const cancelCommitForm = async () => {
const findCancelBtn = () => wrapper.find('[type="reset"]');
await findCancelBtn().trigger('click');
};
describe('when the user commits changes to the current branch', () => {
beforeEach(async () => {
await submitCommit();
});
it('calls the mutation with the default branch', () => {
expect(mockMutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
variables: {
...mockVariables,
branch: mockDefaultBranch,
},
});
});
it('displays an alert to indicate success', () => {
expect(findAlert().text()).toMatchInterpolatedText(
'Your changes have been successfully committed.',
);
});
it('shows no saving state', () => {
expect(findCommitBtnLoadingIcon().exists()).toBe(false);
});
it('a second commit submits the latest sha, keeping the form updated', async () => {
await submitCommit();
expect(mockMutate).toHaveBeenCalledTimes(2);
expect(mockMutate).toHaveBeenLastCalledWith({
mutation: expect.any(Object),
variables: {
...mockVariables,
lastCommitId: mockCommitNextSha,
branch: mockDefaultBranch,
},
});
});
});
describe('when the user commits changes to a new branch', () => {
const newBranch = 'new-branch';
beforeEach(async () => {
await submitCommit({
branch: newBranch,
});
});
it('calls the mutation with the new branch', () => {
expect(mockMutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
variables: {
...mockVariables,
branch: newBranch,
},
});
});
});
describe('when the user commits changes to open a new merge request', () => {
const newBranch = 'new-branch';
beforeEach(async () => {
await submitCommit({
branch: newBranch,
openMergeRequest: true,
});
});
it('redirects to the merge request page with source and target branches', () => {
const branchesQuery = objectToQuery({
'merge_request[source_branch]': newBranch,
'merge_request[target_branch]': mockDefaultBranch,
});
expect(redirectTo).toHaveBeenCalledWith(`${mockNewMergeRequestPath}?${branchesQuery}`);
});
});
describe('when the commit is ocurring', () => {
it('shows a saving state', async () => {
await mockMutate.mockImplementationOnce(() => {
expect(findCommitBtnLoadingIcon().exists()).toBe(true);
return Promise.resolve();
});
await submitCommit({
message: mockCommitMessage,
branch: mockDefaultBranch,
openMergeRequest: false,
});
});
});
describe('when the commit fails', () => {
it('shows an error message', async () => {
mockMutate.mockRejectedValueOnce(new Error('commit failed'));
await submitCommit();
await waitForPromises();
expect(findAlert().text()).toMatchInterpolatedText(
'The GitLab CI configuration could not be updated. commit failed',
);
});
it('shows an unkown error', async () => {
mockMutate.mockRejectedValueOnce();
await submitCommit();
await waitForPromises();
expect(findAlert().text()).toMatchInterpolatedText(
'The GitLab CI configuration could not be updated.',
);
});
});
describe('when the commit form is cancelled', () => {
const otherContent = 'other content';
beforeEach(async () => {
findTextEditor().vm.$emit('input', otherContent);
await nextTick();
});
it('content is restored after cancel is called', async () => {
await cancelCommitForm();
expect(findEditorLite().attributes('value')).toBe(mockCiYml);
});
});
});
});
describe('when queries are called', () => { describe('when queries are called', () => {
beforeEach(() => { beforeEach(() => {
mockBlobContentData.mockResolvedValue(mockCiYml); mockBlobContentData.mockResolvedValue(mockCiYml);
...@@ -422,14 +127,12 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -422,14 +127,12 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises(); await waitForPromises();
}); });
it('shows editor and commit form', () => { it('shows pipeline editor home component', () => {
expect(findEditorLite().exists()).toBe(true); expect(findEditorHome().exists()).toBe(true);
expect(findTextEditor().exists()).toBe(true);
}); });
it('no error is shown when data is set', async () => { it('no error is shown when data is set', () => {
expect(findAlert().exists()).toBe(false); expect(findAlert().exists()).toBe(false);
expect(findEditorLite().attributes('value')).toBe(mockCiYml);
}); });
it('ci config query is called with correct variables', async () => { it('ci config query is called with correct variables', async () => {
...@@ -445,10 +148,10 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -445,10 +148,10 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
}); });
describe('when no file exists', () => { describe('when no file exists', () => {
const expectedAlertMsg = const noFileAlertMsg =
'There is no .gitlab-ci.yml file in this repository, please add one and visit the Pipeline Editor again.'; 'There is no .gitlab-ci.yml file in this repository, please add one and visit the Pipeline Editor again.';
it('shows a 404 error message and does not show editor or commit form', async () => { it('shows a 404 error message and does not show editor home component', async () => {
mockBlobContentData.mockRejectedValueOnce({ mockBlobContentData.mockRejectedValueOnce({
response: { response: {
status: httpStatusCodes.NOT_FOUND, status: httpStatusCodes.NOT_FOUND,
...@@ -458,12 +161,11 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -458,12 +161,11 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises(); await waitForPromises();
expect(findAlert().text()).toBe(expectedAlertMsg); expect(findAlert().text()).toBe(noFileAlertMsg);
expect(findEditorLite().exists()).toBe(false); expect(findEditorHome().exists()).toBe(false);
expect(findTextEditor().exists()).toBe(false);
}); });
it('shows a 400 error message and does not show editor or commit form', async () => { it('shows a 400 error message and does not show editor home component', async () => {
mockBlobContentData.mockRejectedValueOnce({ mockBlobContentData.mockRejectedValueOnce({
response: { response: {
status: httpStatusCodes.BAD_REQUEST, status: httpStatusCodes.BAD_REQUEST,
...@@ -473,9 +175,8 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -473,9 +175,8 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises(); await waitForPromises();
expect(findAlert().text()).toBe(expectedAlertMsg); expect(findAlert().text()).toBe(noFileAlertMsg);
expect(findEditorLite().exists()).toBe(false); expect(findEditorHome().exists()).toBe(false);
expect(findTextEditor().exists()).toBe(false);
}); });
it('shows a unkown error message', async () => { it('shows a unkown error message', async () => {
...@@ -483,9 +184,60 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -483,9 +184,60 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
createComponentWithApollo(); createComponentWithApollo();
await waitForPromises(); await waitForPromises();
expect(findAlert().text()).toBe('The CI configuration was not loaded, please try again.'); expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]);
expect(findEditorLite().exists()).toBe(true); expect(findEditorHome().exists()).toBe(true);
expect(findTextEditor().exists()).toBe(true); });
});
describe('when the user commits', () => {
const updateFailureMessage = 'The GitLab CI configuration could not be updated.';
describe('and the commit mutation succeeds', () => {
beforeEach(() => {
createComponent();
findEditorHome().vm.$emit('commit', { type: COMMIT_SUCCESS });
});
it('shows a confirmation message', () => {
expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]);
});
});
describe('and the commit mutation fails', () => {
const commitFailedReasons = ['Commit failed'];
beforeEach(() => {
createComponent();
findEditorHome().vm.$emit('showError', {
type: COMMIT_FAILURE,
reasons: commitFailedReasons,
});
});
it('shows an error message', () => {
expect(findAlert().text()).toMatchInterpolatedText(
`${updateFailureMessage} ${commitFailedReasons[0]}`,
);
});
});
describe('when an unknown error occurs', () => {
const unknownReasons = ['Commit failed'];
beforeEach(() => {
createComponent();
findEditorHome().vm.$emit('showError', {
type: COMMIT_FAILURE,
reasons: unknownReasons,
});
});
it('shows an error message', () => {
expect(findAlert().text()).toMatchInterpolatedText(
`${updateFailureMessage} ${unknownReasons[0]}`,
);
});
}); });
}); });
}); });
......
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 { mockLintResponse, mockCiYml } from './mock_data';
describe('Pipeline editor home wrapper', () => {
let wrapper;
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(PipelineEditorHome, {
propsData: {
ciConfigData: mockLintResponse,
ciFileContent: mockCiYml,
isCiConfigDataLoading: false,
...props,
},
});
};
const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorTabs);
const findPipelineEditorTabs = () => wrapper.findComponent(CommitSection);
const findCommitSection = () => wrapper.findComponent(PipelineEditorHeader);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('renders', () => {
beforeEach(() => {
createComponent();
});
it('shows the pipeline editor header', () => {
expect(findPipelineEditorHeader().exists()).toBe(true);
});
it('shows the pipeline editor tabs', () => {
expect(findPipelineEditorTabs().exists()).toBe(true);
});
it('shows the commit section', () => {
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