Commit 9fc81d96 authored by Janis Altherr's avatar Janis Altherr Committed by David O'Regan

Add the commit step component to be used by the Pipeline Wizard

This commit introduces the commit step page, which
is always the last step in the pipeline wizard. It will
allow commiting the updated .gitlab-ci.yml
file to a project
Signed-off-by: default avatarJanis Altherr <jaltherr@gitlab.com>
parent f9a90786
<script>
import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
import RefSelector from '~/ref/components/ref_selector.vue';
import { __, s__, sprintf } from '~/locale';
import createCommitMutation from '../queries/create_commit.graphql';
import getFileMetaDataQuery from '../queries/get_file_meta.graphql';
import StepNav from './step_nav.vue';
export const i18n = {
updateFileHeading: s__('PipelineWizard|Commit changes to your file'),
createFileHeading: s__('PipelineWizard|Commit your new file'),
fieldRequiredFeedback: __('This field is required'),
commitMessageLabel: s__('PipelineWizard|Commit Message'),
branchSelectorLabel: s__('PipelineWizard|Commit file to Branch'),
defaultUpdateCommitMessage: s__('PipelineWizardDefaultCommitMessage|Update %{filename}'),
defaultCreateCommitMessage: s__('PipelineWizardDefaultCommitMessage|Add %{filename}'),
commitButtonLabel: s__('PipelineWizard|Commit'),
commitSuccessMessage: s__('PipelineWizard|The file has been committed.'),
errors: {
loadError: s__(
'PipelineWizard|There was a problem while checking whether your file already exists in the specified branch.',
),
commitError: s__('PipelineWizard|There was a problem committing the changes.'),
},
};
const COMMIT_ACTION = {
CREATE: 'CREATE',
UPDATE: 'UPDATE',
};
export default {
i18n,
name: 'PipelineWizardCommitStep',
components: {
RefSelector,
GlAlert,
GlButton,
GlForm,
GlFormGroup,
GlFormTextarea,
StepNav,
},
props: {
prev: {
type: Object,
required: false,
default: null,
},
projectPath: {
type: String,
required: true,
},
defaultBranch: {
type: String,
required: true,
},
fileContent: {
type: String,
required: false,
default: '',
},
filename: {
type: String,
required: true,
},
},
data() {
return {
branch: this.defaultBranch,
loading: false,
loadError: null,
commitError: null,
message: null,
};
},
computed: {
fileExistsInRepo() {
return this.project?.repository?.blobs.nodes.length > 0;
},
commitAction() {
return this.fileExistsInRepo ? COMMIT_ACTION.UPDATE : COMMIT_ACTION.CREATE;
},
defaultMessage() {
return sprintf(
this.fileExistsInRepo
? this.$options.i18n.defaultUpdateCommitMessage
: this.$options.i18n.defaultCreateCommitMessage,
{ filename: this.filename },
);
},
isCommitButtonEnabled() {
return this.fileExistsCheckInProgress;
},
fileExistsCheckInProgress() {
return this.$apollo.queries.project.loading;
},
mutationPayload() {
return {
mutation: createCommitMutation,
variables: {
input: {
projectPath: this.projectPath,
branch: this.branch,
message: this.message || this.defaultMessage,
actions: [
{
action: this.commitAction,
filePath: `/${this.filename}`,
content: this.fileContent,
},
],
},
},
};
},
},
apollo: {
project: {
query: getFileMetaDataQuery,
variables() {
this.loadError = null;
return {
fullPath: this.projectPath,
filePath: this.filename,
ref: this.branch,
};
},
error() {
this.loadError = this.$options.i18n.errors.loadError;
},
},
},
methods: {
async commit() {
this.loading = true;
try {
const { data } = await this.$apollo.mutate(this.mutationPayload);
const hasError = Boolean(data.commitCreate.errors?.length);
if (hasError) {
this.commitError = this.$options.i18n.errors.commitError;
} else {
this.handleCommitSuccess();
}
} catch (e) {
this.commitError = this.$options.i18n.errors.commitError;
} finally {
this.loading = false;
}
},
handleCommitSuccess() {
this.$toast.show(this.$options.i18n.commitSuccessMessage);
this.$emit('done');
},
},
};
</script>
<template>
<div>
<h4 v-if="fileExistsInRepo" key="create-heading">
{{ $options.i18n.updateFileHeading }}
</h4>
<h4 v-else key="update-heading">
{{ $options.i18n.createFileHeading }}
</h4>
<gl-alert
v-if="!!loadError"
:dismissible="false"
class="gl-mb-5"
data-testid="load-error"
variant="danger"
>
{{ loadError }}
</gl-alert>
<gl-form class="gl-max-w-48">
<gl-form-group
:invalid-feedback="$options.i18n.fieldRequiredFeedback"
:label="$options.i18n.commitMessageLabel"
data-testid="commit_message_group"
label-for="commit_message"
>
<gl-form-textarea
id="commit_message"
v-model="message"
:placeholder="defaultMessage"
data-testid="commit_message"
size="md"
@input="(v) => $emit('update:message', v)"
/>
</gl-form-group>
<gl-form-group
:invalid-feedback="$options.i18n.fieldRequiredFeedback"
:label="$options.i18n.branchSelectorLabel"
data-testid="branch_selector_group"
label-for="branch"
>
<ref-selector id="branch" v-model="branch" data-testid="branch" :project-id="projectPath" />
</gl-form-group>
<gl-alert
v-if="!!commitError"
:dismissible="false"
class="gl-mb-5"
data-testid="commit-error"
variant="danger"
>
{{ commitError }}
</gl-alert>
<step-nav show-back-button v-bind="$props" @back="$emit('go-back')">
<template #after>
<gl-button
:disabled="isCommitButtonEnabled"
:loading="fileExistsCheckInProgress || loading"
category="primary"
variant="confirm"
@click="commit"
>
{{ $options.i18n.commitButtonLabel }}
</gl-button>
</template>
</step-nav>
</gl-form>
</div>
</template>
mutation CreateCommit($input: CommitCreateInput!) {
commitCreate(input: $input) {
commit {
id
}
content
errors
}
}
query GetFileMetadata($fullPath: ID!, $filePath: String!, $ref: String) {
project(fullPath: $fullPath) {
id
repository {
blobs(paths: [$filePath], ref: $ref) {
nodes {
id
}
}
}
}
}
......@@ -26643,12 +26643,42 @@ msgstr ""
msgid "PipelineStatusTooltip|Pipeline: %{ci_status}"
msgstr ""
msgid "PipelineWizardDefaultCommitMessage|Add %{filename}"
msgstr ""
msgid "PipelineWizardDefaultCommitMessage|Update %{filename}"
msgstr ""
msgid "PipelineWizardInputValidation|This field is required"
msgstr ""
msgid "PipelineWizardInputValidation|This value is not valid"
msgstr ""
msgid "PipelineWizard|Commit"
msgstr ""
msgid "PipelineWizard|Commit Message"
msgstr ""
msgid "PipelineWizard|Commit changes to your file"
msgstr ""
msgid "PipelineWizard|Commit file to Branch"
msgstr ""
msgid "PipelineWizard|Commit your new file"
msgstr ""
msgid "PipelineWizard|The file has been committed."
msgstr ""
msgid "PipelineWizard|There was a problem committing the changes."
msgstr ""
msgid "PipelineWizard|There was a problem while checking whether your file already exists in the specified branch."
msgstr ""
msgid "Pipelines"
msgstr ""
......
import { GlButton, GlFormGroup } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { __, s__, sprintf } from '~/locale';
import { mountExtended } from 'jest/__helpers__/vue_test_utils_helper';
import CommitStep, { i18n } from '~/pipeline_wizard/components/commit.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import createCommitMutation from '~/pipeline_wizard/queries/create_commit.graphql';
import getFileMetadataQuery from '~/pipeline_wizard/queries/get_file_meta.graphql';
import RefSelector from '~/ref/components/ref_selector.vue';
import flushPromises from 'helpers/flush_promises';
import {
createCommitMutationErrorResult,
createCommitMutationResult,
fileQueryErrorResult,
fileQueryResult,
fileQueryEmptyResult,
} from '../mock/query_responses';
Vue.use(VueApollo);
const COMMIT_MESSAGE_ADD_FILE = s__('PipelineWizardDefaultCommitMessage|Add %{filename}');
const COMMIT_MESSAGE_UPDATE_FILE = s__('PipelineWizardDefaultCommitMessage|Update %{filename}');
describe('Pipeline Wizard - Commit Page', () => {
const createCommitMutationHandler = jest.fn();
const $toast = {
show: jest.fn(),
};
let wrapper;
const getMockApollo = (scenario = {}) => {
return createMockApollo([
[
createCommitMutation,
createCommitMutationHandler.mockResolvedValue(
scenario.commitHasError ? createCommitMutationErrorResult : createCommitMutationResult,
),
],
[
getFileMetadataQuery,
(vars) => {
if (scenario.fileResultByRef) return scenario.fileResultByRef[vars.ref];
if (scenario.hasError) return fileQueryErrorResult;
return scenario.fileExists ? fileQueryResult : fileQueryEmptyResult;
},
],
]);
};
const createComponent = (props = {}, mockApollo = getMockApollo()) => {
wrapper = mountExtended(CommitStep, {
apolloProvider: mockApollo,
propsData: {
projectPath: 'some/path',
defaultBranch: 'main',
filename: 'newFile.yml',
...props,
},
mocks: { $toast },
stubs: {
RefSelector: true,
GlFormGroup,
},
});
};
function getButtonWithLabel(label) {
return wrapper.findAllComponents(GlButton).filter((n) => n.text().match(label));
}
describe('ui setup', () => {
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('shows a commit message input with the correct label', () => {
expect(wrapper.findByTestId('commit_message').exists()).toBe(true);
expect(wrapper.find('label[for="commit_message"]').text()).toBe(i18n.commitMessageLabel);
});
it('shows a branch selector with the correct label', () => {
expect(wrapper.findByTestId('branch').exists()).toBe(true);
expect(wrapper.find('label[for="branch"]').text()).toBe(i18n.branchSelectorLabel);
});
it('shows a commit button', () => {
expect(getButtonWithLabel(i18n.commitButtonLabel).exists()).toBe(true);
});
it('shows a back button', () => {
expect(getButtonWithLabel(__('Back')).exists()).toBe(true);
});
it('does not show a next button', () => {
expect(getButtonWithLabel(__('Next')).exists()).toBe(false);
});
});
describe('loading the remote file', () => {
const projectPath = 'foo/bar';
const filename = 'foo.yml';
it('does not show a load error if call is successful', async () => {
createComponent({ projectPath, filename });
await flushPromises();
expect(wrapper.findByTestId('load-error').exists()).not.toBe(true);
});
it('shows a load error if call returns an unexpected error', async () => {
const branch = 'foo';
createComponent(
{ defaultBranch: branch, projectPath, filename },
createMockApollo([[getFileMetadataQuery, () => fileQueryErrorResult]]),
);
await flushPromises();
expect(wrapper.findByTestId('load-error').exists()).toBe(true);
expect(wrapper.findByTestId('load-error').text()).toBe(i18n.errors.loadError);
});
afterEach(() => {
wrapper.destroy();
});
});
describe('commit result handling', () => {
describe('successful commit', () => {
beforeEach(async () => {
createComponent();
await flushPromises();
await getButtonWithLabel(__('Commit')).trigger('click');
await flushPromises();
});
it('will not show an error', async () => {
expect(wrapper.findByTestId('commit-error').exists()).not.toBe(true);
});
it('will show a toast message', () => {
expect($toast.show).toHaveBeenCalledWith(
s__('PipelineWizard|The file has been committed.'),
);
});
it('emits a done event', () => {
expect(wrapper.emitted().done.length).toBe(1);
});
afterEach(() => {
wrapper.destroy();
jest.clearAllMocks();
});
});
describe('failed commit', () => {
beforeEach(async () => {
createComponent({}, getMockApollo({ commitHasError: true }));
await flushPromises();
await getButtonWithLabel(__('Commit')).trigger('click');
await flushPromises();
});
it('will show an error', async () => {
expect(wrapper.findByTestId('commit-error').exists()).toBe(true);
expect(wrapper.findByTestId('commit-error').text()).toBe(i18n.errors.commitError);
});
it('will not show a toast message', () => {
expect($toast.show).not.toHaveBeenCalledWith(i18n.commitSuccessMessage);
});
it('will not emit a done event', () => {
expect(wrapper.emitted().done?.length).toBeFalsy();
});
afterEach(() => {
wrapper.destroy();
jest.clearAllMocks();
});
});
});
describe('modelling different input combinations', () => {
const projectPath = 'some/path';
const defaultBranch = 'foo';
const fileContent = 'foo: bar';
describe.each`
filename | fileExistsOnDefaultBranch | fileExistsOnInputtedBranch | fileLoadError | commitMessageInputValue | branchInputValue | expectedCommitBranch | expectedCommitMessage | expectedAction
${'foo.yml'} | ${false} | ${undefined} | ${false} | ${'foo'} | ${undefined} | ${defaultBranch} | ${'foo'} | ${'CREATE'}
${'foo.yml'} | ${true} | ${undefined} | ${false} | ${'foo'} | ${undefined} | ${defaultBranch} | ${'foo'} | ${'UPDATE'}
${'foo.yml'} | ${false} | ${true} | ${false} | ${'foo'} | ${'dev'} | ${'dev'} | ${'foo'} | ${'UPDATE'}
${'foo.yml'} | ${false} | ${undefined} | ${false} | ${null} | ${undefined} | ${defaultBranch} | ${COMMIT_MESSAGE_ADD_FILE} | ${'CREATE'}
${'foo.yml'} | ${true} | ${undefined} | ${false} | ${null} | ${undefined} | ${defaultBranch} | ${COMMIT_MESSAGE_UPDATE_FILE} | ${'UPDATE'}
${'foo.yml'} | ${false} | ${true} | ${false} | ${null} | ${'dev'} | ${'dev'} | ${COMMIT_MESSAGE_UPDATE_FILE} | ${'UPDATE'}
`(
'Test with fileExistsOnDefaultBranch=$fileExistsOnDefaultBranch, fileExistsOnInputtedBranch=$fileExistsOnInputtedBranch, commitMessageInputValue=$commitMessageInputValue, branchInputValue=$branchInputValue, commitReturnsError=$commitReturnsError',
({
filename,
fileExistsOnDefaultBranch,
fileExistsOnInputtedBranch,
commitMessageInputValue,
branchInputValue,
expectedCommitBranch,
expectedCommitMessage,
expectedAction,
}) => {
let consoleSpy;
beforeAll(async () => {
createComponent(
{
filename,
defaultBranch,
projectPath,
fileContent,
},
getMockApollo({
fileResultByRef: {
[defaultBranch]: fileExistsOnDefaultBranch ? fileQueryResult : fileQueryEmptyResult,
[branchInputValue]: fileExistsOnInputtedBranch
? fileQueryResult
: fileQueryEmptyResult,
},
}),
);
await flushPromises();
consoleSpy = jest.spyOn(console, 'error');
await wrapper
.findByTestId('commit_message')
.get('textarea')
.setValue(commitMessageInputValue);
if (branchInputValue) {
await wrapper.getComponent(RefSelector).vm.$emit('input', branchInputValue);
}
await Vue.nextTick();
await flushPromises();
});
afterAll(() => {
wrapper.destroy();
});
it('sets up without error', async () => {
expect(consoleSpy).not.toHaveBeenCalled();
});
it('does not show a load error', async () => {
expect(wrapper.findByTestId('load-error').exists()).not.toBe(true);
});
it('sends the expected commit mutation', async () => {
await getButtonWithLabel(__('Commit')).trigger('click');
expect(createCommitMutationHandler).toHaveBeenCalledWith({
input: {
actions: [
{
action: expectedAction,
content: fileContent,
filePath: `/${filename}`,
},
],
branch: expectedCommitBranch,
message: sprintf(expectedCommitMessage, { filename }),
projectPath,
},
});
});
},
);
});
});
export const createCommitMutationResult = {
data: {
commitCreate: {
commit: {
id: '82a9df1',
},
content: 'foo: bar',
errors: null,
},
},
};
export const createCommitMutationErrorResult = {
data: {
commitCreate: {
commit: null,
content: null,
errors: ['Some Error Message'],
},
},
};
export const fileQueryResult = {
data: {
project: {
id: 'gid://gitlab/Project/1',
repository: {
blobs: {
nodes: [
{
id: 'gid://gitlab/Blob/9ff96777b315cd37188f7194d8382c718cb2933c',
},
],
},
},
},
},
};
export const fileQueryEmptyResult = {
data: {
project: {
id: 'gid://gitlab/Project/2',
repository: {
blobs: {
nodes: [],
},
},
},
},
};
export const fileQueryErrorResult = {
data: {
foo: 'bar',
project: {
id: null,
repository: null,
},
},
errors: [{ message: 'GraphQL Error' }],
};
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