Commit ec13864a authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '2647-frontend-mount-the-vue-app' into 'master'

Create branch from Jira issue MVC 2: add new_branch_form Vue App

See merge request gitlab-org/gitlab!66036
parents 6be45816 577c85bc
<script>
import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert } from '@gitlab/ui';
import {
CREATE_BRANCH_ERROR_GENERIC,
CREATE_BRANCH_ERROR_WITH_CONTEXT,
CREATE_BRANCH_SUCCESS_ALERT,
I18N_NEW_BRANCH_PAGE_TITLE,
I18N_NEW_BRANCH_LABEL_DROPDOWN,
I18N_NEW_BRANCH_LABEL_BRANCH,
I18N_NEW_BRANCH_LABEL_SOURCE,
I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT,
} from '../constants';
import createBranchMutation from '../graphql/mutations/create_branch.mutation.graphql';
import ProjectDropdown from './project_dropdown.vue';
import SourceBranchDropdown from './source_branch_dropdown.vue';
const DEFAULT_ALERT_VARIANT = 'danger';
const DEFAULT_ALERT_PARAMS = {
title: '',
message: '',
variant: DEFAULT_ALERT_VARIANT,
primaryButtonLink: '',
primaryButtonText: '',
};
export default {
name: 'JiraConnectNewBranch',
components: {
GlFormGroup,
GlButton,
GlFormInput,
GlForm,
GlAlert,
ProjectDropdown,
SourceBranchDropdown,
},
props: {
initialBranchName: {
type: String,
required: false,
default: '',
},
},
data() {
return {
selectedProject: null,
selectedSourceBranchName: null,
branchName: this.initialBranchName,
createBranchLoading: false,
alertParams: {
...DEFAULT_ALERT_PARAMS,
},
};
},
computed: {
selectedProjectId() {
return this.selectedProject?.id;
},
showAlert() {
return Boolean(this.alertParams?.message);
},
disableSubmitButton() {
return !(this.selectedProject && this.selectedSourceBranchName && this.branchName);
},
},
methods: {
displayAlert({ title, message, variant = DEFAULT_ALERT_VARIANT } = {}) {
this.alertParams = {
title,
message,
variant,
};
},
onAlertDismiss() {
this.alertParams = {
...DEFAULT_ALERT_PARAMS,
};
},
onProjectSelect(project) {
this.selectedProject = project;
this.selectedSourceBranchName = null; // reset branch selection
},
onSourceBranchSelect(branchName) {
this.selectedSourceBranchName = branchName;
},
onError({ title, message } = {}) {
this.displayAlert({
message,
title,
});
},
onSubmit() {
this.createBranch();
},
async createBranch() {
this.createBranchLoading = true;
try {
const { data } = await this.$apollo.mutate({
mutation: createBranchMutation,
variables: {
name: this.branchName,
ref: this.selectedSourceBranchName,
projectPath: this.selectedProject.fullPath,
},
});
const { errors } = data.createBranch;
if (errors.length > 0) {
this.onError({
title: CREATE_BRANCH_ERROR_WITH_CONTEXT,
message: errors[0],
});
} else {
this.displayAlert({
...CREATE_BRANCH_SUCCESS_ALERT,
variant: 'success',
});
}
} catch (e) {
this.onError({
message: CREATE_BRANCH_ERROR_GENERIC,
});
}
this.createBranchLoading = false;
},
},
i18n: {
I18N_NEW_BRANCH_PAGE_TITLE,
I18N_NEW_BRANCH_LABEL_DROPDOWN,
I18N_NEW_BRANCH_LABEL_BRANCH,
I18N_NEW_BRANCH_LABEL_SOURCE,
I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT,
},
};
</script>
<template>
<div>
<div class="gl-border-1 gl-border-b-solid gl-border-gray-100 gl-mb-5 gl-mt-7">
<h1 class="page-title">
{{ $options.i18n.I18N_NEW_BRANCH_PAGE_TITLE }}
</h1>
</div>
<gl-alert
v-if="showAlert"
class="gl-mb-5"
:variant="alertParams.variant"
:title="alertParams.title"
@dismiss="onAlertDismiss"
>
{{ alertParams.message }}
</gl-alert>
<gl-form @submit.prevent="onSubmit">
<gl-form-group
:label="$options.i18n.I18N_NEW_BRANCH_LABEL_DROPDOWN"
label-for="project-select"
>
<project-dropdown
id="project-select"
:selected-project="selectedProject"
@change="onProjectSelect"
@error="onError"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH"
label-for="branch-name-input"
>
<gl-form-input id="branch-name-input" v-model="branchName" type="text" required />
</gl-form-group>
<gl-form-group
:label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE"
label-for="source-branch-select"
>
<source-branch-dropdown
id="source-branch-select"
:selected-project="selectedProject"
:selected-branch-name="selectedSourceBranchName"
@change="onSourceBranchSelect"
@error="onError"
/>
</gl-form-group>
<div class="form-actions">
<gl-button
:loading="createBranchLoading"
type="submit"
variant="confirm"
:disabled="disableSubmitButton"
>
{{ $options.i18n.I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT }}
</gl-button>
</div>
</gl-form>
</div>
</template>
......@@ -60,7 +60,7 @@ export default {
},
},
methods: {
async onProjectSelect(project) {
onProjectSelect(project) {
this.$emit('change', project);
},
onError({ message } = {}) {
......
import { __, s__ } from '~/locale';
export const BRANCHES_PER_PAGE = 20;
export const PROJECTS_PER_PAGE = 20;
export const I18N_NEW_BRANCH_PAGE_TITLE = __('New branch');
export const I18N_NEW_BRANCH_LABEL_DROPDOWN = __('Project');
export const I18N_NEW_BRANCH_LABEL_BRANCH = __('Branch name');
export const I18N_NEW_BRANCH_LABEL_SOURCE = __('Source branch');
export const I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT = __('Create branch');
export const CREATE_BRANCH_ERROR_GENERIC = s__(
'JiraConnect|Failed to create branch. Please try again.',
);
export const CREATE_BRANCH_ERROR_WITH_CONTEXT = s__('JiraConnect|Failed to create branch.');
export const CREATE_BRANCH_SUCCESS_ALERT = {
title: s__('JiraConnect|New branch was successfully created.'),
message: s__('JiraConnect|You can now close this window and return to Jira.'),
};
mutation createBranch($name: String!, $projectPath: ID!, $ref: String!) {
createBranch(input: { name: $name, projectPath: $projectPath, ref: $ref }) {
clientMutationId
errors
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import JiraConnectNewBranchForm from '~/jira_connect/branches/components/new_branch_form.vue';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default async function initJiraConnectBranches() {
const el = document.querySelector('.js-jira-connect-create-branch');
if (!el) {
return null;
}
const { initialBranchName } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
assumeImmutableResults: true,
},
),
});
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(JiraConnectNewBranchForm, {
props: {
initialBranchName,
},
});
},
});
}
......@@ -18523,6 +18523,18 @@ msgstr ""
msgid "Jira-GitLab user mapping template"
msgstr ""
msgid "JiraConnect|Failed to create branch."
msgstr ""
msgid "JiraConnect|Failed to create branch. Please try again."
msgstr ""
msgid "JiraConnect|New branch was successfully created."
msgstr ""
msgid "JiraConnect|You can now close this window and return to Jira."
msgstr ""
msgid "JiraService| on branch %{branch_link}"
msgstr ""
......@@ -30846,6 +30858,9 @@ msgstr ""
msgid "Source IP"
msgstr ""
msgid "Source branch"
msgstr ""
msgid "Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}"
msgstr ""
......
import { GlAlert, GlForm, GlFormInput, GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import NewBranchForm from '~/jira_connect/branches/components/new_branch_form.vue';
import ProjectDropdown from '~/jira_connect/branches/components/project_dropdown.vue';
import SourceBranchDropdown from '~/jira_connect/branches/components/source_branch_dropdown.vue';
import {
CREATE_BRANCH_ERROR_GENERIC,
CREATE_BRANCH_ERROR_WITH_CONTEXT,
CREATE_BRANCH_SUCCESS_ALERT,
} from '~/jira_connect/branches/constants';
import createBranchMutation from '~/jira_connect/branches/graphql/mutations/create_branch.mutation.graphql';
const mockProject = {
id: 'test',
fullPath: 'test-path',
repository: {
branchNames: ['main', 'f-test', 'release'],
rootRef: 'main',
},
};
const mockCreateBranchMutationResponse = {
data: {
createBranch: {
clientMutationId: 1,
errors: [],
},
},
};
const mockCreateBranchMutationResponseWithErrors = {
data: {
createBranch: {
clientMutationId: 1,
errors: ['everything is broken, sorry.'],
},
},
};
const mockCreateBranchMutationSuccess = jest
.fn()
.mockResolvedValue(mockCreateBranchMutationResponse);
const mockCreateBranchMutationWithErrors = jest
.fn()
.mockResolvedValue(mockCreateBranchMutationResponseWithErrors);
const mockCreateBranchMutationFailed = jest.fn().mockRejectedValue(new Error('GraphQL error'));
const mockMutationLoading = jest.fn().mockReturnValue(new Promise(() => {}));
const localVue = createLocalVue();
describe('NewBranchForm', () => {
let wrapper;
const findSourceBranchDropdown = () => wrapper.findComponent(SourceBranchDropdown);
const findProjectDropdown = () => wrapper.findComponent(ProjectDropdown);
const findAlert = () => wrapper.findComponent(GlAlert);
const findForm = () => wrapper.findComponent(GlForm);
const findInput = () => wrapper.findComponent(GlFormInput);
const findButton = () => wrapper.findComponent(GlButton);
const completeForm = async () => {
await findInput().vm.$emit('input', 'cool-branch-name');
await findProjectDropdown().vm.$emit('change', mockProject);
await findSourceBranchDropdown().vm.$emit('change', 'source-branch');
};
function createMockApolloProvider({
mockCreateBranchMutation = mockCreateBranchMutationSuccess,
} = {}) {
localVue.use(VueApollo);
const mockApollo = createMockApollo([[createBranchMutation, mockCreateBranchMutation]]);
return mockApollo;
}
function createComponent({ mockApollo } = {}) {
wrapper = shallowMount(NewBranchForm, {
localVue,
apolloProvider: mockApollo || createMockApolloProvider(),
});
}
afterEach(() => {
wrapper.destroy();
});
describe('when selecting items from dropdowns', () => {
describe('when a project is selected', () => {
it('sets the `selectedProject` prop for ProjectDropdown and SourceBranchDropdown', async () => {
createComponent();
const projectDropdown = findProjectDropdown();
await projectDropdown.vm.$emit('change', mockProject);
expect(projectDropdown.props('selectedProject')).toEqual(mockProject);
expect(findSourceBranchDropdown().props('selectedProject')).toEqual(mockProject);
});
});
describe('when a source branch is selected', () => {
it('sets the `selectedBranchName` prop for SourceBranchDropdown', async () => {
createComponent();
const mockBranchName = 'main';
const sourceBranchDropdown = findSourceBranchDropdown();
await sourceBranchDropdown.vm.$emit('change', mockBranchName);
expect(sourceBranchDropdown.props('selectedBranchName')).toBe(mockBranchName);
});
});
});
describe('when submitting form', () => {
describe('when form submission is loading', () => {
it('sets submit button `loading` prop to `true`', async () => {
createComponent({
mockApollo: createMockApolloProvider({
mockCreateBranchMutation: mockMutationLoading,
}),
});
await completeForm();
await findForm().vm.$emit('submit', new Event('submit'));
await waitForPromises();
expect(findButton().props('loading')).toBe(true);
});
});
describe('when form submission is successful', () => {
beforeEach(async () => {
createComponent();
await completeForm();
await findForm().vm.$emit('submit', new Event('submit'));
await waitForPromises();
});
it('displays a success message', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(CREATE_BRANCH_SUCCESS_ALERT.message);
expect(alert.props()).toMatchObject({
title: CREATE_BRANCH_SUCCESS_ALERT.title,
variant: 'success',
});
});
it('called `createBranch` mutation correctly', () => {
expect(mockCreateBranchMutationSuccess).toHaveBeenCalledWith({
name: 'cool-branch-name',
projectPath: mockProject.fullPath,
ref: 'source-branch',
});
});
it('sets submit button `loading` prop to `false`', () => {
expect(findButton().props('loading')).toBe(false);
});
});
describe('when form submission fails', () => {
describe.each`
scenario | mutation | alertTitle | alertText
${'with errors-as-data'} | ${mockCreateBranchMutationWithErrors} | ${CREATE_BRANCH_ERROR_WITH_CONTEXT} | ${mockCreateBranchMutationResponseWithErrors.data.createBranch.errors[0]}
${'top-level error'} | ${mockCreateBranchMutationFailed} | ${''} | ${CREATE_BRANCH_ERROR_GENERIC}
`('', ({ mutation, alertTitle, alertText }) => {
beforeEach(async () => {
createComponent({
mockApollo: createMockApolloProvider({
mockCreateBranchMutation: mutation,
}),
});
await completeForm();
await findForm().vm.$emit('submit', new Event('submit'));
await waitForPromises();
});
it('displays an alert', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(alertText);
expect(alert.props()).toMatchObject({ title: alertTitle, variant: 'danger' });
});
it('sets submit button `loading` prop to `false`', () => {
expect(findButton().props('loading')).toBe(false);
});
});
});
});
describe('error handling', () => {
describe.each`
component | componentName
${SourceBranchDropdown} | ${'SourceBranchDropdown'}
${ProjectDropdown} | ${'ProjectDropdown'}
`('when $componentName emits error', ({ component }) => {
const mockErrorMessage = 'oh noes!';
beforeEach(async () => {
createComponent();
await wrapper.findComponent(component).vm.$emit('error', { message: mockErrorMessage });
});
it('displays an alert', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(mockErrorMessage);
expect(alert.props('variant')).toBe('danger');
});
describe('when alert is dismissed', () => {
it('hides alert', async () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
await alert.vm.$emit('dismiss');
expect(alert.exists()).toBe(false);
});
});
});
});
});
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