Skip to content
Snippets Groups Projects
Commit cb1202b7 authored by Tom Quirk's avatar Tom Quirk Committed by Peter Hegman
Browse files

Manage Jira Connect subscriptions in Vuex

To prepare for Oauth authentication, where
we'll be fetching subscriptions via the
REST API + token, this comment initializes
Vuex state using the subscriptions data
passed as a data attribute from Rails.
parent f02c3d50
No related branches found
No related tags found
1 merge request!84917Manage Jira Connect subscriptions in Vuex
Showing
with 164 additions and 41 deletions
......@@ -12,6 +12,7 @@ const USER_PROJECTS_PATH = '/api/:version/users/:id/projects';
const USER_POST_STATUS_PATH = '/api/:version/user/status';
const USER_FOLLOW_PATH = '/api/:version/users/:id/follow';
const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow';
const CURRENT_USER_PATH = '/api/:version/user';
export function getUsers(query, options) {
const url = buildApiUrl(USERS_PATH);
......@@ -81,3 +82,8 @@ export function unfollowUser(userId) {
const url = buildApiUrl(USER_UNFOLLOW_PATH).replace(':id', encodeURIComponent(userId));
return axios.post(url);
}
export function getCurrentUser(options) {
const url = buildApiUrl(CURRENT_USER_PATH);
return axios.get(url, { ...options });
}
......@@ -29,3 +29,13 @@ export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
},
});
};
export const fetchSubscriptions = async (subscriptionsPath) => {
const jwt = await getJwt();
return axios.get(subscriptionsPath, {
params: {
jwt,
},
});
};
......@@ -12,7 +12,7 @@ export default {
GroupItemName,
},
inject: {
subscriptionsPath: {
addSubscriptionsPath: {
default: '',
},
},
......@@ -36,7 +36,7 @@ export default {
onClick() {
this.isLoading = true;
addSubscription(this.subscriptionsPath, this.group.full_path)
addSubscription(this.addSubscriptionsPath, this.group.full_path)
.then(() => {
persistAlert({
title: s__('Integrations|Namespace successfully linked'),
......
<script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapMutations } from 'vuex';
import { mapState, mapMutations, mapActions } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import AccessorUtilities from '~/lib/utils/accessor';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......@@ -30,8 +30,8 @@ export default {
usersPath: {
default: '',
},
subscriptions: {
default: [],
subscriptionsPath: {
default: '',
},
},
data() {
......@@ -40,7 +40,7 @@ export default {
};
},
computed: {
...mapState(['alert']),
...mapState(['alert', 'subscriptions']),
shouldShowAlert() {
return Boolean(this.alert?.message);
},
......@@ -64,16 +64,30 @@ export default {
created() {
this.setInitialAlert();
},
mounted() {
this.fetchSubscriptionsOauth();
},
methods: {
...mapMutations({
setAlert: SET_ALERT,
}),
...mapActions(['fetchSubscriptions']),
/**
* Fetch subscriptions from the REST API,
* if the jiraConnectOauth flag is enabled.
*/
fetchSubscriptionsOauth() {
if (!this.isOauthEnabled) return;
this.fetchSubscriptions(this.subscriptionsPath);
},
setInitialAlert() {
const { linkUrl, title, message, variant } = retrieveAlert() || {};
this.setAlert({ linkUrl, title, message, variant });
},
onSignInOauth(user) {
this.user = user;
this.fetchSubscriptionsOauth();
},
onSignInError() {
this.setAlert({
......
......@@ -8,7 +8,7 @@ import {
} from '~/jira_connect/subscriptions/constants';
import { setUrlParams } from '~/lib/utils/url_utility';
import AccessorUtilities from '~/lib/utils/accessor';
import { getCurrentUser } from '~/rest_api';
import { createCodeVerifier, createCodeChallenge } from '../pkce';
export default {
......@@ -40,6 +40,7 @@ export default {
// Build the initial OAuth authorization URL
const { oauth_authorize_url: oauthAuthorizeURL } = this.oauthMetadata;
const oauthAuthorizeURLWithChallenge = setUrlParams(
{
code_challenge: codeChallenge,
......@@ -73,6 +74,7 @@ export default {
const code = event.data?.code;
try {
const accessToken = await this.getOAuthToken(code);
await this.loadUser(accessToken);
} catch (e) {
this.handleError();
......@@ -97,7 +99,7 @@ export default {
return data.access_token;
},
async loadUser(accessToken) {
const { data } = await axios.get('/api/v4/user', {
const { data } = await getCurrentUser({
headers: { Authorization: `Bearer ${accessToken}` },
});
......
<script>
import { GlButton, GlTableLite } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapMutations } from 'vuex';
import { mapMutations, mapState } from 'vuex';
import { removeSubscription } from '~/jira_connect/subscriptions/api';
import { reloadPage } from '~/jira_connect/subscriptions/utils';
import { __, s__ } from '~/locale';
......@@ -16,11 +16,6 @@ export default {
GroupItemName,
TimeagoTooltip,
},
inject: {
subscriptions: {
default: [],
},
},
data() {
return {
loadingItem: null,
......@@ -45,6 +40,9 @@ export default {
i18n: {
unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'),
},
computed: {
...mapState(['subscriptions']),
},
methods: {
...mapMutations({
setAlert: SET_ALERT,
......
......@@ -8,6 +8,9 @@ export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal';
export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab');
export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.');
export const I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE = s__(
'Integrations|Failed to load subscriptions.',
);
const OAUTH_WINDOW_SIZE = 800;
export const OAUTH_WINDOW_OPTIONS = [
......
......@@ -9,8 +9,6 @@ import JiraConnectApp from './components/app.vue';
import createStore from './store';
import { sizeToParent } from './utils';
const store = createStore();
export function initJiraConnect() {
const el = document.querySelector('.js-jira-connect-app');
if (!el) {
......@@ -24,6 +22,7 @@ export function initJiraConnect() {
const {
groupsPath,
subscriptions,
addSubscriptionsPath,
subscriptionsPath,
usersPath,
gitlabUserPath,
......@@ -31,12 +30,14 @@ export function initJiraConnect() {
} = el.dataset;
sizeToParent();
const store = createStore({ subscriptions: JSON.parse(subscriptions) });
return new Vue({
el,
store,
provide: {
groupsPath,
subscriptions: JSON.parse(subscriptions),
addSubscriptionsPath,
subscriptionsPath,
usersPath,
gitlabUserPath,
......
......@@ -40,7 +40,7 @@ export default {
<div>
<h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
<div v-if="hasSubscriptions">
<div class="gl-display-flex gl-justify-content-end">
<div class="gl-display-flex gl-justify-content-end gl-mb-3">
<sign-in-oauth-button
v-if="useSignInOauthButton"
@sign-in="$emit('sign-in-oauth', $event)"
......
<script>
import { GlEmptyState } from '@gitlab/ui';
import { mapState } from 'vuex';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import SubscriptionsList from '../components/subscriptions_list.vue';
import AddNamespaceButton from '../components/add_namespace_button.vue';
......@@ -7,6 +9,7 @@ export default {
name: 'SubscriptionsPage',
components: {
GlEmptyState,
GlLoadingIcon,
SubscriptionsList,
AddNamespaceButton,
},
......@@ -16,6 +19,9 @@ export default {
required: true,
},
},
computed: {
...mapState(['subscriptionsLoading', 'subscriptionsError']),
},
};
</script>
......@@ -23,8 +29,9 @@ export default {
<div>
<h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
<div v-if="hasSubscriptions">
<div class="gl-display-flex gl-justify-content-end">
<gl-loading-icon v-if="subscriptionsLoading" size="md" />
<div v-else-if="hasSubscriptions && !subscriptionsError">
<div class="gl-display-flex gl-justify-content-end gl-mb-3">
<add-namespace-button />
</div>
......
import { fetchSubscriptions as fetchSubscriptionsREST } from '~/jira_connect/subscriptions/api';
import { I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE } from '../constants';
import {
SET_SUBSCRIPTIONS,
SET_SUBSCRIPTIONS_LOADING,
SET_SUBSCRIPTIONS_ERROR,
SET_ALERT,
} from './mutation_types';
export const fetchSubscriptions = async ({ commit }, subscriptionsPath) => {
commit(SET_SUBSCRIPTIONS_LOADING, true);
try {
const data = await fetchSubscriptionsREST(subscriptionsPath);
commit(SET_SUBSCRIPTIONS, data.data.subscriptions);
} catch {
commit(SET_SUBSCRIPTIONS_ERROR, true);
commit(SET_ALERT, { message: I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE, variant: 'danger' });
} finally {
commit(SET_SUBSCRIPTIONS_LOADING, false);
}
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import state from './state';
import createState from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
export default function createStore(initialState) {
return new Vuex.Store({
mutations,
state,
actions,
state: createState(initialState),
});
}
export const SET_ALERT = 'SET_ALERT';
export const SET_SUBSCRIPTIONS = 'SET_SUBSCRIPTIONS';
export const SET_SUBSCRIPTIONS_LOADING = 'SET_SUBSCRIPTIONS_LOADING';
export const SET_SUBSCRIPTIONS_ERROR = 'SET_SUBSCRIPTIONS_ERROR';
import { SET_ALERT } from './mutation_types';
import {
SET_ALERT,
SET_SUBSCRIPTIONS,
SET_SUBSCRIPTIONS_LOADING,
SET_SUBSCRIPTIONS_ERROR,
} from './mutation_types';
export default {
[SET_ALERT](state, { title, message, variant, linkUrl } = {}) {
state.alert = { title, message, variant, linkUrl };
},
[SET_SUBSCRIPTIONS](state, subscriptions = []) {
state.subscriptions = subscriptions;
},
[SET_SUBSCRIPTIONS_LOADING](state, subscriptionsLoading) {
state.subscriptionsLoading = subscriptionsLoading;
},
[SET_SUBSCRIPTIONS_ERROR](state, subscriptionsError) {
state.subscriptionsError = subscriptionsError;
},
};
export default () => ({
alert: undefined,
});
export default function createState({ subscriptions = [], subscriptionsLoading = false } = {}) {
return {
alert: undefined,
subscriptions,
subscriptionsLoading,
subscriptionsError: false,
};
}
......@@ -7,7 +7,8 @@ def jira_connect_app_data(subscriptions)
{
groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }),
subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json,
subscriptions_path: jira_connect_subscriptions_path,
add_subscriptions_path: jira_connect_subscriptions_path,
subscriptions_path: jira_connect_subscriptions_path(format: :json),
users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in
gitlab_user_path: current_user ? user_path(current_user) : nil,
oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data.to_json : nil
......
......@@ -20705,6 +20705,9 @@ msgstr ""
msgid "Integrations|Failed to load namespaces. Please try again."
msgstr ""
 
msgid "Integrations|Failed to load subscriptions."
msgstr ""
msgid "Integrations|Failed to sign in to GitLab."
msgstr ""
 
......
......@@ -13,7 +13,7 @@ jest.mock('~/jira_connect/subscriptions/utils');
describe('GroupsListItem', () => {
let wrapper;
const mockSubscriptionPath = 'subscriptionPath';
const mockAddSubscriptionsPath = '/addSubscriptionsPath';
const createComponent = ({ mountFn = shallowMount } = {}) => {
wrapper = mountFn(GroupsListItem, {
......@@ -21,7 +21,7 @@ describe('GroupsListItem', () => {
group: mockGroup1,
},
provide: {
subscriptionsPath: mockSubscriptionPath,
addSubscriptionsPath: mockAddSubscriptionsPath,
},
});
};
......@@ -70,7 +70,10 @@ describe('GroupsListItem', () => {
await waitForPromises();
expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path);
expect(addSubscriptionSpy).toHaveBeenCalledWith(
mockAddSubscriptionsPath,
mockGroup1.full_path,
);
expect(persistAlert).toHaveBeenCalledWith({
linkUrl: '/help/integration/jira_development_panel.html#use-the-integration',
message:
......
......@@ -12,6 +12,7 @@ import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants';
import { __ } from '~/locale';
import AccessorUtilities from '~/lib/utils/accessor';
import * as api from '~/jira_connect/subscriptions/api';
import { mockSubscription } from '../mock_data';
jest.mock('~/jira_connect/subscriptions/utils', () => ({
......@@ -31,7 +32,8 @@ describe('JiraConnectApp', () => {
const findBrowserSupportAlert = () => wrapper.findComponent(BrowserSupportAlert);
const createComponent = ({ provide, mountFn = shallowMountExtended } = {}) => {
store = createStore();
store = createStore({ subscriptions: [mockSubscription] });
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = mountFn(JiraConnectApp, {
store,
......@@ -53,7 +55,6 @@ describe('JiraConnectApp', () => {
createComponent({
provide: {
usersPath,
subscriptions: [mockSubscription],
},
});
});
......@@ -79,14 +80,13 @@ describe('JiraConnectApp', () => {
createComponent({
provide: {
usersPath: '/user',
subscriptions: [],
},
});
const userLink = findUserLink();
expect(userLink.exists()).toBe(true);
expect(userLink.props()).toEqual({
hasSubscriptions: false,
hasSubscriptions: true,
user: null,
userSignedIn: false,
});
......@@ -167,7 +167,6 @@ describe('JiraConnectApp', () => {
createComponent({
provide: {
usersPath: '/mock',
subscriptions: [],
},
});
findSignInPage().vm.$emit('sign-in-oauth', mockUser);
......@@ -193,7 +192,6 @@ describe('JiraConnectApp', () => {
createComponent({
provide: {
usersPath: '/mock',
subscriptions: [],
},
});
findSignInPage().vm.$emit('error');
......@@ -235,4 +233,31 @@ describe('JiraConnectApp', () => {
});
},
);
describe('when `jiraConnectOauth` feature flag is enabled', () => {
const mockSubscriptionsPath = '/mockSubscriptionsPath';
beforeEach(() => {
jest.spyOn(api, 'fetchSubscriptions').mockResolvedValue({ data: { subscriptions: [] } });
createComponent({
provide: {
glFeatures: { jiraConnectOauth: true },
subscriptionsPath: mockSubscriptionsPath,
},
});
});
describe('when component mounts', () => {
it('dispatches `fetchSubscriptions` action', async () => {
expect(store.dispatch).toHaveBeenCalledWith('fetchSubscriptions', mockSubscriptionsPath);
});
});
describe('when oauth button emits `sign-in-oauth` event', () => {
it('dispatches `fetchSubscriptions` action', () => {
expect(store.dispatch).toHaveBeenCalledWith('fetchSubscriptions', mockSubscriptionsPath);
});
});
});
});
......@@ -11,9 +11,12 @@ import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
import httpStatus from '~/lib/utils/http_status';
import AccessorUtilities from '~/lib/utils/accessor';
import { getCurrentUser } from '~/rest_api';
jest.mock('~/lib/utils/accessor');
jest.mock('~/jira_connect/subscriptions/utils');
jest.mock('~/jira_connect/subscriptions/api');
jest.mock('~/rest_api');
jest.mock('~/jira_connect/subscriptions/pkce', () => ({
createCodeVerifier: jest.fn().mockReturnValue('mock-verifier'),
createCodeChallenge: jest.fn().mockResolvedValue('mock-challenge'),
......@@ -147,7 +150,7 @@ describe('SignInOauthButton', () => {
mockAxios
.onPost(mockOauthMetadata.oauth_token_url)
.replyOnce(httpStatus.OK, { access_token: mockAccessToken });
mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.OK, mockUser);
getCurrentUser.mockResolvedValue({ data: mockUser });
window.dispatchEvent(new MessageEvent('message', mockEvent));
......@@ -162,7 +165,7 @@ describe('SignInOauthButton', () => {
});
it('executes GET request to fetch user data', () => {
expect(axios.get).toHaveBeenCalledWith('/api/v4/user', {
expect(getCurrentUser).toHaveBeenCalledWith({
headers: { Authorization: `Bearer ${mockAccessToken}` },
});
});
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment