Commit 2ac2f384 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo

Merge branch '263144-pipeline-editor-commit-form' into 'master'

Add commit functionality to pipeline editor

See merge request gitlab-org/gitlab!47083
parents e9e21290 5a7eb3af
<script>
import {
GlButton,
GlForm,
GlFormCheckbox,
GlFormInput,
GlFormGroup,
GlFormTextarea,
GlSprintf,
} from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlButton,
GlForm,
GlFormCheckbox,
GlFormInput,
GlFormGroup,
GlFormTextarea,
GlSprintf,
},
props: {
defaultBranch: {
type: String,
required: false,
default: '',
},
defaultMessage: {
type: String,
required: false,
default: '',
},
isSaving: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
message: this.defaultMessage,
branch: this.defaultBranch,
openMergeRequest: false,
};
},
computed: {
isDefaultBranch() {
return this.branch === this.defaultBranch;
},
submitDisabled() {
return !(this.message && this.branch);
},
},
methods: {
onSubmit() {
this.$emit('submit', {
message: this.message,
branch: this.branch,
openMergeRequest: this.openMergeRequest,
});
},
onReset() {
this.$emit('cancel');
},
},
i18n: {
commitMessage: __('Commit message'),
targetBranch: __('Target Branch'),
startMergeRequest: __('Start a %{new_merge_request} with these changes'),
newMergeRequest: __('new merge request'),
commitChanges: __('Commit changes'),
cancel: __('Cancel'),
},
};
</script>
<template>
<div>
<gl-form @submit.prevent="onSubmit" @reset.prevent="onReset">
<gl-form-group
id="commit-group"
:label="$options.i18n.commitMessage"
label-cols-sm="2"
label-for="commit-message"
>
<gl-form-textarea
id="commit-message"
v-model="message"
class="gl-font-monospace!"
required
:placeholder="defaultMessage"
/>
</gl-form-group>
<gl-form-group
id="target-branch-group"
:label="$options.i18n.targetBranch"
label-cols-sm="2"
label-for="target-branch-field"
>
<gl-form-input
id="target-branch-field"
v-model="branch"
class="gl-font-monospace!"
required
/>
<gl-form-checkbox
v-if="!isDefaultBranch"
v-model="openMergeRequest"
data-testid="new-mr-checkbox"
class="gl-mt-3"
>
<gl-sprintf :message="$options.i18n.startMergeRequest">
<template #new_merge_request>
<strong>{{ $options.i18n.newMergeRequest }}</strong>
</template>
</gl-sprintf>
</gl-form-checkbox>
</gl-form-group>
<div
class="gl-display-flex gl-justify-content-space-between gl-p-5 gl-bg-gray-10 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1"
>
<gl-button
type="submit"
class="js-no-auto-disable"
category="primary"
variant="success"
:disabled="submitDisabled"
:loading="isSaving"
>
{{ $options.i18n.commitChanges }}
</gl-button>
<gl-button type="reset" category="secondary" class="gl-mr-3">
{{ $options.i18n.cancel }}
</gl-button>
</div>
</gl-form>
</div>
</template>
......@@ -5,22 +5,10 @@ export default {
components: {
EditorLite,
},
props: {
value: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div class="gl-border-solid gl-border-gray-100 gl-border-1">
<editor-lite
v-model="value"
file-name="*.yml"
:editor-options="{ readOnly: true }"
@editor-ready="$emit('editor-ready')"
/>
<editor-lite file-name="*.yml" v-bind="$attrs" v-on="$listeners" />
</div>
</template>
mutation commitCIFileMutation(
$projectPath: ID!
$branch: String!
$startBranch: String
$message: String!
$filePath: String!
$lastCommitId: String!
$content: String
) {
commitCreate(
input: {
projectPath: $projectPath
branch: $branch
startBranch: $startBranch
message: $message
actions: [
{ action: UPDATE, filePath: $filePath, lastCommitId: $lastCommitId, content: $content }
]
}
) {
commit {
id
}
errors
}
}
......@@ -10,7 +10,11 @@ import PipelineEditorApp from './pipeline_editor_app.vue';
export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const el = document.querySelector(selector);
const { projectPath, defaultBranch, ciConfigPath } = el?.dataset;
if (!el) {
return null;
}
const { ciConfigPath, commitId, defaultBranch, newMergeRequestPath, projectPath } = el?.dataset;
Vue.use(VueApollo);
......@@ -24,9 +28,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
render(h) {
return h(PipelineEditorApp, {
props: {
projectPath,
defaultBranch,
ciConfigPath,
commitId,
defaultBranch,
newMergeRequestPath,
projectPath,
},
});
},
......
<script>
import { GlLoadingIcon, GlAlert, GlTabs, GlTab } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { redirectTo, mergeUrlParams, refreshCurrentPage } from '~/lib/utils/url_utility';
import TextEditor from './components/text_editor.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import CommitForm from './components/commit/commit_form.vue';
import TextEditor from './components/text_editor.vue';
import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql';
import getBlobContent from './graphql/queries/blob_content.graphql';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
const LOAD_FAILURE_NO_REF = 'LOAD_FAILURE_NO_REF';
const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE';
const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
const COMMIT_FAILURE = 'COMMIT_FAILURE';
const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
export default {
components: {
GlLoadingIcon,
GlAlert,
GlTabs,
GlLoadingIcon,
GlTab,
TextEditor,
GlTabs,
PipelineGraph,
CommitForm,
TextEditor,
},
props: {
projectPath: {
......@@ -26,16 +39,30 @@ export default {
required: false,
default: null,
},
commitId: {
type: String,
required: false,
default: null,
},
ciConfigPath: {
type: String,
required: true,
},
newMergeRequestPath: {
type: String,
required: true,
},
},
data() {
return {
error: null,
content: '',
showFailureAlert: false,
failureType: null,
failureReasons: [],
isSaving: false,
editorIsReady: false,
content: '',
contentModel: '',
};
},
apollo: {
......@@ -51,51 +78,168 @@ export default {
update(data) {
return data?.blobContent?.rawData;
},
result({ data }) {
this.contentModel = data?.blobContent?.rawData ?? '';
},
error(error) {
this.error = error;
this.handleBlobContentError(error);
},
},
},
computed: {
loading() {
isLoading() {
return this.$apollo.queries.content.loading;
},
errorMessage() {
const { message: generalReason, networkError } = this.error ?? {};
const { data } = networkError?.response ?? {};
// 404 for missing file uses `message`
// 400 for a missing ref uses `error`
const networkReason = data?.message ?? data?.error;
const reason = networkReason ?? generalReason ?? this.$options.i18n.unknownError;
return sprintf(this.$options.i18n.errorMessageWithReason, { reason });
defaultCommitMessage() {
return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath });
},
pipelineData() {
// Note data will loaded as part of https://gitlab.com/gitlab-org/gitlab/-/issues/263141
return {};
},
failure() {
switch (this.failureType) {
case LOAD_FAILURE_NO_REF:
return {
text: this.$options.errorTexts[LOAD_FAILURE_NO_REF],
variant: 'danger',
};
case LOAD_FAILURE_NO_FILE:
return {
text: this.$options.errorTexts[LOAD_FAILURE_NO_FILE],
variant: 'danger',
};
case LOAD_FAILURE_UNKNOWN:
return {
text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
variant: 'danger',
};
case COMMIT_FAILURE:
return {
text: this.$options.errorTexts[COMMIT_FAILURE],
variant: 'danger',
};
default:
return {
text: this.$options.errorTexts[DEFAULT_FAILURE],
variant: 'danger',
};
}
},
},
i18n: {
unknownError: __('Unknown Error'),
errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'),
defaultCommitMessage: __('Update %{sourcePath} file'),
tabEdit: s__('Pipelines|Write pipeline configuration'),
tabGraph: s__('Pipelines|Visualize'),
},
errorTexts: {
[LOAD_FAILURE_NO_REF]: s__(
'Pipelines|Repository does not have a default branch, please set one.',
),
[LOAD_FAILURE_NO_FILE]: s__('Pipelines|No CI file found in this repository, please add one.'),
[LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
[COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
},
methods: {
handleBlobContentError(error = {}) {
const { networkError } = error;
const { response } = networkError;
if (response?.status === 404) {
// 404 for missing CI file
this.reportFailure(LOAD_FAILURE_NO_FILE);
} else if (response?.status === 400) {
// 400 for a missing ref when no default branch is set
this.reportFailure(LOAD_FAILURE_NO_REF);
} else {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
}
},
dismissFailure() {
this.showFailureAlert = false;
},
reportFailure(type, reasons = []) {
this.showFailureAlert = true;
this.failureType = type;
this.failureReasons = reasons;
},
redirectToNewMergeRequest(sourceBranch) {
const url = mergeUrlParams(
{
[MR_SOURCE_BRANCH]: sourceBranch,
[MR_TARGET_BRANCH]: this.defaultBranch,
},
this.newMergeRequestPath,
);
redirectTo(url);
},
async onCommitSubmit(event) {
this.isSaving = true;
const { message, branch, openMergeRequest } = event;
try {
const {
data: {
commitCreate: { errors },
},
} = await this.$apollo.mutate({
mutation: commitCiFileMutation,
variables: {
projectPath: this.projectPath,
branch,
startBranch: this.defaultBranch,
message,
filePath: this.ciConfigPath,
content: this.contentModel,
lastCommitId: this.commitId,
},
});
if (errors?.length) {
this.reportFailure(COMMIT_FAILURE, errors);
return;
}
if (openMergeRequest) {
this.redirectToNewMergeRequest(branch);
} else {
// Refresh the page to ensure commit is updated
refreshCurrentPage();
}
} catch (error) {
this.reportFailure(COMMIT_FAILURE, [error?.message]);
} finally {
this.isSaving = false;
}
},
onCommitCancel() {
this.contentModel = this.content;
},
},
};
</script>
<template>
<div class="gl-mt-4">
<gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert>
<gl-alert
v-if="showFailureAlert"
:variant="failure.variant"
:dismissible="true"
@dismiss="dismissFailure"
>
{{ failure.text }}
<ul v-if="failureReasons.length" class="gl-mb-0">
<li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
</ul>
</gl-alert>
<div class="gl-mt-4">
<gl-loading-icon v-if="loading" size="lg" />
<div v-else class="file-editor">
<gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
<div v-else class="file-editor gl-mb-3">
<gl-tabs>
<!-- editor should be mounted when its tab is visible, so the container has a size -->
<gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady">
<!-- editor should be mounted only once, when the tab is displayed -->
<text-editor v-model="content" @editor-ready="editorIsReady = true" />
<text-editor v-model="contentModel" @editor-ready="editorIsReady = true" />
</gl-tab>
<gl-tab :title="$options.i18n.tabGraph">
......@@ -103,6 +247,13 @@ export default {
</gl-tab>
</gl-tabs>
</div>
<commit-form
:default-branch="defaultBranch"
:default-message="defaultCommitMessage"
:is-saving="isSaving"
@cancel="onCommitCancel"
@submit="onCommitSubmit"
/>
</div>
</div>
</template>
......@@ -3,4 +3,6 @@
#js-pipeline-editor{ data: { "ci-config-path": @project.ci_config_path_or_default,
"project-path" => @project.full_path,
"default-branch" => @project.default_branch,
"commit-id" => @project.commit ? @project.commit.id : '',
"new-merge-request-path" => namespace_project_new_merge_request_path,
} }
......@@ -6903,6 +6903,9 @@ msgstr ""
msgid "Commit Message"
msgstr ""
msgid "Commit changes"
msgstr ""
msgid "Commit deleted"
msgstr ""
......@@ -19950,9 +19953,6 @@ msgstr ""
msgid "Pipelines|CI Lint"
msgstr ""
msgid "Pipelines|CI file could not be loaded: %{reason}"
msgstr ""
msgid "Pipelines|Child pipeline"
msgstr ""
......@@ -19998,6 +19998,9 @@ msgstr ""
msgid "Pipelines|More Information"
msgstr ""
msgid "Pipelines|No CI file found in this repository, please add one."
msgstr ""
msgid "Pipelines|No triggers have been created yet. Add one using the form above."
msgstr ""
......@@ -20010,6 +20013,9 @@ msgstr ""
msgid "Pipelines|Project cache successfully reset."
msgstr ""
msgid "Pipelines|Repository does not have a default branch, please set one."
msgstr ""
msgid "Pipelines|Revoke"
msgstr ""
......@@ -20019,6 +20025,12 @@ msgstr ""
msgid "Pipelines|Something went wrong while cleaning runners cache."
msgstr ""
msgid "Pipelines|The CI configuration was not loaded, please try again."
msgstr ""
msgid "Pipelines|The GitLab CI configuration could not be updated."
msgstr ""
msgid "Pipelines|There are currently no finished pipelines."
msgstr ""
......
import { shallowMount, mount } from '@vue/test-utils';
import { GlFormInput, GlFormTextarea } from '@gitlab/ui';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import { mockCommitMessage, mockDefaultBranch } from '../../mock_data';
describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
let wrapper;
const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
wrapper = mountFn(CommitForm, {
propsData: {
defaultMessage: mockCommitMessage,
defaultBranch: mockDefaultBranch,
...props,
},
// attachToDocument is required for input/submit events
attachToDocument: mountFn === mount,
});
};
const findCommitTextarea = () => wrapper.find(GlFormTextarea);
const findBranchInput = () => wrapper.find(GlFormInput);
const findNewMrCheckbox = () => wrapper.find('[data-testid="new-mr-checkbox"]');
const findSubmitBtn = () => wrapper.find('[type="submit"]');
const findCancelBtn = () => wrapper.find('[type="reset"]');
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when the form is displayed', () => {
beforeEach(async () => {
createComponent();
});
it('shows a default commit message', () => {
expect(findCommitTextarea().attributes('value')).toBe(mockCommitMessage);
});
it('shows a default branch', () => {
expect(findBranchInput().attributes('value')).toBe(mockDefaultBranch);
});
it('shows buttons', () => {
expect(findSubmitBtn().exists()).toBe(true);
expect(findCancelBtn().exists()).toBe(true);
});
it('does not show a new MR checkbox by default', () => {
expect(findNewMrCheckbox().exists()).toBe(false);
});
});
describe('when buttons are clicked', () => {
beforeEach(async () => {
createComponent({}, mount);
});
it('emits an event when the form submits', () => {
findSubmitBtn().trigger('click');
expect(wrapper.emitted('submit')[0]).toEqual([
{
message: mockCommitMessage,
branch: mockDefaultBranch,
openMergeRequest: false,
},
]);
});
it('emits an event when the form resets', () => {
findCancelBtn().trigger('click');
expect(wrapper.emitted('cancel')).toHaveLength(1);
});
});
describe('when user inputs values', () => {
const anotherMessage = 'Another commit message';
const anotherBranch = 'my-branch';
beforeEach(() => {
createComponent({}, mount);
findCommitTextarea().setValue(anotherMessage);
findBranchInput().setValue(anotherBranch);
});
it('shows a new MR checkbox', () => {
expect(findNewMrCheckbox().exists()).toBe(true);
});
it('emits an event with values', async () => {
await findNewMrCheckbox().setChecked();
await findSubmitBtn().trigger('click');
expect(wrapper.emitted('submit')[0]).toEqual([
{
message: anotherMessage,
branch: anotherBranch,
openMergeRequest: true,
},
]);
});
it('when the commit message is empty, submit button is disabled', async () => {
await findCommitTextarea().setValue('');
expect(findSubmitBtn().attributes('disabled')).toBe('disabled');
});
});
});
......@@ -6,12 +6,16 @@ import TextEditor from '~/pipeline_editor/components/text_editor.vue';
describe('~/pipeline_editor/components/text_editor.vue', () => {
let wrapper;
const editorReadyListener = jest.fn();
const createComponent = (props = {}, mountFn = shallowMount) => {
const createComponent = (attrs = {}, mountFn = shallowMount) => {
wrapper = mountFn(TextEditor, {
propsData: {
attrs: {
value: mockCiYml,
...props,
...attrs,
},
listeners: {
'editor-ready': editorReadyListener,
},
});
};
......@@ -28,14 +32,13 @@ describe('~/pipeline_editor/components/text_editor.vue', () => {
expect(findEditor().props('value')).toBe(mockCiYml);
});
it('editor is readony and configured for .yml', () => {
expect(findEditor().props('editorOptions')).toEqual({ readOnly: true });
it('editor is configured for .yml', () => {
expect(findEditor().props('fileName')).toBe('*.yml');
});
it('bubbles up editor-ready event', () => {
it('bubbles up events', () => {
findEditor().vm.$emit('editor-ready');
expect(wrapper.emitted('editor-ready')).toHaveLength(1);
expect(editorReadyListener).toHaveBeenCalled();
});
});
export const mockProjectPath = 'user1/project1';
export const mockDefaultBranch = 'master';
export const mockNewMergeRequestPath = '/-/merge_requests/new';
export const mockCommitId = 'aabbccdd';
export const mockCommitMessage = 'My commit message';
export const mockCiConfigPath = '.gitlab-ci.yml';
export const mockCiYml = `
......
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import {
GlAlert,
GlButton,
GlFormInput,
GlFormTextarea,
GlLoadingIcon,
GlTabs,
GlTab,
} from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import VueApollo from 'vue-apollo';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { redirectTo, refreshCurrentPage, objectToQuery } from '~/lib/utils/url_utility';
import {
mockCiConfigPath,
mockCiYml,
mockCommitId,
mockCommitMessage,
mockDefaultBranch,
mockProjectPath,
mockNewMergeRequestPath,
} from './mock_data';
import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from './mock_data';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
const localVue = createLocalVue();
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,
}));
describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
let wrapper;
const createComponent = (
{ props = {}, data = {}, loading = false } = {},
let mockMutate;
let mockApollo;
let mockBlobContentData;
const createComponent = ({
props = {},
loading = false,
options = {},
mountFn = shallowMount,
) => {
} = {}) => {
mockMutate = jest.fn().mockResolvedValue({
data: {
commitCreate: {
errors: [],
commit: {},
},
},
});
wrapper = mountFn(PipelineEditorApp, {
propsData: {
projectPath: mockProjectPath,
defaultBranch: mockDefaultBranch,
ciConfigPath: mockCiConfigPath,
commitId: mockCommitId,
defaultBranch: mockDefaultBranch,
projectPath: mockProjectPath,
newMergeRequestPath: mockNewMergeRequestPath,
...props,
},
data() {
return data;
},
stubs: {
GlTabs,
GlButton,
CommitForm,
EditorLite: {
template: '<div/>',
},
TextEditor,
},
mocks: {
......@@ -36,45 +86,73 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
loading,
},
},
mutate: mockMutate,
},
},
// attachToDocument is required for input/submit events
attachToDocument: mountFn === mount,
...options,
});
};
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
mockApollo = createMockApollo([], {
Query: {
blobContent() {
return {
__typename: 'BlobContent',
rawData: mockBlobContentData(),
};
},
},
});
const options = {
localVue,
mocks: {},
apolloProvider: mockApollo,
};
createComponent({ props, options }, mountFn);
};
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findAlert = () => wrapper.find(GlAlert);
const findTabAt = i => wrapper.findAll(GlTab).at(i);
const findEditorLite = () => wrapper.find(EditorLite);
const findTextEditor = () => wrapper.find(TextEditor);
const findCommitForm = () => wrapper.find(CommitForm);
const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').find(GlLoadingIcon);
beforeEach(() => {
createComponent();
mockBlobContentData = jest.fn();
});
afterEach(() => {
mockBlobContentData.mockReset();
refreshCurrentPage.mockReset();
redirectTo.mockReset();
mockMutate.mockReset();
wrapper.destroy();
wrapper = null;
});
it('displays content', () => {
createComponent({ data: { content: mockCiYml } });
expect(findLoadingIcon().exists()).toBe(false);
expect(findEditorLite().props('value')).toBe(mockCiYml);
});
it('displays a loading icon if the query is loading', () => {
createComponent({ loading: true });
expect(findLoadingIcon().exists()).toBe(true);
expect(findTextEditor().exists()).toBe(false);
});
describe('tabs', () => {
it('displays tabs and their content', () => {
createComponent({ data: { content: mockCiYml } });
beforeEach(() => {
createComponent();
});
it('displays tabs and their content', async () => {
expect(
findTabAt(0)
.find(EditorLite)
.find(TextEditor)
.exists(),
).toBe(true);
expect(
......@@ -85,55 +163,234 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
});
it('displays editor tab lazily, until editor is ready', async () => {
createComponent({ data: { content: mockCiYml } });
expect(findTabAt(0).attributes('lazy')).toBe('true');
findEditorLite().vm.$emit('editor-ready');
findTextEditor().vm.$emit('editor-ready');
await nextTick();
expect(findTabAt(0).attributes('lazy')).toBe(undefined);
});
});
describe('when in error state', () => {
class MockError extends Error {
constructor(message, data) {
super(message);
if (data) {
this.networkError = {
response: { data },
};
describe('when data is set', () => {
beforeEach(async () => {
createComponent({ mountFn: mount });
wrapper.setData({
content: mockCiYml,
contentModel: mockCiYml,
});
await nextTick();
});
it('displays content after the query loads', () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findTextEditor().attributes('value')).toBe(mockCiYml);
});
describe('commit form', () => {
const mockVariables = {
content: mockCiYml,
filePath: mockCiConfigPath,
lastCommitId: mockCommitId,
message: mockCommitMessage,
projectPath: mockProjectPath,
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('shows a generic error', () => {
const error = new MockError('An error message');
createComponent({ data: { error } });
it('refreshes the page', () => {
expect(refreshCurrentPage).toHaveBeenCalled();
});
expect(findAlert().text()).toBe('CI file could not be loaded: An error message');
it('shows no saving state', () => {
expect(findCommitBtnLoadingIcon().exists()).toBe(false);
});
});
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,
},
});
});
it('refreshes the page', () => {
expect(refreshCurrentPage).toHaveBeenCalledWith();
});
});
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 a the 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(findTextEditor().attributes('value')).toBe(mockCiYml);
});
});
});
});
describe('displays fetch content errors', () => {
it('no error is show when data is set', async () => {
mockBlobContentData.mockResolvedValue(mockCiYml);
createComponentWithApollo();
await waitForPromises();
expect(findAlert().exists()).toBe(false);
expect(findTextEditor().attributes('value')).toBe(mockCiYml);
});
it('shows a ref missing error state', () => {
const error = new MockError('Ref missing!', {
error: 'ref is missing, ref is empty',
it('shows a 404 error message', async () => {
mockBlobContentData.mockRejectedValueOnce({
response: {
status: 404,
},
});
createComponent({ data: { error } });
createComponentWithApollo();
expect(findAlert().text()).toMatch(
'CI file could not be loaded: ref is missing, ref is empty',
);
await waitForPromises();
expect(findAlert().text()).toMatch('No CI file found in this repository, please add one.');
});
it('shows a file missing error state', async () => {
const error = new MockError('File missing!', {
message: 'file not found',
it('shows a 400 error message', async () => {
mockBlobContentData.mockRejectedValueOnce({
response: {
status: 400,
},
});
createComponentWithApollo();
await waitForPromises();
expect(findAlert().text()).toMatch(
'Repository does not have a default branch, please set one.',
);
});
await wrapper.setData({ error });
it('shows a unkown error message', async () => {
mockBlobContentData.mockRejectedValueOnce(new Error('My error!'));
createComponentWithApollo();
await waitForPromises();
expect(findAlert().text()).toMatch('CI file could not be loaded: file not found');
expect(findAlert().text()).toMatch('The CI configuration was not loaded, please try again.');
});
});
});
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