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) => {
});
};
export const fetchGroups = async (groupsPath, { page, perPage }) => {
export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
return axios.get(groupsPath, {
params: {
page,
per_page: perPage,
search,
},
});
};
<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 { defaultPerPage } from '~/jira_connect/constants';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
......@@ -8,11 +8,10 @@ import GroupsListItem from './groups_list_item.vue';
export default {
components: {
GlTabs,
GlTab,
GlLoadingIcon,
GlPagination,
GlAlert,
GlSearchBoxByType,
GroupsListItem,
},
inject: {
......@@ -23,7 +22,8 @@ export default {
data() {
return {
groups: [],
isLoading: false,
isLoadingInitial: true,
isLoadingMore: false,
page: 1,
perPage: defaultPerPage,
totalItems: 0,
......@@ -31,15 +31,18 @@ export default {
};
},
mounted() {
this.loadGroups();
return this.loadGroups().finally(() => {
this.isLoadingInitial = false;
});
},
methods: {
loadGroups() {
this.isLoading = true;
loadGroups({ searchTerm } = {}) {
this.isLoadingMore = true;
fetchGroups(this.groupsPath, {
return fetchGroups(this.groupsPath, {
page: this.page,
perPage: this.perPage,
search: searchTerm,
})
.then((response) => {
const { page, total } = parseIntPagination(normalizeHeaders(response.headers));
......@@ -51,35 +54,48 @@ export default {
this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.');
})
.finally(() => {
this.isLoading = false;
this.isLoadingMore = false;
});
},
onGroupSearch(searchTerm) {
return this.loadGroups({ searchTerm });
},
},
};
</script>
<template>
<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 }}
</gl-alert>
<gl-tabs>
<gl-tab :title="__('Groups and subgroups')" class="gl-pt-3">
<gl-loading-icon v-if="isLoading" size="md" />
<gl-search-box-by-type
class="gl-mb-5"
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">
<h5>{{ s__('Integrations|No available namespaces.') }}</h5>
<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>
</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
v-for="group in groups"
:key="group.id"
:group="group"
:disabled="isLoadingMore"
@error="errorMessage = $event"
/>
</ul>
......@@ -94,7 +110,5 @@ export default {
@input="loadGroups"
/>
</div>
</gl-tab>
</gl-tabs>
</div>
</template>
......@@ -21,6 +21,11 @@ export default {
type: Object,
required: true,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -60,7 +65,7 @@ export default {
</script>
<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">
<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">
......@@ -83,11 +88,13 @@ export default {
<gl-button
category="secondary"
variant="success"
variant="confirm"
:loading="isLoading"
:disabled="disabled"
@click.prevent="onClick"
>{{ __('Link') }}</gl-button
>
{{ __('Link') }}
</gl-button>
</div>
</div>
</li>
......
......@@ -4,7 +4,6 @@
@import 'bootstrap-vue/src/index';
@import '@gitlab/ui/src/scss/utilities';
@import '@gitlab/ui/src/components/base/alert/alert';
// We should only import styles that we actually use.
@import '@gitlab/ui/src/components/base/alert/alert';
......@@ -16,8 +15,8 @@
@import '@gitlab/ui/src/components/base/loading_icon/loading_icon';
@import '@gitlab/ui/src/components/base/modal/modal';
@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/search_box_by_type/search_box_by_type';
$atlaskit-border-color: #dfe1e6;
$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 { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { fetchGroups } from '~/jira_connect/api';
import GroupsList from '~/jira_connect/components/groups_list.vue';
import GroupsListItem from '~/jira_connect/components/groups_list_item.vue';
......@@ -12,20 +12,27 @@ jest.mock('~/jira_connect/api', () => {
fetchGroups: jest.fn(),
};
});
const mockGroupsPath = '/groups';
describe('GroupsList', () => {
let wrapper;
const mockEmptyResponse = { data: [] };
const createComponent = (options = {}) => {
wrapper = shallowMount(GroupsList, {
wrapper = extendedWrapper(
shallowMount(GroupsList, {
provide: {
groupsPath: mockGroupsPath,
},
...options,
});
}),
);
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGlAlert = () => wrapper.find(GlAlert);
......@@ -33,56 +40,72 @@ describe('GroupsList', () => {
const findAllItems = () => wrapper.findAll(GroupsListItem);
const findFirstItem = () => findAllItems().at(0);
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 () => {
fetchGroups.mockResolvedValue(mockEmptyResponse);
fetchGroups.mockReturnValue(new Promise(() => {}));
createComponent();
wrapper.setData({ isLoading: true });
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(true);
});
});
describe('error fetching groups', () => {
describe('when groups fetch fails', () => {
it('renders error message', async () => {
fetchGroups.mockRejectedValue();
createComponent();
await waitForPromises();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findGlAlert().exists()).toBe(true);
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 () => {
fetchGroups.mockResolvedValue(mockEmptyResponse);
createComponent();
await waitForPromises();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(wrapper.text()).toContain('No available namespaces');
});
});
describe('with groups returned', () => {
beforeEach(async () => {
fetchGroups.mockResolvedValue({ data: [mockGroup1, mockGroup2] });
fetchGroups.mockResolvedValue({
headers: { 'X-PAGE': 1, 'X-TOTAL': 2 },
data: [mockGroup1, mockGroup2],
});
createComponent();
await waitForPromises();
});
it('renders groups list', () => {
expect(findAllItems().length).toBe(2);
expect(findAllItems()).toHaveLength(2);
expect(findFirstItem().props('group')).toBe(mockGroup1);
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 () => {
const errorMessage = 'error message';
......@@ -93,5 +116,55 @@ describe('GroupsList', () => {
expect(findGlAlert().exists()).toBe(true);
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