Commit 118d7875 authored by Peter Leitzen's avatar Peter Leitzen

Merge branch '13020-add-link-functionality-to-jira-connect-app' into 'master'

Add "Link" functionality to Jira Connect app new UI

See merge request gitlab-org/gitlab!51866
parents 41fcdee9 cf3f8d01
import axios from 'axios'; import axios from 'axios';
const getJwt = async () => { export const getJwt = () => {
return AP.context.getToken(); return new Promise((resolve) => {
AP.context.getToken((token) => {
resolve(token);
});
});
}; };
export const addSubscription = async (addPath, namespace) => { export const addSubscription = async (addPath, namespace) => {
......
<script> <script>
import { GlTabs, GlTab, GlLoadingIcon, GlPagination } from '@gitlab/ui'; import { GlTabs, GlTab, GlLoadingIcon, GlPagination, GlAlert } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { fetchGroups } from '~/jira_connect/api'; import { fetchGroups } from '~/jira_connect/api';
...@@ -12,6 +12,7 @@ export default { ...@@ -12,6 +12,7 @@ export default {
GlTab, GlTab,
GlLoadingIcon, GlLoadingIcon,
GlPagination, GlPagination,
GlAlert,
GroupsListItem, GroupsListItem,
}, },
inject: { inject: {
...@@ -26,6 +27,7 @@ export default { ...@@ -26,6 +27,7 @@ export default {
page: 1, page: 1,
perPage: defaultPerPage, perPage: defaultPerPage,
totalItems: 0, totalItems: 0,
errorMessage: null,
}; };
}, },
mounted() { mounted() {
...@@ -46,8 +48,7 @@ export default { ...@@ -46,8 +48,7 @@ export default {
this.groups = response.data; this.groups = response.data;
}) })
.catch(() => { .catch(() => {
// eslint-disable-next-line no-alert this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.');
alert(s__('Integrations|Failed to load namespaces. Please try again.'));
}) })
.finally(() => { .finally(() => {
this.isLoading = false; this.isLoading = false;
...@@ -58,31 +59,42 @@ export default { ...@@ -58,31 +59,42 @@ export default {
</script> </script>
<template> <template>
<gl-tabs> <div>
<gl-tab :title="__('Groups and subgroups')" class="gl-pt-3"> <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" @dismiss="errorMessage = null">
<gl-loading-icon v-if="isLoading" size="md" /> {{ errorMessage }}
<div v-else-if="groups.length === 0" class="gl-text-center"> </gl-alert>
<h5>{{ s__('Integrations|No available namespaces.') }}</h5>
<p class="gl-mt-5">
{{
s__('Integrations|You must have owner or maintainer permissions to link namespaces.')
}}
</p>
</div>
<ul v-else class="gl-list-style-none gl-pl-0">
<groups-list-item v-for="group in groups" :key="group.id" :group="group" />
</ul>
<div class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-tabs>
<gl-pagination <gl-tab :title="__('Groups and subgroups')" class="gl-pt-3">
v-if="totalItems > perPage && groups.length > 0" <gl-loading-icon v-if="isLoading" size="md" />
v-model="page" <div v-else-if="groups.length === 0" class="gl-text-center">
class="gl-mb-0" <h5>{{ s__('Integrations|No available namespaces.') }}</h5>
:per-page="perPage" <p class="gl-mt-5">
:total-items="totalItems" {{
@input="loadGroups" s__('Integrations|You must have owner or maintainer permissions to link namespaces.')
/> }}
</div> </p>
</gl-tab> </div>
</gl-tabs> <ul v-else class="gl-list-style-none gl-pl-0">
<groups-list-item
v-for="group in groups"
:key="group.id"
:group="group"
@error="errorMessage = $event"
/>
</ul>
<div class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-pagination
v-if="totalItems > perPage && groups.length > 0"
v-model="page"
class="gl-mb-0"
:per-page="perPage"
:total-items="totalItems"
@input="loadGroups"
/>
</div>
</gl-tab>
</gl-tabs>
</div>
</template> </template>
<script> <script>
import { GlIcon, GlAvatar } from '@gitlab/ui'; import { GlAvatar, GlButton, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import { addSubscription } from '~/jira_connect/api';
export default { export default {
components: { components: {
GlIcon,
GlAvatar, GlAvatar,
GlButton,
GlIcon,
},
inject: {
subscriptionsPath: {
default: '',
},
}, },
props: { props: {
group: { group: {
...@@ -12,6 +21,31 @@ export default { ...@@ -12,6 +21,31 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
isLoading: false,
};
},
methods: {
onClick() {
this.isLoading = true;
addSubscription(this.subscriptionsPath, this.group.full_path)
.then(() => {
AP.navigator.reload();
})
.catch((error) => {
this.$emit(
'error',
error?.response?.data?.error ||
s__('Integrations|Failed to link namespace. Please try again.'),
);
})
.finally(() => {
this.isLoading = false;
});
},
},
}; };
</script> </script>
...@@ -36,6 +70,14 @@ export default { ...@@ -36,6 +70,14 @@ export default {
<p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p> <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p>
</div> </div>
</div> </div>
<gl-button
category="secondary"
variant="success"
:loading="isLoading"
@click.prevent="onClick"
>{{ __('Link') }}</gl-button
>
</div> </div>
</div> </div>
</li> </li>
......
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex';
import $ from 'jquery'; import $ from 'jquery';
import setConfigs from '@gitlab/ui/dist/config'; import setConfigs from '@gitlab/ui/dist/config';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
...@@ -10,8 +9,6 @@ import { addSubscription, removeSubscription } from '~/jira_connect/api'; ...@@ -10,8 +9,6 @@ import { addSubscription, removeSubscription } from '~/jira_connect/api';
import createStore from './store'; import createStore from './store';
import { SET_ERROR_MESSAGE } from './store/mutation_types'; import { SET_ERROR_MESSAGE } from './store/mutation_types';
Vue.use(Vuex);
const store = createStore(); const store = createStore();
/** /**
...@@ -73,13 +70,14 @@ function initJiraConnect() { ...@@ -73,13 +70,14 @@ function initJiraConnect() {
Vue.use(Translate); Vue.use(Translate);
Vue.use(GlFeatureFlagsPlugin); Vue.use(GlFeatureFlagsPlugin);
const { groupsPath } = el.dataset; const { groupsPath, subscriptionsPath } = el.dataset;
return new Vue({ return new Vue({
el, el,
store, store,
provide: { provide: {
groupsPath, groupsPath,
subscriptionsPath,
}, },
render(createElement) { render(createElement) {
return createElement(JiraConnectApp); return createElement(JiraConnectApp);
......
import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
Vue.use(Vuex);
export default () => export default () =>
new Vuex.Store({ new Vuex.Store({
state,
mutations, mutations,
state,
}); });
...@@ -5,9 +5,14 @@ module JiraConnectHelper ...@@ -5,9 +5,14 @@ module JiraConnectHelper
Feature.enabled?(:new_jira_connect_ui, type: :development, default_enabled: :yaml) Feature.enabled?(:new_jira_connect_ui, type: :development, default_enabled: :yaml)
end end
def jira_connect_app_data def jira_connect_app_data(subscriptions)
return {} unless new_jira_connect_ui?
skip_groups = subscriptions.map(&:namespace_id)
{ {
groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER }) groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }),
subscriptions_path: jira_connect_subscriptions_path
} }
end end
end end
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
.gl-mt-5 .gl-mt-5
%p Note: this integration only works with accounts on GitLab.com (SaaS). %p Note: this integration only works with accounts on GitLab.com (SaaS).
- else - else
.js-jira-connect-app{ data: jira_connect_app_data } .js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) }
- unless new_jira_connect_ui? - unless new_jira_connect_ui?
%form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path } %form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path }
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
Link namespace to Jira Link namespace to Jira
- if @subscriptions.present? - if @subscriptions.present?
%table.subscriptions %table.subscriptions.gl-w-full
%thead %thead
%tr %tr
%th Namespace %th Namespace
......
...@@ -15410,6 +15410,9 @@ msgstr "" ...@@ -15410,6 +15410,9 @@ msgstr ""
msgid "Integrations|Enable comments" msgid "Integrations|Enable comments"
msgstr "" msgstr ""
msgid "Integrations|Failed to link namespace. Please try again."
msgstr ""
msgid "Integrations|Failed to load namespaces. Please try again." msgid "Integrations|Failed to load namespaces. Please try again."
msgstr "" msgstr ""
...@@ -16986,6 +16989,9 @@ msgstr[1] "" ...@@ -16986,6 +16989,9 @@ msgstr[1] ""
msgid "Line changes" msgid "Line changes"
msgstr "" msgstr ""
msgid "Link"
msgstr ""
msgid "Link Prometheus monitoring to GitLab." msgid "Link Prometheus monitoring to GitLab."
msgstr "" msgstr ""
......
...@@ -14,7 +14,7 @@ describe('JiraConnect API', () => { ...@@ -14,7 +14,7 @@ describe('JiraConnect API', () => {
const mockJwt = 'jwt'; const mockJwt = 'jwt';
const mockResponse = { success: true }; const mockResponse = { success: true };
const tokenSpy = jest.fn().mockReturnValue(mockJwt); const tokenSpy = jest.fn((callback) => callback(mockJwt));
window.AP = { window.AP = {
context: { context: {
......
import { shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import { GlAvatar } from '@gitlab/ui'; import { GlAvatar, GlButton } from '@gitlab/ui';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mockGroup1 } from '../mock_data'; import { mockGroup1 } from '../mock_data';
import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; import GroupsListItem from '~/jira_connect/components/groups_list_item.vue';
import * as JiraConnectApi from '~/jira_connect/api';
describe('GroupsListItem', () => { describe('GroupsListItem', () => {
let wrapper; let wrapper;
const mockSubscriptionPath = 'subscriptionPath';
const reloadSpy = jest.fn();
global.AP = {
navigator: {
reload: reloadSpy,
},
};
const createComponent = () => { const createComponent = ({ mountFn = shallowMount } = {}) => {
wrapper = extendedWrapper( wrapper = extendedWrapper(
shallowMount(GroupsListItem, { mountFn(GroupsListItem, {
propsData: { propsData: {
group: mockGroup1, group: mockGroup1,
}, },
provide: {
subscriptionsPath: mockSubscriptionPath,
},
}), }),
); );
}; };
beforeEach(() => {
createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
...@@ -30,17 +41,82 @@ describe('GroupsListItem', () => { ...@@ -30,17 +41,82 @@ describe('GroupsListItem', () => {
const findGlAvatar = () => wrapper.find(GlAvatar); const findGlAvatar = () => wrapper.find(GlAvatar);
const findGroupName = () => wrapper.findByTestId('group-list-item-name'); const findGroupName = () => wrapper.findByTestId('group-list-item-name');
const findGroupDescription = () => wrapper.findByTestId('group-list-item-description'); const findGroupDescription = () => wrapper.findByTestId('group-list-item-description');
const findLinkButton = () => wrapper.find(GlButton);
const clickLinkButton = () => findLinkButton().trigger('click');
it('renders group avatar', () => { describe('template', () => {
expect(findGlAvatar().exists()).toBe(true); beforeEach(() => {
expect(findGlAvatar().props('src')).toBe(mockGroup1.avatar_url); createComponent();
}); });
it('renders group avatar', () => {
expect(findGlAvatar().exists()).toBe(true);
expect(findGlAvatar().props('src')).toBe(mockGroup1.avatar_url);
});
it('renders group name', () => {
expect(findGroupName().text()).toBe(mockGroup1.full_name);
});
it('renders group name', () => { it('renders group description', () => {
expect(findGroupName().text()).toBe(mockGroup1.full_name); expect(findGroupDescription().text()).toBe(mockGroup1.description);
});
it('renders Link button', () => {
expect(findLinkButton().exists()).toBe(true);
expect(findLinkButton().text()).toBe('Link');
});
}); });
it('renders group description', () => { describe('on Link button click', () => {
expect(findGroupDescription().text()).toBe(mockGroup1.description); let addSubscriptionSpy;
beforeEach(() => {
createComponent({ mountFn: mount });
addSubscriptionSpy = jest.spyOn(JiraConnectApi, 'addSubscription').mockResolvedValue();
});
it('sets button to loading and sends request', async () => {
expect(findLinkButton().props('loading')).toBe(false);
clickLinkButton();
await wrapper.vm.$nextTick();
expect(findLinkButton().props('loading')).toBe(true);
expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path);
});
describe('when request is successful', () => {
it('reloads the page', async () => {
clickLinkButton();
await waitForPromises();
expect(reloadSpy).toHaveBeenCalled();
});
});
describe('when request has errors', () => {
const mockErrorMessage = 'error message';
const mockError = { response: { data: { error: mockErrorMessage } } };
beforeEach(() => {
addSubscriptionSpy = jest
.spyOn(JiraConnectApi, 'addSubscription')
.mockRejectedValue(mockError);
});
it('emits `error` event', async () => {
clickLinkButton();
await waitForPromises();
expect(reloadSpy).not.toHaveBeenCalled();
expect(wrapper.emitted('error')[0][0]).toBe(mockErrorMessage);
});
});
}); });
}); });
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { fetchGroups } from '~/jira_connect/api'; import { fetchGroups } from '~/jira_connect/api';
...@@ -28,6 +28,7 @@ describe('GroupsList', () => { ...@@ -28,6 +28,7 @@ describe('GroupsList', () => {
wrapper = null; wrapper = null;
}); });
const findGlAlert = () => wrapper.find(GlAlert);
const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon); const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findAllItems = () => wrapper.findAll(GroupsListItem); const findAllItems = () => wrapper.findAll(GroupsListItem);
const findFirstItem = () => findAllItems().at(0); const findFirstItem = () => findAllItems().at(0);
...@@ -45,6 +46,18 @@ describe('GroupsList', () => { ...@@ -45,6 +46,18 @@ describe('GroupsList', () => {
}); });
}); });
describe('error fetching groups', () => {
it('renders error message', async () => {
fetchGroups.mockRejectedValue();
createComponent();
await waitForPromises();
expect(findGlAlert().exists()).toBe(true);
expect(findGlAlert().text()).toBe('Failed to load namespaces. Please try again.');
});
});
describe('no groups returned', () => { describe('no groups returned', () => {
it('renders empty state', async () => { it('renders empty state', async () => {
fetchGroups.mockResolvedValue(mockEmptyResponse); fetchGroups.mockResolvedValue(mockEmptyResponse);
...@@ -57,15 +70,28 @@ describe('GroupsList', () => { ...@@ -57,15 +70,28 @@ describe('GroupsList', () => {
}); });
describe('with groups returned', () => { describe('with groups returned', () => {
it('renders groups list', async () => { beforeEach(async () => {
fetchGroups.mockResolvedValue({ data: [mockGroup1, mockGroup2] }); fetchGroups.mockResolvedValue({ data: [mockGroup1, mockGroup2] });
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
});
it('renders groups list', () => {
expect(findAllItems().length).toBe(2); expect(findAllItems().length).toBe(2);
expect(findFirstItem().props('group')).toBe(mockGroup1); expect(findFirstItem().props('group')).toBe(mockGroup1);
expect(findSecondItem().props('group')).toBe(mockGroup2); expect(findSecondItem().props('group')).toBe(mockGroup2);
}); });
it('shows error message on $emit from item', async () => {
const errorMessage = 'error message';
findFirstItem().vm.$emit('error', errorMessage);
await wrapper.vm.$nextTick();
expect(findGlAlert().exists()).toBe(true);
expect(findGlAlert().text()).toContain(errorMessage);
});
}); });
}); });
...@@ -3,6 +3,7 @@ export const mockGroup1 = { ...@@ -3,6 +3,7 @@ export const mockGroup1 = {
avatar_url: 'avatar.png', avatar_url: 'avatar.png',
name: 'Gitlab Org', name: 'Gitlab Org',
full_name: 'Gitlab Org', full_name: 'Gitlab Org',
full_path: 'gitlab-org',
description: 'Open source software to collaborate on code', description: 'Open source software to collaborate on code',
}; };
...@@ -11,5 +12,6 @@ export const mockGroup2 = { ...@@ -11,5 +12,6 @@ export const mockGroup2 = {
avatar_url: 'avatar.png', avatar_url: 'avatar.png',
name: 'Gitlab Com', name: 'Gitlab Com',
full_name: 'Gitlab Com', full_name: 'Gitlab Com',
full_path: 'gitlab-com',
description: 'For GitLab company related projects', description: 'For GitLab company related projects',
}; };
...@@ -4,12 +4,21 @@ require 'spec_helper' ...@@ -4,12 +4,21 @@ require 'spec_helper'
RSpec.describe JiraConnectHelper do RSpec.describe JiraConnectHelper do
describe '#jira_connect_app_data' do describe '#jira_connect_app_data' do
subject { helper.jira_connect_app_data } let_it_be(:subscription) { create(:jira_connect_subscription) }
subject { helper.jira_connect_app_data([subscription]) }
it 'includes Jira Connect app attributes' do it 'includes Jira Connect app attributes' do
is_expected.to include( is_expected.to include(
:groups_path :groups_path,
:subscriptions_path
) )
end end
it 'passes group as "skip_groups" param' do
skip_groups_param = CGI.escape('skip_groups[]')
expect(subject[:groups_path]).to include("#{skip_groups_param}=#{subscription.namespace.id}")
end
end end
end end
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