Commit 3b31e6c2 authored by David O'Regan's avatar David O'Regan

Merge branch 'add-commit-step-component-for-pipeline-wizard' into 'master'

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

See merge request gitlab-org/gitlab!80171
parents 9863afff 9fc81d96
<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