Commit b000e175 authored by Tom Quirk's avatar Tom Quirk

Reorganise Jira Connect UI

The commit reorganises the UI logic for the Jira Connect
App. This is part-UI enhancement, part tech-debt cleanup.

To the user, the main change is that the Add Namespace button
is now in the empty state when the user is signed in but has no
subscriptions.

This restructuring also enables us to move some HAML
markup to Vue.

Changelog: changed
parent bff6c914
<script> <script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { GlAlert, GlLink, GlSprintf, GlEmptyState } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapMutations } from 'vuex'; import { mapState, mapMutations } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils'; import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import { SET_ALERT } from '../store/mutation_types'; import { SET_ALERT } from '../store/mutation_types';
...@@ -13,6 +14,7 @@ export default { ...@@ -13,6 +14,7 @@ export default {
GlAlert, GlAlert,
GlLink, GlLink,
GlSprintf, GlSprintf,
GlEmptyState,
SubscriptionsList, SubscriptionsList,
AddNamespaceButton, AddNamespaceButton,
SignInButton, SignInButton,
...@@ -21,12 +23,18 @@ export default { ...@@ -21,12 +23,18 @@ export default {
usersPath: { usersPath: {
default: '', default: '',
}, },
subscriptions: {
default: [],
},
}, },
computed: { computed: {
...mapState(['alert']), ...mapState(['alert']),
shouldShowAlert() { shouldShowAlert() {
return Boolean(this.alert?.message); return Boolean(this.alert?.message);
}, },
hasSubscriptions() {
return !isEmpty(this.subscriptions);
},
userSignedIn() { userSignedIn() {
return Boolean(!this.usersPath); return Boolean(!this.usersPath);
}, },
...@@ -66,15 +74,44 @@ export default { ...@@ -66,15 +74,44 @@ export default {
</template> </template>
</gl-alert> </gl-alert>
<h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
<div class="jira-connect-app-body gl-mx-auto gl-px-5 gl-mb-7">
<div class="jira-connect-app-body gl-my-7 gl-px-5 gl-pb-4"> <template v-if="hasSubscriptions">
<div class="gl-display-flex gl-justify-content-end"> <div class="gl-display-flex gl-justify-content-end">
<sign-in-button v-if="!userSignedIn" :users-path="usersPath" /> <sign-in-button v-if="!userSignedIn" :users-path="usersPath" />
<add-namespace-button v-else /> <add-namespace-button v-else />
</div> </div>
<subscriptions-list /> <subscriptions-list />
</template>
<template v-else>
<div v-if="!userSignedIn" class="gl-text-center">
<p class="gl-mb-7">{{ s__('JiraService|Sign in to GitLab.com to get started.') }}</p>
<sign-in-button class="gl-mb-7" :users-path="usersPath">
{{ __('Sign in to GitLab') }}
</sign-in-button>
<p>
{{
s__(
'Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).',
)
}}
</p>
</div>
<gl-empty-state
v-else
:title="s__('Integrations|No linked namespaces')"
:description="
s__(
'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
)
"
>
<template #actions>
<add-namespace-button />
</template>
</gl-empty-state>
</template>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; import { GlButton, GlTable } from '@gitlab/ui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { mapMutations } from 'vuex'; import { mapMutations } from 'vuex';
import { removeSubscription } from '~/jira_connect/subscriptions/api'; import { removeSubscription } from '~/jira_connect/subscriptions/api';
...@@ -12,7 +12,6 @@ import GroupItemName from './group_item_name.vue'; ...@@ -12,7 +12,6 @@ import GroupItemName from './group_item_name.vue';
export default { export default {
components: { components: {
GlButton, GlButton,
GlEmptyState,
GlTable, GlTable,
GroupItemName, GroupItemName,
TimeagoTooltip, TimeagoTooltip,
...@@ -44,17 +43,15 @@ export default { ...@@ -44,17 +43,15 @@ export default {
}, },
], ],
i18n: { i18n: {
emptyTitle: s__('Integrations|No linked namespaces'),
emptyDescription: s__(
'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
),
unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'), unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'),
}, },
methods: { methods: {
...mapMutations({ ...mapMutations({
setAlert: SET_ALERT, setAlert: SET_ALERT,
}), }),
isEmpty, isUnlinkButtonDisabled(item) {
return !isEmpty(item);
},
isLoadingItem(item) { isLoadingItem(item) {
return this.loadingItem === item; return this.loadingItem === item;
}, },
...@@ -81,29 +78,22 @@ export default { ...@@ -81,29 +78,22 @@ export default {
</script> </script>
<template> <template>
<div> <gl-table :items="subscriptions" :fields="$options.fields">
<gl-empty-state <template #cell(name)="{ item }">
v-if="isEmpty(subscriptions)" <group-item-name :group="item.group" />
:title="$options.i18n.emptyTitle" </template>
:description="$options.i18n.emptyDescription" <template #cell(created_at)="{ item }">
/> <timeago-tooltip :time="item.created_at" />
<gl-table v-else :items="subscriptions" :fields="$options.fields"> </template>
<template #cell(name)="{ item }"> <template #cell(actions)="{ item }">
<group-item-name :group="item.group" /> <gl-button
</template> :class="unlinkBtnClass(item)"
<template #cell(created_at)="{ item }"> category="secondary"
<timeago-tooltip :time="item.created_at" /> :loading="isLoadingItem(item)"
</template> :disabled="isUnlinkButtonDisabled(loadingItem)"
<template #cell(actions)="{ item }"> @click.prevent="onClick(item)"
<gl-button >{{ __('Unlink') }}</gl-button
:class="unlinkBtnClass(item)" >
category="secondary" </template>
:loading="isLoadingItem(item)" </gl-table>
:disabled="!isEmpty(loadingItem)"
@click.prevent="onClick(item)"
>{{ __('Unlink') }}</gl-button
>
</template>
</gl-table>
</div>
</template> </template>
...@@ -42,8 +42,6 @@ $header-height: 40px; ...@@ -42,8 +42,6 @@ $header-height: 40px;
.jira-connect-app-body { .jira-connect-app-body {
max-width: 768px; max-width: 768px;
margin-left: auto;
margin-right: auto;
} }
// needed for external_link // needed for external_link
......
...@@ -9,20 +9,9 @@ ...@@ -9,20 +9,9 @@
= link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in' = link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in'
%main.jira-connect-app.gl-px-5.gl-pt-7.gl-mx-auto %main.jira-connect-app.gl-px-5.gl-pt-7.gl-mx-auto
- if current_user.blank? && @subscriptions.empty? .js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) }
.jira-connect-app-body.gl-px-5.gl-text-center
%h2= s_('JiraService|GitLab for Jira Configuration')
%p= s_('JiraService|Sign in to GitLab.com to get started.')
.gl-mt-7 %p.jira-connect-app-body.gl-px-5.gl-font-base.gl-text-center.gl-mx-auto
= external_link _('Sign in to GitLab'), jira_connect_users_path, class: "btn gl-button btn-confirm js-jira-connect-sign-in"
.gl-mt-7
%p= s_('Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).')
- else
.js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) }
%p.jira-connect-app-body.gl-px-5.gl-mt-7.gl-font-base.gl-text-center
%strong= s_('Integrations|Browser limitations') %strong= s_('Integrations|Browser limitations')
- browser_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">' - browser_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'
- firefox_link_start = browser_link_start.html_safe % { url: 'https://www.mozilla.org/en-US/firefox/' } - firefox_link_start = browser_link_start.html_safe % { url: 'https://www.mozilla.org/en-US/firefox/' }
......
import { GlAlert, GlLink } from '@gitlab/ui'; import { GlAlert, GlLink, GlEmptyState } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue'; import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue';
...@@ -7,6 +7,7 @@ import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button ...@@ -7,6 +7,7 @@ import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button
import createStore from '~/jira_connect/subscriptions/store'; import createStore from '~/jira_connect/subscriptions/store';
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { mockSubscription } from '../mock_data';
jest.mock('~/jira_connect/subscriptions/utils', () => ({ jest.mock('~/jira_connect/subscriptions/utils', () => ({
retrieveAlert: jest.fn().mockReturnValue({ message: 'error message' }), retrieveAlert: jest.fn().mockReturnValue({ message: 'error message' }),
...@@ -20,6 +21,7 @@ describe('JiraConnectApp', () => { ...@@ -20,6 +21,7 @@ describe('JiraConnectApp', () => {
const findAlertLink = () => findAlert().findComponent(GlLink); const findAlertLink = () => findAlert().findComponent(GlLink);
const findSignInButton = () => wrapper.findComponent(SignInButton); const findSignInButton = () => wrapper.findComponent(SignInButton);
const findAddNamespaceButton = () => wrapper.findComponent(AddNamespaceButton); const findAddNamespaceButton = () => wrapper.findComponent(AddNamespaceButton);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const createComponent = ({ provide, mountFn = shallowMount } = {}) => { const createComponent = ({ provide, mountFn = shallowMount } = {}) => {
store = createStore(); store = createStore();
...@@ -34,7 +36,7 @@ describe('JiraConnectApp', () => { ...@@ -34,7 +36,7 @@ describe('JiraConnectApp', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('template', () => { describe('with subscriptions', () => {
describe.each` describe.each`
scenario | usersPath | expectSignInButton | expectNamespaceButton scenario | usersPath | expectSignInButton | expectNamespaceButton
${'user is not signed in'} | ${'/users'} | ${true} | ${false} ${'user is not signed in'} | ${'/users'} | ${true} | ${false}
...@@ -44,6 +46,7 @@ describe('JiraConnectApp', () => { ...@@ -44,6 +46,7 @@ describe('JiraConnectApp', () => {
createComponent({ createComponent({
provide: { provide: {
usersPath, usersPath,
subscriptions: [mockSubscription],
}, },
}); });
}); });
...@@ -56,71 +59,96 @@ describe('JiraConnectApp', () => { ...@@ -56,71 +59,96 @@ describe('JiraConnectApp', () => {
expect(findAddNamespaceButton().exists()).toBe(expectNamespaceButton); expect(findAddNamespaceButton().exists()).toBe(expectNamespaceButton);
}); });
}); });
});
describe('with no subscriptions', () => {
describe.each`
scenario | usersPath | expectSignInButton | expectEmptyState
${'user is not signed in'} | ${'/users'} | ${true} | ${false}
${'user is signed in'} | ${undefined} | ${false} | ${true}
`('when $scenario', ({ usersPath, expectSignInButton, expectEmptyState }) => {
beforeEach(() => {
createComponent({
provide: {
usersPath,
subscriptions: [],
},
});
});
it('renders sign in button as expected', () => {
expect(findSignInButton().exists()).toBe(expectSignInButton);
});
describe('alert', () => { it('renders empty state as expected', () => {
it.each` expect(findEmptyState().exists()).toBe(expectEmptyState);
message | variant | alertShouldRender });
${'Test error'} | ${'danger'} | ${true} });
${'Test notice'} | ${'info'} | ${true} });
${''} | ${undefined} | ${false}
${undefined} | ${undefined} | ${false} describe('alert', () => {
`( it.each`
'renders correct alert when message is `$message` and variant is `$variant`', message | variant | alertShouldRender
async ({ message, alertShouldRender, variant }) => { ${'Test error'} | ${'danger'} | ${true}
createComponent(); ${'Test notice'} | ${'info'} | ${true}
${''} | ${undefined} | ${false}
store.commit(SET_ALERT, { message, variant }); ${undefined} | ${undefined} | ${false}
await wrapper.vm.$nextTick(); `(
'renders correct alert when message is `$message` and variant is `$variant`',
const alert = findAlert(); async ({ message, alertShouldRender, variant }) => {
expect(alert.exists()).toBe(alertShouldRender);
if (alertShouldRender) {
expect(alert.isVisible()).toBe(alertShouldRender);
expect(alert.html()).toContain(message);
expect(alert.props('variant')).toBe(variant);
expect(findAlertLink().exists()).toBe(false);
}
},
);
it('hides alert on @dismiss event', async () => {
createComponent(); createComponent();
store.commit(SET_ALERT, { message: 'test message' }); store.commit(SET_ALERT, { message, variant });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
findAlert().vm.$emit('dismiss'); const alert = findAlert();
await wrapper.vm.$nextTick();
expect(findAlert().exists()).toBe(false); expect(alert.exists()).toBe(alertShouldRender);
}); if (alertShouldRender) {
expect(alert.isVisible()).toBe(alertShouldRender);
expect(alert.html()).toContain(message);
expect(alert.props('variant')).toBe(variant);
expect(findAlertLink().exists()).toBe(false);
}
},
);
it('renders link when `linkUrl` is set', async () => { it('hides alert on @dismiss event', async () => {
createComponent({ mountFn: mount }); createComponent();
store.commit(SET_ALERT, { store.commit(SET_ALERT, { message: 'test message' });
message: __('test message %{linkStart}test link%{linkEnd}'), await wrapper.vm.$nextTick();
linkUrl: 'https://gitlab.com',
});
await wrapper.vm.$nextTick();
const alertLink = findAlertLink(); findAlert().vm.$emit('dismiss');
await wrapper.vm.$nextTick();
expect(alertLink.exists()).toBe(true); expect(findAlert().exists()).toBe(false);
expect(alertLink.text()).toContain('test link'); });
expect(alertLink.attributes('href')).toBe('https://gitlab.com');
it('renders link when `linkUrl` is set', async () => {
createComponent({ mountFn: mount });
store.commit(SET_ALERT, {
message: __('test message %{linkStart}test link%{linkEnd}'),
linkUrl: 'https://gitlab.com',
}); });
await wrapper.vm.$nextTick();
const alertLink = findAlertLink();
describe('when alert is set in localStoage', () => { expect(alertLink.exists()).toBe(true);
it('renders alert on mount', () => { expect(alertLink.text()).toContain('test link');
createComponent(); expect(alertLink.attributes('href')).toBe('https://gitlab.com');
});
describe('when alert is set in localStoage', () => {
it('renders alert on mount', () => {
createComponent();
const alert = findAlert(); const alert = findAlert();
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
expect(alert.html()).toContain('error message'); expect(alert.html()).toContain('error message');
});
}); });
}); });
}); });
......
import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import * as JiraConnectApi from '~/jira_connect/subscriptions/api'; import * as JiraConnectApi from '~/jira_connect/subscriptions/api';
import GroupItemName from '~/jira_connect/subscriptions/components/group_item_name.vue';
import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
import createStore from '~/jira_connect/subscriptions/store'; import createStore from '~/jira_connect/subscriptions/store';
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { reloadPage } from '~/jira_connect/subscriptions/utils'; import { reloadPage } from '~/jira_connect/subscriptions/utils';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { mockSubscription } from '../mock_data'; import { mockSubscription } from '../mock_data';
jest.mock('~/jira_connect/subscriptions/utils'); jest.mock('~/jira_connect/subscriptions/utils');
...@@ -15,11 +18,13 @@ describe('SubscriptionsList', () => { ...@@ -15,11 +18,13 @@ describe('SubscriptionsList', () => {
let wrapper; let wrapper;
let store; let store;
const createComponent = ({ mountFn = shallowMount, provide = {} } = {}) => { const createComponent = () => {
store = createStore(); store = createStore();
wrapper = mountFn(SubscriptionsList, { wrapper = mount(SubscriptionsList, {
provide, provide: {
subscriptions: [mockSubscription],
},
store, store,
}); });
}; };
...@@ -28,28 +33,28 @@ describe('SubscriptionsList', () => { ...@@ -28,28 +33,28 @@ describe('SubscriptionsList', () => {
wrapper.destroy(); wrapper.destroy();
}); });
const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); const findUnlinkButton = () => wrapper.findComponent(GlButton);
const findGlTable = () => wrapper.findComponent(GlTable);
const findUnlinkButton = () => findGlTable().findComponent(GlButton);
const clickUnlinkButton = () => findUnlinkButton().trigger('click'); const clickUnlinkButton = () => findUnlinkButton().trigger('click');
describe('template', () => { describe('template', () => {
it('renders GlEmptyState when subscriptions is empty', () => { beforeEach(() => {
createComponent(); createComponent();
});
it('renders "name" cell correctly', () => {
const groupItemNames = wrapper.findAllComponents(GroupItemName);
expect(groupItemNames.wrappers).toHaveLength(1);
expect(findGlEmptyState().exists()).toBe(true); const item = groupItemNames.at(0);
expect(findGlTable().exists()).toBe(false); expect(item.props('group')).toBe(mockSubscription.group);
}); });
it('renders GlTable when subscriptions are present', () => { it('renders "created at" cell correctly', () => {
createComponent({ const timeAgoTooltips = wrapper.findAllComponents(TimeagoTooltip);
provide: { expect(timeAgoTooltips.wrappers).toHaveLength(1);
subscriptions: [mockSubscription],
},
});
expect(findGlEmptyState().exists()).toBe(false); const item = timeAgoTooltips.at(0);
expect(findGlTable().exists()).toBe(true); expect(item.props('time')).toBe(mockSubscription.created_at);
}); });
}); });
...@@ -57,12 +62,7 @@ describe('SubscriptionsList', () => { ...@@ -57,12 +62,7 @@ describe('SubscriptionsList', () => {
let removeSubscriptionSpy; let removeSubscriptionSpy;
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent();
mountFn: mount,
provide: {
subscriptions: [mockSubscription],
},
});
removeSubscriptionSpy = jest.spyOn(JiraConnectApi, 'removeSubscription').mockResolvedValue(); removeSubscriptionSpy = jest.spyOn(JiraConnectApi, 'removeSubscription').mockResolvedValue();
}); });
......
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