Commit 3dcaf9dc authored by Tom Quirk's avatar Tom Quirk Committed by Miguel Rincon

Add search functionality to Jira Connect App namespaces

parent 9b6dd658
...@@ -39,11 +39,12 @@ export const removeSubscription = async (removePath) => { ...@@ -39,11 +39,12 @@ export const removeSubscription = async (removePath) => {
}); });
}; };
export const fetchGroups = async (groupsPath, { page, perPage }) => { export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
return axios.get(groupsPath, { return axios.get(groupsPath, {
params: { params: {
page, page,
per_page: perPage, per_page: perPage,
search,
}, },
}); });
}; };
<script> <script>
import { GlTabs, GlTab, GlLoadingIcon, GlPagination, GlAlert } from '@gitlab/ui'; import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui';
import { fetchGroups } from '~/jira_connect/api'; import { fetchGroups } from '~/jira_connect/api';
import { defaultPerPage } from '~/jira_connect/constants'; import { defaultPerPage } from '~/jira_connect/constants';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
...@@ -8,11 +8,10 @@ import GroupsListItem from './groups_list_item.vue'; ...@@ -8,11 +8,10 @@ import GroupsListItem from './groups_list_item.vue';
export default { export default {
components: { components: {
GlTabs,
GlTab,
GlLoadingIcon, GlLoadingIcon,
GlPagination, GlPagination,
GlAlert, GlAlert,
GlSearchBoxByType,
GroupsListItem, GroupsListItem,
}, },
inject: { inject: {
...@@ -23,7 +22,8 @@ export default { ...@@ -23,7 +22,8 @@ export default {
data() { data() {
return { return {
groups: [], groups: [],
isLoading: false, isLoadingInitial: true,
isLoadingMore: false,
page: 1, page: 1,
perPage: defaultPerPage, perPage: defaultPerPage,
totalItems: 0, totalItems: 0,
...@@ -31,15 +31,18 @@ export default { ...@@ -31,15 +31,18 @@ export default {
}; };
}, },
mounted() { mounted() {
this.loadGroups(); return this.loadGroups().finally(() => {
this.isLoadingInitial = false;
});
}, },
methods: { methods: {
loadGroups() { loadGroups({ searchTerm } = {}) {
this.isLoading = true; this.isLoadingMore = true;
fetchGroups(this.groupsPath, { return fetchGroups(this.groupsPath, {
page: this.page, page: this.page,
perPage: this.perPage, perPage: this.perPage,
search: searchTerm,
}) })
.then((response) => { .then((response) => {
const { page, total } = parseIntPagination(normalizeHeaders(response.headers)); const { page, total } = parseIntPagination(normalizeHeaders(response.headers));
...@@ -51,35 +54,48 @@ export default { ...@@ -51,35 +54,48 @@ export default {
this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.'); this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.');
}) })
.finally(() => { .finally(() => {
this.isLoading = false; this.isLoadingMore = false;
}); });
}, },
onGroupSearch(searchTerm) {
return this.loadGroups({ searchTerm });
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" @dismiss="errorMessage = null"> <gl-alert v-if="errorMessage" class="gl-mb-5" variant="danger" @dismiss="errorMessage = null">
{{ errorMessage }} {{ errorMessage }}
</gl-alert> </gl-alert>
<gl-tabs> <gl-search-box-by-type
<gl-tab :title="__('Groups and subgroups')" class="gl-pt-3"> class="gl-mb-5"
<gl-loading-icon v-if="isLoading" size="md" /> debounce="500"
:placeholder="__('Search by name')"
:is-loading="isLoadingMore"
@input="onGroupSearch"
/>
<gl-loading-icon v-if="isLoadingInitial" size="md" />
<div v-else-if="groups.length === 0" class="gl-text-center"> <div v-else-if="groups.length === 0" class="gl-text-center">
<h5>{{ s__('Integrations|No available namespaces.') }}</h5> <h5>{{ s__('Integrations|No available namespaces.') }}</h5>
<p class="gl-mt-5"> <p class="gl-mt-5">
{{ {{ s__('Integrations|You must have owner or maintainer permissions to link namespaces.') }}
s__('Integrations|You must have owner or maintainer permissions to link namespaces.')
}}
</p> </p>
</div> </div>
<ul v-else class="gl-list-style-none gl-pl-0"> <ul
v-else
class="gl-list-style-none gl-pl-0 gl-border-t-1 gl-border-t-solid gl-border-t-gray-100"
:class="{ 'gl-opacity-5': isLoadingMore }"
data-testid="groups-list"
>
<groups-list-item <groups-list-item
v-for="group in groups" v-for="group in groups"
:key="group.id" :key="group.id"
:group="group" :group="group"
:disabled="isLoadingMore"
@error="errorMessage = $event" @error="errorMessage = $event"
/> />
</ul> </ul>
...@@ -94,7 +110,5 @@ export default { ...@@ -94,7 +110,5 @@ export default {
@input="loadGroups" @input="loadGroups"
/> />
</div> </div>
</gl-tab>
</gl-tabs>
</div> </div>
</template> </template>
...@@ -21,6 +21,11 @@ export default { ...@@ -21,6 +21,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
disabled: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -60,7 +65,7 @@ export default { ...@@ -60,7 +65,7 @@ export default {
</script> </script>
<template> <template>
<li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"> <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
<div class="gl-display-flex gl-align-items-center gl-py-3"> <div class="gl-display-flex gl-align-items-center gl-py-3">
<gl-icon name="folder-o" class="gl-mr-3" /> <gl-icon name="folder-o" class="gl-mr-3" />
<div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3"> <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3">
...@@ -83,11 +88,13 @@ export default { ...@@ -83,11 +88,13 @@ export default {
<gl-button <gl-button
category="secondary" category="secondary"
variant="success" variant="confirm"
:loading="isLoading" :loading="isLoading"
:disabled="disabled"
@click.prevent="onClick" @click.prevent="onClick"
>{{ __('Link') }}</gl-button
> >
{{ __('Link') }}
</gl-button>
</div> </div>
</div> </div>
</li> </li>
......
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
@import 'bootstrap-vue/src/index'; @import 'bootstrap-vue/src/index';
@import '@gitlab/ui/src/scss/utilities'; @import '@gitlab/ui/src/scss/utilities';
@import '@gitlab/ui/src/components/base/alert/alert';
// We should only import styles that we actually use. // We should only import styles that we actually use.
@import '@gitlab/ui/src/components/base/alert/alert'; @import '@gitlab/ui/src/components/base/alert/alert';
...@@ -16,8 +15,8 @@ ...@@ -16,8 +15,8 @@
@import '@gitlab/ui/src/components/base/loading_icon/loading_icon'; @import '@gitlab/ui/src/components/base/loading_icon/loading_icon';
@import '@gitlab/ui/src/components/base/modal/modal'; @import '@gitlab/ui/src/components/base/modal/modal';
@import '@gitlab/ui/src/components/base/pagination/pagination'; @import '@gitlab/ui/src/components/base/pagination/pagination';
@import '@gitlab/ui/src/components/base/tabs/tabs/tabs';
@import '@gitlab/ui/src/components/base/tooltip/tooltip'; @import '@gitlab/ui/src/components/base/tooltip/tooltip';
@import '@gitlab/ui/src/components/base/search_box_by_type/search_box_by_type';
$atlaskit-border-color: #dfe1e6; $atlaskit-border-color: #dfe1e6;
$header-height: 40px; $header-height: 40px;
......
---
title: Add search functionality to Jira Connect App namespaces
merge_request: 57669
author:
type: added
import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
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';
import GroupsList from '~/jira_connect/components/groups_list.vue'; import GroupsList from '~/jira_connect/components/groups_list.vue';
import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; import GroupsListItem from '~/jira_connect/components/groups_list_item.vue';
...@@ -12,20 +12,27 @@ jest.mock('~/jira_connect/api', () => { ...@@ -12,20 +12,27 @@ jest.mock('~/jira_connect/api', () => {
fetchGroups: jest.fn(), fetchGroups: jest.fn(),
}; };
}); });
const mockGroupsPath = '/groups';
describe('GroupsList', () => { describe('GroupsList', () => {
let wrapper; let wrapper;
const mockEmptyResponse = { data: [] }; const mockEmptyResponse = { data: [] };
const createComponent = (options = {}) => { const createComponent = (options = {}) => {
wrapper = shallowMount(GroupsList, { wrapper = extendedWrapper(
shallowMount(GroupsList, {
provide: {
groupsPath: mockGroupsPath,
},
...options, ...options,
}); }),
);
}; };
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
const findGlAlert = () => wrapper.find(GlAlert); const findGlAlert = () => wrapper.find(GlAlert);
...@@ -33,56 +40,72 @@ describe('GroupsList', () => { ...@@ -33,56 +40,72 @@ describe('GroupsList', () => {
const findAllItems = () => wrapper.findAll(GroupsListItem); const findAllItems = () => wrapper.findAll(GroupsListItem);
const findFirstItem = () => findAllItems().at(0); const findFirstItem = () => findAllItems().at(0);
const findSecondItem = () => findAllItems().at(1); const findSecondItem = () => findAllItems().at(1);
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const findGroupsList = () => wrapper.findByTestId('groups-list');
describe('isLoading is true', () => { describe('when groups are loading', () => {
it('renders loading icon', async () => { it('renders loading icon', async () => {
fetchGroups.mockResolvedValue(mockEmptyResponse); fetchGroups.mockReturnValue(new Promise(() => {}));
createComponent(); createComponent();
wrapper.setData({ isLoading: true });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(true); expect(findGlLoadingIcon().exists()).toBe(true);
}); });
}); });
describe('error fetching groups', () => { describe('when groups fetch fails', () => {
it('renders error message', async () => { it('renders error message', async () => {
fetchGroups.mockRejectedValue(); fetchGroups.mockRejectedValue();
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findGlAlert().exists()).toBe(true); expect(findGlAlert().exists()).toBe(true);
expect(findGlAlert().text()).toBe('Failed to load namespaces. Please try again.'); expect(findGlAlert().text()).toBe('Failed to load namespaces. Please try again.');
}); });
}); });
describe('no groups returned', () => { describe('with no groups returned', () => {
it('renders empty state', async () => { it('renders empty state', async () => {
fetchGroups.mockResolvedValue(mockEmptyResponse); fetchGroups.mockResolvedValue(mockEmptyResponse);
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(wrapper.text()).toContain('No available namespaces'); expect(wrapper.text()).toContain('No available namespaces');
}); });
}); });
describe('with groups returned', () => { describe('with groups returned', () => {
beforeEach(async () => { beforeEach(async () => {
fetchGroups.mockResolvedValue({ data: [mockGroup1, mockGroup2] }); fetchGroups.mockResolvedValue({
headers: { 'X-PAGE': 1, 'X-TOTAL': 2 },
data: [mockGroup1, mockGroup2],
});
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
}); });
it('renders groups list', () => { it('renders groups list', () => {
expect(findAllItems().length).toBe(2); expect(findAllItems()).toHaveLength(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('sets GroupListItem `disabled` prop to `false`', () => {
findAllItems().wrappers.forEach((groupListItem) => {
expect(groupListItem.props('disabled')).toBe(false);
});
});
it('does not set opacity of the groups list', () => {
expect(findGroupsList().classes()).not.toContain('gl-opacity-5');
});
it('shows error message on $emit from item', async () => { it('shows error message on $emit from item', async () => {
const errorMessage = 'error message'; const errorMessage = 'error message';
...@@ -93,5 +116,55 @@ describe('GroupsList', () => { ...@@ -93,5 +116,55 @@ describe('GroupsList', () => {
expect(findGlAlert().exists()).toBe(true); expect(findGlAlert().exists()).toBe(true);
expect(findGlAlert().text()).toContain(errorMessage); expect(findGlAlert().text()).toContain(errorMessage);
}); });
describe('when searching groups', () => {
const mockSearchTeam = 'mock search term';
describe('while groups are loading', () => {
beforeEach(async () => {
fetchGroups.mockClear();
fetchGroups.mockReturnValue(new Promise(() => {}));
findSearchBox().vm.$emit('input', mockSearchTeam);
await wrapper.vm.$nextTick();
});
it('calls `fetchGroups` with search term', () => {
expect(fetchGroups).toHaveBeenCalledWith(mockGroupsPath, {
page: 1,
perPage: 10,
search: mockSearchTeam,
});
});
it('disables GroupListItems', async () => {
findAllItems().wrappers.forEach((groupListItem) => {
expect(groupListItem.props('disabled')).toBe(true);
});
});
it('sets opacity of the groups list', () => {
expect(findGroupsList().classes()).toContain('gl-opacity-5');
});
it('sets loading prop of ths search box', () => {
expect(findSearchBox().props('isLoading')).toBe(true);
});
});
describe('when group search finishes loading', () => {
beforeEach(async () => {
fetchGroups.mockResolvedValue({ data: [mockGroup1] });
findSearchBox().vm.$emit('input');
await waitForPromises();
});
it('renders new groups list', () => {
expect(findAllItems()).toHaveLength(1);
expect(findFirstItem().props('group')).toBe(mockGroup1);
});
});
});
}); });
}); });
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