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 = `
......
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