Commit 4a3dec29 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '337274-jira-connect-better-empty-states' into 'master'

Jira Connect create branch page: Alert when lacking permissions

See merge request gitlab-org/gitlab!80043
parents 2993cbf7 469676b5
<script>
import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert } from '@gitlab/ui';
import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import {
CREATE_BRANCH_ERROR_GENERIC,
CREATE_BRANCH_ERROR_WITH_CONTEXT,
......@@ -7,6 +8,7 @@ import {
I18N_NEW_BRANCH_LABEL_BRANCH,
I18N_NEW_BRANCH_LABEL_SOURCE,
I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT,
I18N_NEW_BRANCH_PERMISSION_ALERT,
} from '../constants';
import createBranchMutation from '../graphql/mutations/create_branch.mutation.graphql';
import ProjectDropdown from './project_dropdown.vue';
......@@ -17,6 +19,8 @@ const DEFAULT_ALERT_PARAMS = {
title: '',
message: '',
variant: DEFAULT_ALERT_VARIANT,
link: undefined,
dismissible: true,
};
export default {
......@@ -27,10 +31,16 @@ export default {
GlFormInput,
GlForm,
GlAlert,
GlSprintf,
GlLink,
ProjectDropdown,
SourceBranchDropdown,
},
inject: ['initialBranchName'],
inject: {
initialBranchName: {
default: '',
},
},
data() {
return {
selectedProject: null,
......@@ -40,6 +50,7 @@ export default {
alertParams: {
...DEFAULT_ALERT_PARAMS,
},
hasPermission: false,
};
},
computed: {
......@@ -49,19 +60,38 @@ export default {
showAlert() {
return Boolean(this.alertParams?.message);
},
isBranchNameValid() {
return (this.branchName ?? '').trim().length > 0;
},
disableSubmitButton() {
return !(this.selectedProject && this.selectedSourceBranchName && this.branchName);
return !(this.selectedProject && this.selectedSourceBranchName && this.isBranchNameValid);
},
},
methods: {
displayAlert({ title, message, variant = DEFAULT_ALERT_VARIANT } = {}) {
displayAlert({
title,
message,
variant = DEFAULT_ALERT_VARIANT,
link,
dismissible = true,
} = {}) {
this.alertParams = {
title,
message,
variant,
link,
dismissible,
};
},
onAlertDismiss() {
setPermissionAlert() {
this.displayAlert({
message: I18N_NEW_BRANCH_PERMISSION_ALERT,
variant: 'warning',
link: helpPagePath('user/permissions', { anchor: 'project-members-permissions' }),
dismissible: false,
});
},
dismissAlert() {
this.alertParams = {
...DEFAULT_ALERT_PARAMS,
};
......@@ -69,6 +99,14 @@ export default {
onProjectSelect(project) {
this.selectedProject = project;
this.selectedSourceBranchName = null; // reset branch selection
this.hasPermission = this.selectedProject.userPermissions.pushCode;
if (!this.hasPermission) {
this.setPermissionAlert();
} else {
// clear alert if the user has permissions for the newly-selected project.
this.dismissAlert();
}
},
onSourceBranchSelect(branchName) {
this.selectedSourceBranchName = branchName;
......@@ -127,10 +165,18 @@ export default {
class="gl-mb-5"
:variant="alertParams.variant"
:title="alertParams.title"
@dismiss="onAlertDismiss"
:dismissible="alertParams.dismissible"
@dismiss="dismissAlert"
>
{{ alertParams.message }}
<gl-sprintf :message="alertParams.message">
<template #link="{ content }">
<gl-link :href="alertParams.link" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<gl-form-group :label="$options.i18n.I18N_NEW_BRANCH_LABEL_DROPDOWN" label-for="project-select">
<project-dropdown
id="project-select"
......@@ -140,26 +186,28 @@ export default {
/>
</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>
<template v-if="selectedProject && hasPermission">
<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>
<gl-form-group
:label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH"
label-for="branch-name-input"
class="gl-max-w-62"
>
<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_BRANCH"
label-for="branch-name-input"
class="gl-max-w-62"
>
<gl-form-input id="branch-name-input" v-model="branchName" type="text" required />
</gl-form-group>
</template>
<div class="form-actions">
<gl-button
......
......@@ -23,3 +23,6 @@ export const I18N_NEW_BRANCH_SUCCESS_TITLE = s__(
export const I18N_NEW_BRANCH_SUCCESS_MESSAGE = s__(
'JiraConnect|You can now close this window and return to Jira.',
);
export const I18N_NEW_BRANCH_PERMISSION_ALERT = s__(
"JiraConnect|You don't have permission to create branches for this project. Select a different project or contact the project owner for access. %{linkStart}Learn more.%{linkEnd}",
);
......@@ -26,6 +26,9 @@ query jiraGetProjects(
repository {
empty
}
userPermissions {
pushCode
}
}
pageInfo {
...PageInfo
......
......@@ -20515,6 +20515,9 @@ msgstr ""
msgid "JiraConnect|You can now close this window and return to Jira."
msgstr ""
msgid "JiraConnect|You don't have permission to create branches for this project. Select a different project or contact the project owner for access. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "JiraRequest|A connection error occurred while connecting to Jira. Try your request again."
msgstr ""
......
......@@ -25,8 +25,9 @@ RSpec.describe 'Create GitLab branches from Jira', :js do
it 'select project and branch and submit the form' do
visit new_jira_connect_branch_path(issue_key: 'ACME-123', issue_summary: 'My issue !@#$% title')
expect(page).to have_field('Branch name', with: 'ACME-123-my-issue-title')
expect(page).to have_button('Create branch', disabled: true)
# initially, branch field should be hidden.
expect(page).not_to have_field('Branch name')
# Select project1
......@@ -44,6 +45,7 @@ RSpec.describe 'Create GitLab branches from Jira', :js do
click_on 'Alice / foo'
end
expect(page).to have_field('Branch name', with: 'ACME-123-my-issue-title')
expect(page).to have_button('Create branch', disabled: false)
click_on 'master'
......
import { GlAlert, GlForm, GlFormInput, GlButton } from '@gitlab/ui';
import { GlAlert, GlForm, GlFormInput, GlButton, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
......@@ -10,17 +10,12 @@ import SourceBranchDropdown from '~/jira_connect/branches/components/source_bran
import {
CREATE_BRANCH_ERROR_GENERIC,
CREATE_BRANCH_ERROR_WITH_CONTEXT,
I18N_NEW_BRANCH_PERMISSION_ALERT,
} from '~/jira_connect/branches/constants';
import createBranchMutation from '~/jira_connect/branches/graphql/mutations/create_branch.mutation.graphql';
import { mockProjects } from '../mock_data';
const mockProject = {
id: 'test',
fullPath: 'test-path',
repository: {
branchNames: ['main', 'f-test', 'release'],
rootRef: 'main',
},
};
const mockProject = mockProjects[0];
const mockCreateBranchMutationResponse = {
data: {
createBranch: {
......@@ -52,14 +47,15 @@ describe('NewBranchForm', () => {
const findSourceBranchDropdown = () => wrapper.findComponent(SourceBranchDropdown);
const findProjectDropdown = () => wrapper.findComponent(ProjectDropdown);
const findAlert = () => wrapper.findComponent(GlAlert);
const findAlertSprintf = () => findAlert().findComponent(GlSprintf);
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');
await findInput().vm.$emit('input', 'cool-branch-name');
};
function createMockApolloProvider({
......@@ -87,27 +83,107 @@ describe('NewBranchForm', () => {
});
describe('when selecting items from dropdowns', () => {
describe('when a project is selected', () => {
it('sets the `selectedProject` prop for ProjectDropdown and SourceBranchDropdown', async () => {
describe('when no project selected', () => {
beforeEach(() => {
createComponent();
});
const projectDropdown = findProjectDropdown();
await projectDropdown.vm.$emit('change', mockProject);
it('hides source branch selection and branch name input', () => {
expect(findSourceBranchDropdown().exists()).toBe(false);
expect(findInput().exists()).toBe(false);
});
it('disables the submit button', () => {
expect(findButton().props('disabled')).toBe(true);
});
});
describe('when a valid project is selected', () => {
describe("when a source branch isn't selected", () => {
beforeEach(async () => {
createComponent();
await findProjectDropdown().vm.$emit('change', mockProject);
});
it('sets the `selectedProject` prop for ProjectDropdown and SourceBranchDropdown', () => {
expect(findProjectDropdown().props('selectedProject')).toEqual(mockProject);
expect(findSourceBranchDropdown().exists()).toBe(true);
expect(findSourceBranchDropdown().props('selectedProject')).toEqual(mockProject);
});
it('disables the submit button', () => {
expect(findButton().props('disabled')).toBe(true);
});
it('renders branch input field', () => {
expect(findInput().exists()).toBe(true);
});
});
describe('when `initialBranchName` is provided', () => {
it('sets value of branch name input to `initialBranchName` by default', async () => {
const mockInitialBranchName = 'ap1-test-branch-name';
createComponent({ provide: { initialBranchName: mockInitialBranchName } });
await findProjectDropdown().vm.$emit('change', mockProject);
expect(findInput().attributes('value')).toBe(mockInitialBranchName);
});
});
describe('when a source branch is selected', () => {
it('sets the `selectedBranchName` prop for SourceBranchDropdown', async () => {
createComponent();
await completeForm();
const mockBranchName = 'main';
const sourceBranchDropdown = findSourceBranchDropdown();
await sourceBranchDropdown.vm.$emit('change', mockBranchName);
expect(sourceBranchDropdown.props('selectedBranchName')).toBe(mockBranchName);
});
expect(projectDropdown.props('selectedProject')).toEqual(mockProject);
expect(findSourceBranchDropdown().props('selectedProject')).toEqual(mockProject);
describe.each`
branchName | submitButtonDisabled
${undefined} | ${true}
${''} | ${true}
${' '} | ${true}
${'test-branch'} | ${false}
`('when branch name is $branchName', ({ branchName, submitButtonDisabled }) => {
it(`sets submit button 'disabled' prop to ${submitButtonDisabled}`, async () => {
createComponent();
await completeForm();
await findInput().vm.$emit('input', branchName);
expect(findButton().props('disabled')).toBe(submitButtonDisabled);
});
});
});
});
describe('when a source branch is selected', () => {
it('sets the `selectedBranchName` prop for SourceBranchDropdown', async () => {
describe("when user doesn't have push permissions for the selected project", () => {
beforeEach(async () => {
createComponent();
const mockBranchName = 'main';
const sourceBranchDropdown = findSourceBranchDropdown();
await sourceBranchDropdown.vm.$emit('change', mockBranchName);
const projectDropdown = findProjectDropdown();
await projectDropdown.vm.$emit('change', {
...mockProject,
userPermissions: { pushCode: false },
});
});
it('displays an alert', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(findAlertSprintf().attributes('message')).toBe(I18N_NEW_BRANCH_PERMISSION_ALERT);
expect(alert.props('variant')).toBe('warning');
expect(alert.props('dismissible')).toBe(false);
});
expect(sourceBranchDropdown.props('selectedBranchName')).toBe(mockBranchName);
it('hides source branch selection and branch name input', () => {
expect(findSourceBranchDropdown().exists()).toBe(false);
expect(findInput().exists()).toBe(false);
});
});
});
......@@ -179,7 +255,7 @@ describe('NewBranchForm', () => {
it('displays an alert', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(alertText);
expect(findAlertSprintf().attributes('message')).toBe(alertText);
expect(alert.props()).toMatchObject({ title: alertTitle, variant: 'danger' });
});
......@@ -190,15 +266,6 @@ describe('NewBranchForm', () => {
});
});
describe('when `initialBranchName` is specified', () => {
it('sets value of branch name input to `initialBranchName` by default', () => {
const mockInitialBranchName = 'ap1-test-branch-name';
createComponent({ provide: { initialBranchName: mockInitialBranchName } });
expect(findInput().attributes('value')).toBe(mockInitialBranchName);
});
});
describe('error handling', () => {
describe.each`
component | componentName
......@@ -209,13 +276,15 @@ describe('NewBranchForm', () => {
beforeEach(async () => {
createComponent();
await completeForm();
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(findAlertSprintf().attributes('message')).toBe(mockErrorMessage);
expect(alert.props('variant')).toBe('danger');
});
......
......@@ -14,30 +14,7 @@ import ProjectDropdown from '~/jira_connect/branches/components/project_dropdown
import { PROJECTS_PER_PAGE } from '~/jira_connect/branches/constants';
import getProjectsQuery from '~/jira_connect/branches/graphql/queries/get_projects.query.graphql';
const mockProjects = [
{
id: 'test',
name: 'test',
nameWithNamespace: 'test',
avatarUrl: 'https://gitlab.com',
path: 'test-path',
fullPath: 'test-path',
repository: {
empty: false,
},
},
{
id: 'gitlab',
name: 'GitLab',
nameWithNamespace: 'gitlab-org/gitlab',
avatarUrl: 'https://gitlab.com',
path: 'gitlab',
fullPath: 'gitlab-org/gitlab',
repository: {
empty: false,
},
},
];
import { mockProjects } from '../mock_data';
const mockProjectsQueryResponse = {
data: {
......@@ -134,7 +111,7 @@ describe('ProjectDropdown', () => {
});
describe('when selecting a dropdown item', () => {
it('emits `change` event with the selected project name', async () => {
it('emits `change` event with the selected project', async () => {
const mockProject = mockProjects[0];
const itemToSelect = findDropdownItemByProjectId(mockProject.id);
await itemToSelect.vm.$emit('click');
......@@ -146,7 +123,7 @@ describe('ProjectDropdown', () => {
describe('when `selectedProject` prop is specified', () => {
const mockProject = mockProjects[0];
beforeEach(async () => {
beforeEach(() => {
wrapper.setProps({
selectedProject: mockProject,
});
......
......@@ -7,15 +7,16 @@ import waitForPromises from 'helpers/wait_for_promises';
import SourceBranchDropdown from '~/jira_connect/branches/components/source_branch_dropdown.vue';
import { BRANCHES_PER_PAGE } from '~/jira_connect/branches/constants';
import getProjectQuery from '~/jira_connect/branches/graphql/queries/get_project.query.graphql';
import { mockProjects } from '../mock_data';
const mockProject = {
id: 'test',
fullPath: 'test-path',
repository: {
branchNames: ['main', 'f-test', 'release'],
rootRef: 'main',
},
};
const mockSelectedProject = mockProjects[0];
const mockProjectQueryResponse = {
data: {
......@@ -76,7 +77,7 @@ describe('SourceBranchDropdown', () => {
describe('when `selectedProject` becomes specified', () => {
beforeEach(async () => {
wrapper.setProps({
selectedProject: mockProject,
selectedProject: mockSelectedProject,
});
await waitForPromises();
......@@ -101,7 +102,7 @@ describe('SourceBranchDropdown', () => {
it('renders loading icon in dropdown', () => {
createComponent({
mockApollo: createMockApolloProvider({ getProjectQueryLoading: true }),
props: { selectedProject: mockProject },
props: { selectedProject: mockSelectedProject },
});
expect(findLoadingIcon().isVisible()).toBe(true);
......@@ -111,7 +112,7 @@ describe('SourceBranchDropdown', () => {
describe('when branches have loaded', () => {
describe('when searching branches', () => {
it('triggers a refetch', async () => {
createComponent({ mountFn: mount, props: { selectedProject: mockProject } });
createComponent({ mountFn: mount, props: { selectedProject: mockSelectedProject } });
await waitForPromises();
jest.clearAllMocks();
......@@ -129,7 +130,7 @@ describe('SourceBranchDropdown', () => {
describe('template', () => {
beforeEach(async () => {
createComponent({ props: { selectedProject: mockProject } });
createComponent({ props: { selectedProject: mockSelectedProject } });
await waitForPromises();
});
......
export const mockProjects = [
{
id: 'test',
name: 'test',
nameWithNamespace: 'test',
avatarUrl: 'https://gitlab.com',
path: 'test-path',
fullPath: 'test-path',
repository: {
empty: false,
},
userPermissions: {
pushCode: true,
},
},
{
id: 'gitlab',
name: 'GitLab',
nameWithNamespace: 'gitlab-org/gitlab',
avatarUrl: 'https://gitlab.com',
path: 'gitlab',
fullPath: 'gitlab-org/gitlab',
repository: {
empty: false,
},
userPermissions: {
pushCode: true,
},
},
];
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