Commit 36bfdb8a authored by Miguel Rincon's avatar Miguel Rincon

Add commit functionality to pipeline editor

This change adds a form at the bottom of the pipeline editor to
commit changes in the CI configuration.
parent 0b30d88d
<script>
import {
GlForm,
GlFormGroup,
GlFormTextarea,
GlFormCheckbox,
GlFormInput,
GlButton,
GlSprintf,
} from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlForm,
GlFormGroup,
GlFormTextarea,
GlFormCheckbox,
GlFormInput,
GlButton,
GlSprintf,
},
props: {
defaultBranch: {
type: String,
required: true,
},
defaultMessage: {
type: String,
required: false,
default: '',
},
isSaving: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
message: this.defaultMessage,
branch: this.defaultBranch,
openMergeRequest: false,
};
},
computed: {
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="branch !== defaultBranch"
v-model="openMergeRequest"
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,7 @@ 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;
const { projectPath, defaultBranch, commitId, ciConfigPath, newMergeRequestPath } = el?.dataset;
Vue.use(VueApollo);
......@@ -26,7 +26,9 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
props: {
projectPath,
defaultBranch,
commitId,
ciConfigPath,
newMergeRequestPath,
},
});
},
......
<script>
import { GlLoadingIcon, GlAlert, GlTabs, GlTab } 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 CommitForm from './components/commit/commit_form.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.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]';
export default {
components: {
GlLoadingIcon,
......@@ -14,6 +20,7 @@ export default {
GlTabs,
GlTab,
TextEditor,
CommitForm,
PipelineGraph,
},
props: {
......@@ -26,16 +33,27 @@ 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: '',
errorMessage: null,
isSaving: false,
editorIsReady: false,
content: '',
contentModel: '',
};
},
apollo: {
......@@ -51,8 +69,11 @@ export default {
update(data) {
return data?.blobContent?.rawData;
},
result({ data }) {
this.contentModel = data?.blobContent?.rawData ?? '';
},
error(error) {
this.error = error;
this.handleBlobContentError(error);
},
},
},
......@@ -60,16 +81,8 @@ export default {
loading() {
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
......@@ -77,25 +90,98 @@ export default {
},
},
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'),
unknownError: __('Unknown Error'),
fetchErrorMsg: s__('Pipelines|CI file could not be loaded: %{reason}'),
commitErrorMsg: s__('Pipelines|CI file could not be saved: %{reason}'),
},
methods: {
handleBlobContentError(error) {
const { message: generalReason, networkError } = 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;
this.errorMessage = sprintf(this.$options.i18n.fetchErrorMsg, { reason });
},
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) {
throw new Error(errors[0]);
}
if (openMergeRequest) {
this.redirectToNewMergeRequest(branch);
} else {
// Refresh the page to ensure commit is updated
refreshCurrentPage();
}
} catch (error) {
const reason = error?.message || this.$options.i18n.unknownError;
this.errorMessage = sprintf(this.$options.i18n.commitErrorMsg, { reason });
} finally {
this.isSaving = false;
}
},
onCommitCancel() {
this.contentModel = this.content;
},
onErrorDismiss() {
this.errorMessage = null;
},
},
};
</script>
<template>
<div class="gl-mt-4">
<gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert>
<gl-alert v-if="errorMessage" variant="danger" :dismissible="true" @dismiss="onErrorDismiss">
{{ errorMessage }}
</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="loading" 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 +189,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,
} }
......@@ -6897,6 +6897,9 @@ msgstr ""
msgid "Commit Message"
msgstr ""
msgid "Commit changes"
msgstr ""
msgid "Commit deleted"
msgstr ""
......@@ -19938,6 +19941,9 @@ msgstr ""
msgid "Pipelines|CI file could not be loaded: %{reason}"
msgstr ""
msgid "Pipelines|CI file could not be saved: %{reason}"
msgstr ""
msgid "Pipelines|Child pipeline"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlForm, GlFormTextarea, GlFormInput, GlFormCheckbox } 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,
},
});
};
const findForm = () => wrapper.find(GlForm);
const findCommitTextarea = () => wrapper.find(GlFormTextarea);
const findBranchInput = () => wrapper.find(GlFormInput);
const findMrCheckbox = () => wrapper.find(GlFormCheckbox);
const findSubmitBtn = () => wrapper.find('[type="submit"]');
const findCancelBtn = () => wrapper.find('[type="reset"]');
beforeEach(() => {
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', () => {
expect(findMrCheckbox().exists()).toBe(false);
});
describe('events', () => {
it('emits an event when the form submits', () => {
findForm().vm.$emit('submit', new Event('submit'));
expect(wrapper.emitted('submit')[0]).toEqual([
{
message: mockCommitMessage,
branch: mockDefaultBranch,
openMergeRequest: false,
},
]);
});
it('emits an event when the form resets', () => {
findForm().vm.$emit('reset', new Event('reset'));
expect(wrapper.emitted('cancel')).toHaveLength(1);
});
});
describe('when values change', () => {
const anotherMessage = 'Another commit message';
const anotherBranch = 'my-branch';
beforeEach(() => {
findCommitTextarea().vm.$emit('input', anotherMessage);
findBranchInput().vm.$emit('input', anotherBranch);
});
it('shows a new MR checkbox', () => {
expect(findMrCheckbox().exists()).toBe(true);
});
it('emits an event with other values', () => {
findMrCheckbox().vm.$emit('input', true);
findForm().vm.$emit('submit', new Event('submit'));
expect(wrapper.emitted('submit')[0]).toEqual([
{
message: anotherMessage,
branch: anotherBranch,
openMergeRequest: true,
},
]);
});
describe('when values are removed', () => {
beforeEach(() => {
findBranchInput().vm.$emit('input', anotherBranch);
});
it('shows a disables the form', () => {
findCommitTextarea().vm.$emit('input', '');
expect(findMrCheckbox().exists()).toBe(true);
});
it('emits an event with other values', () => {
findMrCheckbox().vm.$emit('input', true);
findForm().vm.$emit('submit', new Event('submit'));
expect(wrapper.emitted('submit')[0]).toEqual([
{
message: anotherMessage,
branch: anotherBranch,
openMergeRequest: true,
},
]);
});
});
});
});
......@@ -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('bubble up of 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 = `
......
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