summaryrefslogtreecommitdiff
path: root/spec/frontend/groups
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/groups')
-rw-r--r--spec/frontend/groups/components/app_spec.js507
-rw-r--r--spec/frontend/groups/components/group_folder_spec.js65
-rw-r--r--spec/frontend/groups/components/group_item_spec.js215
-rw-r--r--spec/frontend/groups/components/groups_spec.js72
-rw-r--r--spec/frontend/groups/components/item_actions_spec.js84
-rw-r--r--spec/frontend/groups/components/item_caret_spec.js38
-rw-r--r--spec/frontend/groups/components/item_stats_spec.js119
-rw-r--r--spec/frontend/groups/components/item_stats_value_spec.js82
-rw-r--r--spec/frontend/groups/components/item_type_icon_spec.js53
-rw-r--r--spec/frontend/groups/mock_data.js398
-rw-r--r--spec/frontend/groups/service/groups_service_spec.js42
-rw-r--r--spec/frontend/groups/store/groups_store_spec.js123
12 files changed, 1798 insertions, 0 deletions
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
new file mode 100644
index 00000000000..35eda21e047
--- /dev/null
+++ b/spec/frontend/groups/components/app_spec.js
@@ -0,0 +1,507 @@
+import '~/flash';
+import $ from 'jquery';
+import Vue from 'vue';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import appComponent from '~/groups/components/app.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import GroupsStore from '~/groups/store/groups_store';
+import GroupsService from '~/groups/service/groups_service';
+import * as urlUtilities from '~/lib/utils/url_utility';
+
+import {
+ mockEndpoint,
+ mockGroups,
+ mockSearchedGroups,
+ mockRawPageInfo,
+ mockParentGroupItem,
+ mockRawChildren,
+ mockChildren,
+ mockPageInfo,
+} from '../mock_data';
+
+const createComponent = (hideProjects = false) => {
+ const Component = Vue.extend(appComponent);
+ const store = new GroupsStore(false);
+ const service = new GroupsService(mockEndpoint);
+
+ store.state.pageInfo = mockPageInfo;
+
+ return new Component({
+ propsData: {
+ store,
+ service,
+ hideProjects,
+ },
+ });
+};
+
+describe('AppComponent', () => {
+ let vm;
+ let mock;
+ let getGroupsSpy;
+
+ beforeEach(() => {
+ mock = new AxiosMockAdapter(axios);
+ mock.onGet('/dashboard/groups.json').reply(200, mockGroups);
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+ getGroupsSpy = jest.spyOn(vm.service, 'getGroups');
+ return vm.$nextTick();
+ });
+
+ describe('computed', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('groups', () => {
+ it('should return list of groups from store', () => {
+ jest.spyOn(vm.store, 'getGroups').mockImplementation(() => {});
+
+ const { groups } = vm;
+
+ expect(vm.store.getGroups).toHaveBeenCalled();
+ expect(groups).not.toBeDefined();
+ });
+ });
+
+ describe('pageInfo', () => {
+ it('should return pagination info from store', () => {
+ jest.spyOn(vm.store, 'getPaginationInfo').mockImplementation(() => {});
+
+ const { pageInfo } = vm;
+
+ expect(vm.store.getPaginationInfo).toHaveBeenCalled();
+ expect(pageInfo).not.toBeDefined();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('fetchGroups', () => {
+ it('should call `getGroups` with all the params provided', () => {
+ return vm
+ .fetchGroups({
+ parentId: 1,
+ page: 2,
+ filterGroupsBy: 'git',
+ sortBy: 'created_desc',
+ archived: true,
+ })
+ .then(() => {
+ expect(getGroupsSpy).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true);
+ });
+ });
+
+ it('should set headers to store for building pagination info when called with `updatePagination`', () => {
+ mock.onGet('/dashboard/groups.json').reply(200, { headers: mockRawPageInfo });
+
+ jest.spyOn(vm, 'updatePagination').mockImplementation(() => {});
+
+ return vm.fetchGroups({ updatePagination: true }).then(() => {
+ expect(getGroupsSpy).toHaveBeenCalled();
+ expect(vm.updatePagination).toHaveBeenCalled();
+ });
+ });
+
+ it('should show flash error when request fails', () => {
+ mock.onGet('/dashboard/groups.json').reply(400);
+
+ jest.spyOn($, 'scrollTo').mockImplementation(() => {});
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+
+ return vm.fetchGroups({}).then(() => {
+ expect(vm.isLoading).toBe(false);
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
+ });
+ });
+ });
+
+ describe('fetchAllGroups', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'fetchGroups');
+ jest.spyOn(vm, 'updateGroups');
+ });
+
+ it('should fetch default set of groups', () => {
+ jest.spyOn(vm, 'updatePagination');
+
+ const fetchPromise = vm.fetchAllGroups();
+
+ expect(vm.isLoading).toBe(true);
+
+ return fetchPromise.then(() => {
+ expect(vm.isLoading).toBe(false);
+ expect(vm.updateGroups).toHaveBeenCalled();
+ });
+ });
+
+ it('should fetch matching set of groups when app is loaded with search query', () => {
+ mock.onGet('/dashboard/groups.json').reply(200, mockSearchedGroups);
+
+ const fetchPromise = vm.fetchAllGroups();
+
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: null,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: null,
+ });
+ return fetchPromise.then(() => {
+ expect(vm.updateGroups).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('fetchPage', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'fetchGroups');
+ jest.spyOn(vm, 'updateGroups');
+ });
+
+ it('should fetch groups for provided page details and update window state', () => {
+ jest.spyOn(urlUtilities, 'mergeUrlParams');
+ jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
+ jest.spyOn($, 'scrollTo').mockImplementation(() => {});
+
+ const fetchPagePromise = vm.fetchPage(2, null, null, true);
+
+ expect(vm.isLoading).toBe(true);
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: 2,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: true,
+ });
+
+ return fetchPagePromise.then(() => {
+ expect(vm.isLoading).toBe(false);
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(urlUtilities.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, expect.any(String));
+ expect(window.history.replaceState).toHaveBeenCalledWith(
+ {
+ page: expect.any(String),
+ },
+ expect.any(String),
+ expect.any(String),
+ );
+
+ expect(vm.updateGroups).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('toggleChildren', () => {
+ let groupItem;
+
+ beforeEach(() => {
+ groupItem = { ...mockParentGroupItem };
+ groupItem.isOpen = false;
+ groupItem.isChildrenLoading = false;
+ });
+
+ it('should fetch children of given group and expand it if group is collapsed and children are not loaded', () => {
+ mock.onGet('/dashboard/groups.json').reply(200, mockRawChildren);
+ jest.spyOn(vm, 'fetchGroups');
+ jest.spyOn(vm.store, 'setGroupChildren').mockImplementation(() => {});
+
+ vm.toggleChildren(groupItem);
+
+ expect(groupItem.isChildrenLoading).toBe(true);
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ parentId: groupItem.id,
+ });
+ return waitForPromises().then(() => {
+ expect(vm.store.setGroupChildren).toHaveBeenCalled();
+ });
+ });
+
+ it('should skip network request while expanding group if children are already loaded', () => {
+ jest.spyOn(vm, 'fetchGroups');
+ groupItem.children = mockRawChildren;
+
+ vm.toggleChildren(groupItem);
+
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBe(true);
+ });
+
+ it('should collapse group if it is already expanded', () => {
+ jest.spyOn(vm, 'fetchGroups');
+ groupItem.isOpen = true;
+
+ vm.toggleChildren(groupItem);
+
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBe(false);
+ });
+
+ it('should set `isChildrenLoading` back to `false` if load request fails', () => {
+ mock.onGet('/dashboard/groups.json').reply(400);
+
+ vm.toggleChildren(groupItem);
+
+ expect(groupItem.isChildrenLoading).toBe(true);
+ return waitForPromises().then(() => {
+ expect(groupItem.isChildrenLoading).toBe(false);
+ });
+ });
+ });
+
+ describe('showLeaveGroupModal', () => {
+ it('caches candidate group (as props) which is to be left', () => {
+ const group = { ...mockParentGroupItem };
+
+ expect(vm.targetGroup).toBe(null);
+ expect(vm.targetParentGroup).toBe(null);
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+
+ expect(vm.targetGroup).not.toBe(null);
+ expect(vm.targetParentGroup).not.toBe(null);
+ });
+
+ it('updates props which show modal confirmation dialog', () => {
+ const group = { ...mockParentGroupItem };
+
+ expect(vm.showModal).toBe(false);
+ expect(vm.groupLeaveConfirmationMessage).toBe('');
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+
+ expect(vm.showModal).toBe(true);
+ expect(vm.groupLeaveConfirmationMessage).toBe(
+ `Are you sure you want to leave the "${group.fullName}" group?`,
+ );
+ });
+ });
+
+ describe('hideLeaveGroupModal', () => {
+ it('hides modal confirmation which is shown before leaving the group', () => {
+ const group = { ...mockParentGroupItem };
+ vm.showLeaveGroupModal(group, mockParentGroupItem);
+
+ expect(vm.showModal).toBe(true);
+ vm.hideLeaveGroupModal();
+
+ expect(vm.showModal).toBe(false);
+ });
+ });
+
+ describe('leaveGroup', () => {
+ let groupItem;
+ let childGroupItem;
+
+ beforeEach(() => {
+ groupItem = { ...mockParentGroupItem };
+ groupItem.children = mockChildren;
+ [childGroupItem] = groupItem.children;
+ groupItem.isChildrenLoading = false;
+ vm.targetGroup = childGroupItem;
+ vm.targetParentGroup = groupItem;
+ });
+
+ it('hides modal confirmation leave group and remove group item from tree', () => {
+ const notice = `You left the "${childGroupItem.fullName}" group.`;
+ jest.spyOn(vm.service, 'leaveGroup').mockResolvedValue({ data: { notice } });
+ jest.spyOn(vm.store, 'removeGroup');
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+ jest.spyOn($, 'scrollTo').mockImplementation(() => {});
+
+ vm.leaveGroup();
+
+ expect(vm.showModal).toBe(false);
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath);
+ return waitForPromises().then(() => {
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup);
+ expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
+ });
+ });
+
+ it('should show error flash message if request failed to leave group', () => {
+ const message = 'An error occurred. Please try again.';
+ jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 500 });
+ jest.spyOn(vm.store, 'removeGroup');
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+
+ vm.leaveGroup();
+
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ return waitForPromises().then(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(vm.targetGroup.isBeingRemoved).toBe(false);
+ });
+ });
+
+ it('should show appropriate error flash message if request forbids to leave group', () => {
+ const message = 'Failed to leave the group. Please make sure you are not the only owner.';
+ jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 403 });
+ jest.spyOn(vm.store, 'removeGroup');
+ jest.spyOn(window, 'Flash').mockImplementation(() => {});
+
+ vm.leaveGroup(childGroupItem, groupItem);
+
+ expect(vm.targetGroup.isBeingRemoved).toBe(true);
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ return waitForPromises().then(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(vm.targetGroup.isBeingRemoved).toBe(false);
+ });
+ });
+ });
+
+ describe('updatePagination', () => {
+ it('should set pagination info to store from provided headers', () => {
+ jest.spyOn(vm.store, 'setPaginationInfo').mockImplementation(() => {});
+
+ vm.updatePagination(mockRawPageInfo);
+
+ expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo);
+ });
+ });
+
+ describe('updateGroups', () => {
+ it('should call setGroups on store if method was called directly', () => {
+ jest.spyOn(vm.store, 'setGroups').mockImplementation(() => {});
+
+ vm.updateGroups(mockGroups);
+
+ expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should call setSearchedGroups on store if method was called with fromSearch param', () => {
+ jest.spyOn(vm.store, 'setSearchedGroups').mockImplementation(() => {});
+
+ vm.updateGroups(mockGroups, true);
+
+ expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should set `isSearchEmpty` prop based on groups count', () => {
+ vm.updateGroups(mockGroups);
+
+ expect(vm.isSearchEmpty).toBe(false);
+
+ vm.updateGroups([]);
+
+ expect(vm.isSearchEmpty).toBe(true);
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should bind event listeners on eventHub', () => {
+ jest.spyOn(eventHub, '$on').mockImplementation(() => {});
+
+ const newVm = createComponent();
+ newVm.$mount();
+
+ return vm.$nextTick().then(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', expect.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', expect.any(Function));
+ newVm.$destroy();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', () => {
+ const newVm = createComponent();
+ newVm.$mount();
+ return vm.$nextTick().then(() => {
+ expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search');
+ newVm.$destroy();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', () => {
+ const newVm = createComponent(true);
+ newVm.$mount();
+ return vm.$nextTick().then(() => {
+ expect(newVm.searchEmptyMessage).toBe('No groups matched your search');
+ newVm.$destroy();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', () => {
+ jest.spyOn(eventHub, '$off').mockImplementation(() => {});
+
+ const newVm = createComponent();
+ newVm.$mount();
+ newVm.$destroy();
+
+ return vm.$nextTick().then(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', expect.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', expect.any(Function));
+ });
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render loading icon', () => {
+ vm.isLoading = true;
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
+ expect(vm.$el.querySelector('span').getAttribute('aria-label')).toBe('Loading groups');
+ });
+ });
+
+ it('should render groups tree', () => {
+ vm.store.state.groups = [mockParentGroupItem];
+ vm.isLoading = false;
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ });
+ });
+
+ it('renders modal confirmation dialog', () => {
+ vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?';
+ vm.showModal = true;
+ return vm.$nextTick().then(() => {
+ const modalDialogEl = vm.$el.querySelector('.modal');
+
+ expect(modalDialogEl).not.toBe(null);
+ expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
+ expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js
new file mode 100644
index 00000000000..a40fa9bece8
--- /dev/null
+++ b/spec/frontend/groups/components/group_folder_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import { mockGroups, mockParentGroupItem } from '../mock_data';
+
+const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
+ const Component = Vue.extend(groupFolderComponent);
+
+ return new Component({
+ propsData: {
+ groups,
+ parentGroup,
+ },
+ });
+};
+
+describe('GroupFolderComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+ vm.$mount();
+
+ return Vue.nextTick();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('hasMoreChildren', () => {
+ it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
+ expect(vm.hasMoreChildren).toBeFalsy();
+ });
+ });
+
+ describe('moreChildrenStats', () => {
+ it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
+ expect(vm.moreChildrenStats).toBe('3 more items');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
+ });
+
+ it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
+ const parentGroup = { ...mockParentGroupItem };
+ parentGroup.childrenCount = 21;
+
+ const newVm = createComponent(mockGroups, parentGroup);
+ newVm.$mount();
+
+ expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
+ newVm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js
new file mode 100644
index 00000000000..7eb1c54ddb2
--- /dev/null
+++ b/spec/frontend/groups/components/group_item_spec.js
@@ -0,0 +1,215 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import eventHub from '~/groups/event_hub';
+import * as urlUtilities from '~/lib/utils/url_utility';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(groupItemComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('GroupItemComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ Vue.component('group-folder', groupFolderComponent);
+
+ vm = createComponent();
+
+ return Vue.nextTick();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('groupDomId', () => {
+ it('should return ID string suffixed with group ID', () => {
+ expect(vm.groupDomId).toBe('group-55');
+ });
+ });
+
+ describe('rowClass', () => {
+ it('should return map of classes based on group details', () => {
+ const classes = ['is-open', 'has-children', 'has-description', 'being-removed'];
+ const { rowClass } = vm;
+
+ expect(Object.keys(rowClass).length).toBe(classes.length);
+ Object.keys(rowClass).forEach(className => {
+ expect(classes.indexOf(className)).toBeGreaterThan(-1);
+ });
+ });
+ });
+
+ describe('hasChildren', () => {
+ it('should return boolean value representing if group has any children present', () => {
+ let newVm;
+ const group = { ...mockParentGroupItem };
+
+ group.childrenCount = 5;
+ newVm = createComponent(group);
+
+ expect(newVm.hasChildren).toBeTruthy();
+ newVm.$destroy();
+
+ group.childrenCount = 0;
+ newVm = createComponent(group);
+
+ expect(newVm.hasChildren).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('hasAvatar', () => {
+ it('should return boolean value representing if group has any avatar present', () => {
+ let newVm;
+ const group = { ...mockParentGroupItem };
+
+ group.avatarUrl = null;
+ newVm = createComponent(group);
+
+ expect(newVm.hasAvatar).toBeFalsy();
+ newVm.$destroy();
+
+ group.avatarUrl = '/uploads/group_avatar.png';
+ newVm = createComponent(group);
+
+ expect(newVm.hasAvatar).toBeTruthy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing if group item is of type `group` or not', () => {
+ let newVm;
+ const group = { ...mockParentGroupItem };
+
+ group.type = 'group';
+ newVm = createComponent(group);
+
+ expect(newVm.isGroup).toBeTruthy();
+ newVm.$destroy();
+
+ group.type = 'project';
+ newVm = createComponent(group);
+
+ expect(newVm.isGroup).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('onClickRowGroup', () => {
+ let event;
+
+ beforeEach(() => {
+ const classList = {
+ contains() {
+ return false;
+ },
+ };
+
+ event = {
+ target: {
+ classList,
+ parentElement: {
+ classList,
+ },
+ },
+ };
+ });
+
+ it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ vm.onClickRowGroup(event);
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group);
+ });
+
+ it('should navigate page to group homepage if group does not have any children present', () => {
+ jest.spyOn(urlUtilities, 'visitUrl').mockImplementation();
+ const group = { ...mockParentGroupItem };
+ group.childrenCount = 0;
+ const newVm = createComponent(group);
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ newVm.onClickRowGroup(event);
+
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ expect(urlUtilities.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
+ });
+ });
+ });
+
+ describe('template', () => {
+ let group = null;
+
+ describe('for a group pending deletion', () => {
+ beforeEach(() => {
+ group = { ...mockParentGroupItem, pendingRemoval: true };
+ vm = createComponent(group);
+ });
+
+ it('renders the group pending removal badge', () => {
+ const badgeEl = vm.$el.querySelector('.badge-warning');
+
+ expect(badgeEl).toBeDefined();
+ expect(badgeEl.innerHTML).toContain('pending removal');
+ });
+ });
+
+ describe('for a group not scheduled for deletion', () => {
+ beforeEach(() => {
+ group = { ...mockParentGroupItem, pendingRemoval: false };
+ vm = createComponent(group);
+ });
+
+ it('does not render the group pending removal badge', () => {
+ const groupTextContainer = vm.$el.querySelector('.group-text-container');
+
+ expect(groupTextContainer).not.toContain('pending removal');
+ });
+ });
+
+ it('should render component template correctly', () => {
+ const visibilityIconEl = vm.$el.querySelector('.item-visibility');
+
+ expect(vm.$el.getAttribute('id')).toBe('group-55');
+ expect(vm.$el.classList.contains('group-row')).toBeTruthy();
+
+ expect(vm.$el.querySelector('.group-row-contents')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined();
+
+ expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined();
+
+ expect(vm.$el.querySelector('.avatar-container')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined();
+
+ expect(vm.$el.querySelector('.title')).toBeDefined();
+ expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
+
+ expect(visibilityIconEl).not.toBe(null);
+ expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
+ expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
+
+ expect(vm.$el.querySelector('.access-type')).toBeDefined();
+ expect(vm.$el.querySelector('.description')).toBeDefined();
+
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js
new file mode 100644
index 00000000000..6205400eb03
--- /dev/null
+++ b/spec/frontend/groups/components/groups_spec.js
@@ -0,0 +1,72 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import groupsComponent from '~/groups/components/groups.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import { mockGroups, mockPageInfo } from '../mock_data';
+
+const createComponent = (searchEmpty = false) => {
+ const Component = Vue.extend(groupsComponent);
+
+ return mountComponent(Component, {
+ groups: mockGroups,
+ pageInfo: mockPageInfo,
+ searchEmptyMessage: 'No matching results',
+ searchEmpty,
+ });
+};
+
+describe('GroupsComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+
+ return vm.$nextTick();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('change', () => {
+ it('should emit `fetchPage` event when page is changed via pagination', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+
+ vm.change(2);
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'fetchPage',
+ 2,
+ expect.any(Object),
+ expect.any(Object),
+ expect.any(Object),
+ );
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
+ expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0);
+ });
+ });
+
+ it('should render empty search message when `searchEmpty` is `true`', () => {
+ vm.searchEmpty = true;
+ return vm.$nextTick().then(() => {
+ expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js
new file mode 100644
index 00000000000..c0dc1a816e6
--- /dev/null
+++ b/spec/frontend/groups/components/item_actions_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemActionsComponent from '~/groups/components/item_actions.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(itemActionsComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('ItemActionsComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('onLeaveGroup', () => {
+ it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ vm.onLeaveGroup();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'showLeaveGroupModal',
+ vm.group,
+ vm.parentGroup,
+ );
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('controls')).toBeTruthy();
+ });
+
+ it('should render Edit Group button with correct attribute values', () => {
+ const group = { ...mockParentGroupItem };
+ group.canEdit = true;
+ const newVm = createComponent(group);
+
+ const editBtn = newVm.$el.querySelector('a.edit-group');
+
+ expect(editBtn).toBeDefined();
+ expect(editBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(editBtn.getAttribute('href')).toBe(group.editPath);
+ expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
+ expect(editBtn.dataset.originalTitle).toBe('Edit group');
+ expect(editBtn.querySelectorAll('svg use').length).not.toBe(0);
+ expect(editBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#settings');
+
+ newVm.$destroy();
+ });
+
+ it('should render Leave Group button with correct attribute values', () => {
+ const group = { ...mockParentGroupItem };
+ group.canLeave = true;
+ const newVm = createComponent(group);
+
+ const leaveBtn = newVm.$el.querySelector('a.leave-group');
+
+ expect(leaveBtn).toBeDefined();
+ expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(leaveBtn.getAttribute('href')).toBe(group.leavePath);
+ expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group');
+ expect(leaveBtn.dataset.originalTitle).toBe('Leave this group');
+ expect(leaveBtn.querySelectorAll('svg use').length).not.toBe(0);
+ expect(leaveBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#leave');
+
+ newVm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js
new file mode 100644
index 00000000000..bfe27be9b51
--- /dev/null
+++ b/spec/frontend/groups/components/item_caret_spec.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemCaretComponent from '~/groups/components/item_caret.vue';
+
+const createComponent = (isGroupOpen = false) => {
+ const Component = Vue.extend(itemCaretComponent);
+
+ return mountComponent(Component, {
+ isGroupOpen,
+ });
+};
+
+describe('ItemCaretComponent', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ vm = createComponent();
+ expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('svg').length).toBe(1);
+ });
+
+ it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
+ vm = createComponent(true);
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down');
+ });
+
+ it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
+ vm = createComponent();
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right');
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js
new file mode 100644
index 00000000000..771643609ec
--- /dev/null
+++ b/spec/frontend/groups/components/item_stats_spec.js
@@ -0,0 +1,119 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemStatsComponent from '~/groups/components/item_stats.vue';
+import {
+ mockParentGroupItem,
+ ITEM_TYPE,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
+} from '../mock_data';
+
+const createComponent = (item = mockParentGroupItem) => {
+ const Component = Vue.extend(itemStatsComponent);
+
+ return mountComponent(Component, {
+ item,
+ });
+};
+
+describe('ItemStatsComponent', () => {
+ describe('computed', () => {
+ describe('visibilityIcon', () => {
+ it('should return icon class based on `item.visibility` value', () => {
+ Object.keys(VISIBILITY_TYPE_ICON).forEach(visibility => {
+ const item = { ...mockParentGroupItem, visibility };
+ const vm = createComponent(item);
+
+ expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('visibilityTooltip', () => {
+ it('should return tooltip string for Group based on `item.visibility` value', () => {
+ Object.keys(GROUP_VISIBILITY_TYPE).forEach(visibility => {
+ const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.GROUP };
+ const vm = createComponent(item);
+
+ expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+
+ it('should return tooltip string for Project based on `item.visibility` value', () => {
+ Object.keys(PROJECT_VISIBILITY_TYPE).forEach(visibility => {
+ const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.PROJECT };
+ const vm = createComponent(item);
+
+ expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('isProject', () => {
+ it('should return boolean value representing whether `item.type` is Project or not', () => {
+ let item;
+ let vm;
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT };
+ vm = createComponent(item);
+
+ expect(vm.isProject).toBeTruthy();
+ vm.$destroy();
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP };
+ vm = createComponent(item);
+
+ expect(vm.isProject).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing whether `item.type` is Group or not', () => {
+ let item;
+ let vm;
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP };
+ vm = createComponent(item);
+
+ expect(vm.isGroup).toBeTruthy();
+ vm.$destroy();
+
+ item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT };
+ vm = createComponent(item);
+
+ expect(vm.isGroup).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element correctly', () => {
+ const vm = createComponent();
+
+ expect(vm.$el.classList.contains('stats')).toBeTruthy();
+
+ vm.$destroy();
+ });
+
+ it('renders start count and last updated information for project item correctly', () => {
+ const item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT, starCount: 4 };
+ const vm = createComponent(item);
+
+ const projectStarIconEl = vm.$el.querySelector('.project-stars');
+
+ expect(projectStarIconEl).not.toBeNull();
+ expect(projectStarIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
+ expect(projectStarIconEl.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
+ expect(vm.$el.querySelectorAll('.last-updated').length).toBeGreaterThan(0);
+
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js
new file mode 100644
index 00000000000..da6f145fa19
--- /dev/null
+++ b/spec/frontend/groups/components/item_stats_value_spec.js
@@ -0,0 +1,82 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemStatsValueComponent from '~/groups/components/item_stats_value.vue';
+
+const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => {
+ const Component = Vue.extend(itemStatsValueComponent);
+
+ return mountComponent(Component, {
+ title,
+ cssClass,
+ iconName,
+ tooltipPlacement,
+ value,
+ });
+};
+
+describe('ItemStatsValueComponent', () => {
+ describe('computed', () => {
+ let vm;
+ const itemConfig = {
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ };
+
+ describe('isValuePresent', () => {
+ it('returns true if non-empty `value` is present', () => {
+ vm = createComponent({ ...itemConfig, value: 10 });
+
+ expect(vm.isValuePresent).toBeTruthy();
+ });
+
+ it('returns false if empty `value` is present', () => {
+ vm = createComponent(itemConfig);
+
+ expect(vm.isValuePresent).toBeFalsy();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ let vm;
+ beforeEach(() => {
+ vm = createComponent({
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ value: 10,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders component element correctly', () => {
+ expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0);
+ expect(vm.$el.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
+ });
+
+ it('renders element tooltip correctly', () => {
+ expect(vm.$el.dataset.originalTitle).toBe('Subgroups');
+ expect(vm.$el.dataset.placement).toBe('left');
+ });
+
+ it('renders element icon correctly', () => {
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder');
+ });
+
+ it('renders value count correctly', () => {
+ expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10');
+ });
+ });
+});
diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js
new file mode 100644
index 00000000000..251b5b5ff4c
--- /dev/null
+++ b/spec/frontend/groups/components/item_type_icon_spec.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+
+import mountComponent from 'helpers/vue_mount_component_helper';
+import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import { ITEM_TYPE } from '../mock_data';
+
+const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
+ const Component = Vue.extend(itemTypeIconComponent);
+
+ return mountComponent(Component, {
+ itemType,
+ isGroupOpen,
+ });
+};
+
+describe('ItemTypeIconComponent', () => {
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+
+ expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render folder open or close icon based `isGroupOpen` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.GROUP, true);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open');
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder');
+ vm.$destroy();
+ });
+
+ it('should render bookmark icon based on `isProject` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.PROJECT);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark');
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+
+ expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark');
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/groups/mock_data.js b/spec/frontend/groups/mock_data.js
new file mode 100644
index 00000000000..380dda9f7b1
--- /dev/null
+++ b/spec/frontend/groups/mock_data.js
@@ -0,0 +1,398 @@
+export const mockEndpoint = '/dashboard/groups.json';
+
+export const ITEM_TYPE = {
+ PROJECT: 'project',
+ GROUP: 'group',
+};
+
+export const GROUP_VISIBILITY_TYPE = {
+ public: 'Public - The group and any public projects can be viewed without any authentication.',
+ internal: 'Internal - The group and any internal projects can be viewed by any logged in user.',
+ private: 'Private - The group and its projects can only be viewed by members.',
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+ public: 'Public - The project can be accessed without any authentication.',
+ internal: 'Internal - The project can be accessed by any logged in user.',
+ private:
+ 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ public: 'earth',
+ internal: 'shield',
+ private: 'lock',
+};
+
+export const mockParentGroupItem = {
+ id: 55,
+ name: 'hardware',
+ description: '',
+ visibility: 'public',
+ fullName: 'platform / hardware',
+ relativePath: '/platform/hardware',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/edit',
+ childrenCount: 3,
+ leavePath: '/groups/platform/hardware/group_members/leave',
+ parentId: 54,
+ memberCount: '1',
+ projectCount: 1,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
+ updatedAt: '2017-04-09T18:40:39.101Z',
+};
+
+export const mockRawChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [],
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+];
+
+export const mockChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ fullName: 'platform / hardware / bsp',
+ relativePath: '/platform/hardware/bsp',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/bsp/edit',
+ childrenCount: 6,
+ leavePath: '/groups/platform/hardware/bsp/group_members/leave',
+ parentId: 55,
+ memberCount: '1',
+ projectCount: 4,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
+ updatedAt: '2017-04-09T18:40:39.101Z',
+ },
+];
+
+export const mockGroups = [
+ {
+ id: 75,
+ name: 'test-group',
+ description: '',
+ visibility: 'public',
+ full_name: 'test-group',
+ relative_path: '/test-group',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/test-group/edit',
+ children_count: 2,
+ leave_path: '/groups/test-group/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 67,
+ name: 'open-source',
+ description: '',
+ visibility: 'private',
+ full_name: 'open-source',
+ relative_path: '/open-source',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/open-source/edit',
+ children_count: 0,
+ leave_path: '/groups/open-source/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 54,
+ name: 'platform',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform',
+ relative_path: '/platform',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 5,
+ name: 'H5bp',
+ description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.',
+ visibility: 'public',
+ full_name: 'H5bp',
+ relative_path: '/h5bp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/h5bp/edit',
+ children_count: 1,
+ leave_path: '/groups/h5bp/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 4,
+ name: 'Twitter',
+ description: 'Deserunt hic nostrum placeat veniam.',
+ visibility: 'public',
+ full_name: 'Twitter',
+ relative_path: '/twitter',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/twitter/edit',
+ children_count: 2,
+ leave_path: '/groups/twitter/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 3,
+ name: 'Documentcloud',
+ description: 'Consequatur saepe totam ea pariatur maxime.',
+ visibility: 'public',
+ full_name: 'Documentcloud',
+ relative_path: '/documentcloud',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/documentcloud/edit',
+ children_count: 1,
+ leave_path: '/groups/documentcloud/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+ {
+ id: 2,
+ name: 'Gitlab Org',
+ description: 'Debitis ea quas aperiam velit doloremque ab.',
+ visibility: 'public',
+ full_name: 'Gitlab Org',
+ relative_path: '/gitlab-org',
+ can_edit: true,
+ type: 'group',
+ avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
+ permission: 'Owner',
+ edit_path: '/groups/gitlab-org/edit',
+ children_count: 4,
+ leave_path: '/groups/gitlab-org/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 4,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ },
+];
+
+export const mockSearchedGroups = [
+ {
+ id: 55,
+ name: 'hardware',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware',
+ relative_path: '/platform/hardware',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/edit',
+ children_count: 3,
+ leave_path: '/groups/platform/hardware/group_members/leave',
+ parent_id: 54,
+ number_users_with_delimiter: '1',
+ project_count: 1,
+ subgroup_count: 2,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ children: [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ children: [
+ {
+ id: 60,
+ name: 'kernel',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel',
+ relative_path: '/platform/hardware/bsp/kernel',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave',
+ parent_id: 57,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ children: [
+ {
+ id: 61,
+ name: 'common',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common',
+ relative_path: '/platform/hardware/bsp/kernel/common',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/common/edit',
+ children_count: 2,
+ leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave',
+ parent_id: 60,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ updated_at: '2017-04-09T18:40:39.101Z',
+ children: [
+ {
+ id: 17,
+ name: 'v4.4',
+ description:
+ 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common / v4.4',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.4',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit',
+ star_count: 0,
+ updated_at: '2017-09-12T06:37:04.925Z',
+ },
+ {
+ id: 16,
+ name: 'v4.1',
+ description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common / v4.1',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.1',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit',
+ star_count: 0,
+ updated_at: '2017-04-09T18:41:03.112Z',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export const mockRawPageInfo = {
+ 'x-per-page': 10,
+ 'x-page': 10,
+ 'x-total': 10,
+ 'x-total-pages': 10,
+ 'x-next-page': 10,
+ 'x-prev-page': 10,
+};
+
+export const mockPageInfo = {
+ perPage: 10,
+ page: 10,
+ total: 10,
+ totalPages: 10,
+ nextPage: 10,
+ prevPage: 10,
+};
diff --git a/spec/frontend/groups/service/groups_service_spec.js b/spec/frontend/groups/service/groups_service_spec.js
new file mode 100644
index 00000000000..38a565eba01
--- /dev/null
+++ b/spec/frontend/groups/service/groups_service_spec.js
@@ -0,0 +1,42 @@
+import axios from '~/lib/utils/axios_utils';
+
+import GroupsService from '~/groups/service/groups_service';
+import { mockEndpoint, mockParentGroupItem } from '../mock_data';
+
+describe('GroupsService', () => {
+ let service;
+
+ beforeEach(() => {
+ service = new GroupsService(mockEndpoint);
+ });
+
+ describe('getGroups', () => {
+ it('should return promise for `GET` request on provided endpoint', () => {
+ jest.spyOn(axios, 'get').mockResolvedValue();
+ const params = {
+ page: 2,
+ filter: 'git',
+ sort: 'created_asc',
+ archived: true,
+ };
+
+ service.getGroups(55, 2, 'git', 'created_asc', true);
+
+ expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params: { parent_id: 55 } });
+
+ service.getGroups(null, 2, 'git', 'created_asc', true);
+
+ expect(axios.get).toHaveBeenCalledWith(mockEndpoint, { params });
+ });
+ });
+
+ describe('leaveGroup', () => {
+ it('should return promise for `DELETE` request on provided endpoint', () => {
+ jest.spyOn(axios, 'delete').mockResolvedValue();
+
+ service.leaveGroup(mockParentGroupItem.leavePath);
+
+ expect(axios.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
+ });
+ });
+});
diff --git a/spec/frontend/groups/store/groups_store_spec.js b/spec/frontend/groups/store/groups_store_spec.js
new file mode 100644
index 00000000000..7d12f73d270
--- /dev/null
+++ b/spec/frontend/groups/store/groups_store_spec.js
@@ -0,0 +1,123 @@
+import GroupsStore from '~/groups/store/groups_store';
+import {
+ mockGroups,
+ mockSearchedGroups,
+ mockParentGroupItem,
+ mockRawChildren,
+ mockRawPageInfo,
+} from '../mock_data';
+
+describe('ProjectsStore', () => {
+ describe('constructor', () => {
+ it('should initialize default state', () => {
+ let store;
+
+ store = new GroupsStore();
+
+ expect(Object.keys(store.state).length).toBe(2);
+ expect(Array.isArray(store.state.groups)).toBeTruthy();
+ expect(Object.keys(store.state.pageInfo).length).toBe(0);
+ expect(store.hideProjects).not.toBeDefined();
+
+ store = new GroupsStore(true);
+
+ expect(store.hideProjects).toBeTruthy();
+ });
+ });
+
+ describe('setGroups', () => {
+ it('should set groups to state', () => {
+ const store = new GroupsStore();
+ jest.spyOn(store, 'formatGroupItem');
+
+ store.setGroups(mockGroups);
+
+ expect(store.state.groups.length).toBe(mockGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1);
+ });
+ });
+
+ describe('setSearchedGroups', () => {
+ it('should set searched groups to state', () => {
+ const store = new GroupsStore();
+ jest.spyOn(store, 'formatGroupItem');
+
+ store.setSearchedGroups(mockSearchedGroups);
+
+ expect(store.state.groups.length).toBe(mockSearchedGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName')).toBeGreaterThan(
+ -1,
+ );
+ });
+ });
+
+ describe('setGroupChildren', () => {
+ it('should set children to group item in state', () => {
+ const store = new GroupsStore();
+ jest.spyOn(store, 'formatGroupItem');
+
+ store.setGroupChildren(mockParentGroupItem, mockRawChildren);
+
+ expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object));
+ expect(mockParentGroupItem.children.length).toBe(1);
+ expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(mockParentGroupItem.isOpen).toBeTruthy();
+ expect(mockParentGroupItem.isChildrenLoading).toBeFalsy();
+ });
+ });
+
+ describe('setPaginationInfo', () => {
+ it('should parse and set pagination info in state', () => {
+ const store = new GroupsStore();
+
+ store.setPaginationInfo(mockRawPageInfo);
+
+ expect(store.state.pageInfo.perPage).toBe(10);
+ expect(store.state.pageInfo.page).toBe(10);
+ expect(store.state.pageInfo.total).toBe(10);
+ expect(store.state.pageInfo.totalPages).toBe(10);
+ expect(store.state.pageInfo.nextPage).toBe(10);
+ expect(store.state.pageInfo.previousPage).toBe(10);
+ });
+ });
+
+ describe('formatGroupItem', () => {
+ it('should parse group item object and return updated object', () => {
+ let store;
+ let updatedGroupItem;
+
+ store = new GroupsStore();
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+
+ expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
+ expect(updatedGroupItem.isChildrenLoading).toBe(false);
+ expect(updatedGroupItem.isBeingRemoved).toBe(false);
+
+ store = new GroupsStore(true);
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+
+ expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
+ });
+ });
+
+ describe('removeGroup', () => {
+ it('should remove children from group item in state', () => {
+ const store = new GroupsStore();
+ const rawParentGroup = { ...mockGroups[0] };
+ const rawChildGroup = { ...mockGroups[1] };
+
+ store.setGroups([rawParentGroup]);
+ store.setGroupChildren(store.state.groups[0], [rawChildGroup]);
+ const childItem = store.state.groups[0].children[0];
+
+ store.removeGroup(childItem, store.state.groups[0]);
+
+ expect(store.state.groups[0].children.length).toBe(0);
+ });
+ });
+});