Groups tree enhancements for Groups Dashboard and Group Homepage

This commit is contained in:
Kushal Pandya 2017-10-04 14:10:24 +00:00 committed by Bob Van Landuyt
parent 67815272dc
commit de55396134
48 changed files with 2762 additions and 900 deletions

View File

@ -6,10 +6,11 @@ import _ from 'underscore';
*/
export default class FilterableList {
constructor(form, filter, holder) {
constructor(form, filter, holder, filterInputField = 'filter_groups') {
this.filterForm = form;
this.listFilterElement = filter;
this.listHolderElement = holder;
this.filterInputField = filterInputField;
this.isBusy = false;
}
@ -32,10 +33,10 @@ export default class FilterableList {
onFilterInput() {
const $form = $(this.filterForm);
const queryData = {};
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) {
queryData.filter_groups = filterGroupsParam;
queryData[this.filterInputField] = filterGroupsParam;
}
this.filterResults(queryData);

View File

@ -0,0 +1,191 @@
<script>
/* global Flash */
import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import { COMMON_STR } from '../constants';
import groupsComponent from './groups.vue';
export default {
components: {
loadingIcon,
groupsComponent,
},
props: {
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
hideProjects: {
type: Boolean,
required: true,
},
},
data() {
return {
isLoading: true,
isSearchEmpty: false,
searchEmptyMessage: '',
};
},
computed: {
groups() {
return this.store.getGroups();
},
pageInfo() {
return this.store.getPaginationInfo();
},
},
methods: {
fetchGroups({ parentId, page, filterGroupsBy, sortBy, updatePagination }) {
return this.service.getGroups(parentId, page, filterGroupsBy, sortBy)
.then((res) => {
if (updatePagination) {
this.updatePagination(res.headers);
}
return res;
})
.then(res => res.json())
.catch(() => {
this.isLoading = false;
$.scrollTo(0);
Flash(COMMON_STR.FAILURE);
});
},
fetchAllGroups() {
const page = getParameterByName('page') || null;
const sortBy = getParameterByName('sort') || null;
const filterGroupsBy = getParameterByName('filter') || null;
this.isLoading = true;
// eslint-disable-next-line promise/catch-or-return
this.fetchGroups({
page,
filterGroupsBy,
sortBy,
updatePagination: true,
}).then((res) => {
this.isLoading = false;
this.updateGroups(res, Boolean(filterGroupsBy));
});
},
fetchPage(page, filterGroupsBy, sortBy) {
this.isLoading = true;
// eslint-disable-next-line promise/catch-or-return
this.fetchGroups({
page,
filterGroupsBy,
sortBy,
updatePagination: true,
}).then((res) => {
this.isLoading = false;
$.scrollTo(0);
const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
window.history.replaceState({
page: currentPath,
}, document.title, currentPath);
this.updateGroups(res);
});
},
toggleChildren(group) {
const parentGroup = group;
if (!parentGroup.isOpen) {
if (parentGroup.children.length === 0) {
parentGroup.isChildrenLoading = true;
// eslint-disable-next-line promise/catch-or-return
this.fetchGroups({
parentId: parentGroup.id,
}).then((res) => {
this.store.setGroupChildren(parentGroup, res);
}).catch(() => {
parentGroup.isChildrenLoading = false;
});
} else {
parentGroup.isOpen = true;
}
} else {
parentGroup.isOpen = false;
}
},
leaveGroup(group, parentGroup) {
const targetGroup = group;
targetGroup.isBeingRemoved = true;
this.service.leaveGroup(targetGroup.leavePath)
.then(res => res.json())
.then((res) => {
$.scrollTo(0);
this.store.removeGroup(targetGroup, parentGroup);
Flash(res.notice, 'notice');
})
.catch((err) => {
let message = COMMON_STR.FAILURE;
if (err.status === 403) {
message = COMMON_STR.LEAVE_FORBIDDEN;
}
Flash(message);
targetGroup.isBeingRemoved = false;
});
},
updatePagination(headers) {
this.store.setPaginationInfo(headers);
},
updateGroups(groups, fromSearch) {
this.isSearchEmpty = groups ? groups.length === 0 : false;
if (fromSearch) {
this.store.setSearchedGroups(groups);
} else {
this.store.setGroups(groups);
}
},
},
created() {
this.searchEmptyMessage = this.hideProjects ?
COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
eventHub.$on('fetchPage', this.fetchPage);
eventHub.$on('toggleChildren', this.toggleChildren);
eventHub.$on('leaveGroup', this.leaveGroup);
eventHub.$on('updatePagination', this.updatePagination);
eventHub.$on('updateGroups', this.updateGroups);
},
mounted() {
this.fetchAllGroups();
},
beforeDestroy() {
eventHub.$off('fetchPage', this.fetchPage);
eventHub.$off('toggleChildren', this.toggleChildren);
eventHub.$off('leaveGroup', this.leaveGroup);
eventHub.$off('updatePagination', this.updatePagination);
eventHub.$off('updateGroups', this.updateGroups);
},
};
</script>
<template>
<div>
<loading-icon
class="loading-animation prepend-top-20"
size="2"
v-if="isLoading"
:label="s__('GroupsTree|Loading groups')"
/>
<groups-component
v-if="!isLoading"
:groups="groups"
:search-empty="isSearchEmpty"
:search-empty-message="searchEmptyMessage"
:page-info="pageInfo"
/>
</div>
</template>

View File

@ -1,15 +1,27 @@
<script>
import { n__ } from '../../locale';
import { MAX_CHILDREN_COUNT } from '../constants';
export default {
props: {
groups: {
type: Object,
required: true,
},
baseGroup: {
parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
groups: {
type: Array,
required: false,
default: () => ([]),
},
},
computed: {
hasMoreChildren() {
return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT;
},
moreChildrenStats() {
return n__('One more item', '%d more items', this.parentGroup.childrenCount - this.parentGroup.children.length);
},
},
};
</script>
@ -20,8 +32,20 @@ export default {
v-for="(group, index) in groups"
:key="index"
:group="group"
:base-group="baseGroup"
:collection="groups"
:parent-group="parentGroup"
/>
<li
v-if="hasMoreChildren"
class="group-row">
<a
:href="parentGroup.relativePath"
class="group-row-contents has-more-items">
<i
class="fa fa-external-link"
aria-hidden="true"
/>
{{moreChildrenStats}}
</a>
</li>
</ul>
</template>

View File

@ -2,50 +2,29 @@
import identicon from '../../vue_shared/components/identicon.vue';
import eventHub from '../event_hub';
import itemCaret from './item_caret.vue';
import itemTypeIcon from './item_type_icon.vue';
import itemStats from './item_stats.vue';
import itemActions from './item_actions.vue';
export default {
components: {
identicon,
itemCaret,
itemTypeIcon,
itemStats,
itemActions,
},
props: {
parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
group: {
type: Object,
required: true,
},
baseGroup: {
type: Object,
required: false,
default: () => ({}),
},
collection: {
type: Object,
required: false,
default: () => ({}),
},
},
methods: {
onClickRowGroup(e) {
e.stopPropagation();
// Skip for buttons
if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) {
if (this.group.hasSubgroups) {
eventHub.$emit('toggleSubGroups', this.group);
} else {
window.location.href = this.group.groupPath;
}
}
},
onLeaveGroup(e) {
e.preventDefault();
// eslint-disable-next-line no-alert
if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) {
this.leaveGroup();
}
},
leaveGroup() {
eventHub.$emit('leaveGroup', this.group, this.collection);
},
},
computed: {
groupDomId() {
@ -53,51 +32,33 @@ export default {
},
rowClass() {
return {
'group-row': true,
'is-open': this.group.isOpen,
'has-subgroups': this.group.hasSubgroups,
'no-description': !this.group.description,
'has-children': this.hasChildren,
'has-description': this.group.description,
'being-removed': this.group.isBeingRemoved,
};
},
visibilityIcon() {
return {
fa: true,
'fa-globe': this.group.visibility === 'public',
'fa-shield': this.group.visibility === 'internal',
'fa-lock': this.group.visibility === 'private',
};
},
fullPath() {
let fullPath = '';
if (this.group.isOrphan) {
// check if current group is baseGroup
if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) {
// Remove baseGroup prefix from our current group.fullName. e.g:
// baseGroup.fullName: `level1`
// group.fullName: `level1 / level2 / level3`
// Result: `level2 / level3`
const gfn = this.group.fullName;
const bfn = this.baseGroup.fullName;
const length = bfn.length;
const start = gfn.indexOf(bfn);
const extraPrefixChars = 3;
fullPath = gfn.substr(start + length + extraPrefixChars);
} else {
fullPath = this.group.fullName;
}
} else {
fullPath = this.group.name;
}
return fullPath;
},
hasGroups() {
return Object.keys(this.group.subGroups).length > 0;
hasChildren() {
return this.group.childrenCount > 0;
},
hasAvatar() {
return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1;
return this.group.avatarUrl !== null;
},
isGroup() {
return this.group.type === 'group';
},
},
methods: {
onClickRowGroup(e) {
const NO_EXPAND_CLS = 'no-expand';
if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
if (this.hasChildren) {
eventHub.$emit('toggleChildren', this.group);
} else {
gl.utils.visitUrl(this.group.relativePath);
}
}
},
},
};
@ -108,98 +69,36 @@ export default {
@click.stop="onClickRowGroup"
:id="groupDomId"
:class="rowClass"
class="group-row"
>
<div
class="group-row-contents">
<div
class="controls">
<a
v-if="group.canEdit"
class="edit-group btn"
:href="group.editPath">
<i
class="fa fa-cogs"
aria-hidden="true"
>
</i>
</a>
<a
@click="onLeaveGroup"
:href="group.leavePath"
class="leave-group btn"
title="Leave this group">
<i
class="fa fa-sign-out"
aria-hidden="true"
>
</i>
</a>
</div>
<div
class="stats">
<span
class="number-projects">
<i
class="fa fa-bookmark"
aria-hidden="true"
>
</i>
{{group.numberProjects}}
</span>
<span
class="number-users">
<i
class="fa fa-users"
aria-hidden="true"
>
</i>
{{group.numberUsers}}
</span>
<span
class="group-visibility">
<i
:class="visibilityIcon"
aria-hidden="true"
>
</i>
</span>
</div>
<item-actions
v-if="isGroup"
:group="group"
:parent-group="parentGroup"
/>
<item-stats
:item="group"
/>
<div
class="folder-toggle-wrap">
<span
class="folder-caret"
v-if="group.hasSubgroups">
<i
v-if="group.isOpen"
class="fa fa-caret-down"
aria-hidden="true"
>
</i>
<i
v-if="!group.isOpen"
class="fa fa-caret-right"
aria-hidden="true"
>
</i>
</span>
<span class="folder-icon">
<i
v-if="group.isOpen"
class="fa fa-folder-open"
aria-hidden="true"
>
</i>
<i
v-if="!group.isOpen"
class="fa fa-folder"
aria-hidden="true">
</i>
</span>
<item-caret
:is-group-open="group.isOpen"
/>
<item-type-icon
:item-type="group.type"
:is-group-open="group.isOpen"
/>
</div>
<div
class="avatar-container s40 hidden-xs">
class="avatar-container s40 hidden-xs"
:class="{ 'content-loading': group.isChildrenLoading }"
>
<a
:href="group.groupPath">
:href="group.relativePath"
class="no-expand"
>
<img
v-if="hasAvatar"
class="avatar s40"
@ -215,19 +114,22 @@ export default {
<div
class="title">
<a
:href="group.groupPath">{{fullPath}}</a>
<template v-if="group.permissions.humanGroupAccess">
as
<span class="access-type">{{group.permissions.humanGroupAccess}}</span>
</template>
:href="group.relativePath"
class="no-expand">{{group.fullName}}</a>
<span
v-if="group.permission"
class="access-type"
>
{{s__('GroupsTreeRole|as')}} {{group.permission}}
</span>
</div>
<div
class="description">{{group.description}}</div>
</div>
<group-folder
v-if="group.isOpen && hasGroups"
:groups="group.subGroups"
:baseGroup="group"
v-if="group.isOpen && hasChildren"
:parent-group="group"
:groups="group.children"
/>
</li>
</template>

View File

@ -4,18 +4,26 @@ import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils';
export default {
components: {
tablePagination,
},
props: {
groups: {
type: Object,
type: Array,
required: true,
},
pageInfo: {
type: Object,
required: true,
},
},
components: {
tablePagination,
searchEmpty: {
type: Boolean,
required: true,
},
searchEmptyMessage: {
type: String,
required: true,
},
},
methods: {
change(page) {
@ -29,10 +37,17 @@ export default {
<template>
<div class="groups-list-tree-container">
<div
v-if="searchEmpty"
class="has-no-search-results">
{{searchEmptyMessage}}
</div>
<group-folder
v-if="!searchEmpty"
:groups="groups"
/>
<table-pagination
v-if="!searchEmpty"
:change="change"
:pageInfo="pageInfo"
/>

View File

@ -0,0 +1,92 @@
<script>
import { s__ } from '../../locale';
import tooltip from '../../vue_shared/directives/tooltip';
import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
import eventHub from '../event_hub';
import { COMMON_STR } from '../constants';
export default {
components: {
PopupDialog,
},
directives: {
tooltip,
},
props: {
parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
group: {
type: Object,
required: true,
},
},
data() {
return {
dialogStatus: false,
};
},
computed: {
leaveBtnTitle() {
return COMMON_STR.LEAVE_BTN_TITLE;
},
editBtnTitle() {
return COMMON_STR.EDIT_BTN_TITLE;
},
leaveConfirmationMessage() {
return s__(`GroupsTree|Are you sure you want to leave the "${this.group.fullName}" group?`);
},
},
methods: {
onLeaveGroup() {
this.dialogStatus = true;
},
leaveGroup(leaveConfirmed) {
this.dialogStatus = false;
if (leaveConfirmed) {
eventHub.$emit('leaveGroup', this.group, this.parentGroup);
}
},
},
};
</script>
<template>
<div class="controls">
<a
v-tooltip
v-if="group.canEdit"
:href="group.editPath"
:title="editBtnTitle"
:aria-label="editBtnTitle"
data-container="body"
class="edit-group btn no-expand">
<i
class="fa fa-cogs"
aria-hidden="true"/>
</a>
<a
v-tooltip
v-if="group.canLeave"
@click.prevent="onLeaveGroup"
:href="group.leavePath"
:title="leaveBtnTitle"
:aria-label="leaveBtnTitle"
data-container="body"
class="leave-group btn no-expand">
<i
class="fa fa-sign-out"
aria-hidden="true"/>
</a>
<popup-dialog
v-show="dialogStatus"
:primary-button-label="__('Leave')"
kind="warning"
:title="__('Are you sure?')"
:body="leaveConfirmationMessage"
@submit="leaveGroup"
/>
</div>
</template>

View File

@ -0,0 +1,25 @@
<script>
export default {
props: {
isGroupOpen: {
type: Boolean,
required: true,
default: false,
},
},
computed: {
iconClass() {
return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right';
},
},
};
</script>
<template>
<span class="folder-caret">
<i
:class="iconClass"
class="fa"
aria-hidden="true"/>
</span>
</template>

View File

@ -0,0 +1,98 @@
<script>
import tooltip from '../../vue_shared/directives/tooltip';
import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE } from '../constants';
export default {
directives: {
tooltip,
},
props: {
item: {
type: Object,
required: true,
},
},
computed: {
visibilityIcon() {
return VISIBILITY_TYPE_ICON[this.item.visibility];
},
visibilityTooltip() {
if (this.item.type === ITEM_TYPE.GROUP) {
return GROUP_VISIBILITY_TYPE[this.item.visibility];
}
return PROJECT_VISIBILITY_TYPE[this.item.visibility];
},
isProject() {
return this.item.type === ITEM_TYPE.PROJECT;
},
isGroup() {
return this.item.type === ITEM_TYPE.GROUP;
},
},
};
</script>
<template>
<div class="stats">
<span
v-tooltip
v-if="isGroup"
:title="s__('Subgroups')"
class="number-subgroups"
data-placement="top"
data-container="body">
<i
class="fa fa-folder"
aria-hidden="true"
/>
{{item.subgroupCount}}
</span>
<span
v-tooltip
v-if="isGroup"
:title="s__('Projects')"
class="number-projects"
data-placement="top"
data-container="body">
<i
class="fa fa-bookmark"
aria-hidden="true"
/>
{{item.projectCount}}
</span>
<span
v-tooltip
v-if="isGroup"
:title="s__('Members')"
class="number-users"
data-placement="top"
data-container="body">
<i
class="fa fa-users"
aria-hidden="true"
/>
{{item.memberCount}}
</span>
<span
v-if="isProject"
class="project-stars">
<i
class="fa fa-star"
aria-hidden="true"
/>
{{item.starCount}}
</span>
<span
v-tooltip
:title="visibilityTooltip"
data-placement="left"
data-container="body"
class="item-visibility">
<i
:class="visibilityIcon"
class="fa"
aria-hidden="true"
/>
</span>
</div>
</template>

View File

@ -0,0 +1,34 @@
<script>
import { ITEM_TYPE } from '../constants';
export default {
props: {
itemType: {
type: String,
required: true,
},
isGroupOpen: {
type: Boolean,
required: true,
default: false,
},
},
computed: {
iconClass() {
if (this.itemType === ITEM_TYPE.GROUP) {
return this.isGroupOpen ? 'fa-folder-open' : 'fa-folder';
}
return 'fa-bookmark';
},
},
};
</script>
<template>
<span class="item-type-icon">
<i
:class="iconClass"
class="fa"
aria-hidden="true"/>
</span>
</template>

View File

@ -0,0 +1,35 @@
import { __, s__ } from '../locale';
export const MAX_CHILDREN_COUNT = 20;
export const COMMON_STR = {
FAILURE: __('An error occurred. Please try again.'),
LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'),
LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'),
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'),
};
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.'),
};
export const VISIBILITY_TYPE_ICON = {
public: 'fa-globe',
internal: 'fa-shield',
private: 'fa-lock',
};

View File

@ -3,12 +3,13 @@ import eventHub from './event_hub';
import { getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList {
constructor({ form, filter, holder, filterEndpoint, pagePath }) {
super(form, filter, holder);
constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
super(form, filter, holder, filterInputField);
this.form = form;
this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath;
this.$dropdown = $('.js-group-filter-dropdown-wrap');
this.filterInputField = filterInputField;
this.$dropdown = $(dropdownSel);
}
getFilterEndpoint() {
@ -35,11 +36,11 @@ export default class GroupFilterableList extends FilterableList {
e.preventDefault();
const $form = $(this.form);
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
const queryData = {};
if (filterGroupsParam) {
queryData.filter_groups = filterGroupsParam;
queryData[this.filterInputField] = filterGroupsParam;
}
this.filterResults(queryData);
@ -47,7 +48,7 @@ export default class GroupFilterableList extends FilterableList {
}
setDefaultFilterOption() {
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a').first().text());
this.$dropdown.find('.dropdown-label').text(defaultOption);
}
@ -65,13 +66,15 @@ export default class GroupFilterableList extends FilterableList {
// Active selected option
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
this.$dropdown.find('.dropdown-menu li a').removeClass('is-active');
$(e.target).addClass('is-active');
// Clear current value on search form
this.form.querySelector('[name="filter_groups"]').value = '';
this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
}
onFilterSuccess(data, xhr, queryData) {
super.onFilterSuccess(data, xhr, queryData);
const currentPath = this.getPagePath(queryData);
const paginationData = {
'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
@ -82,7 +85,11 @@ export default class GroupFilterableList extends FilterableList {
'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
};
eventHub.$emit('updateGroups', data);
window.history.replaceState({
page: currentPath,
}, document.title, currentPath);
eventHub.$emit('updateGroups', data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
eventHub.$emit('updatePagination', paginationData);
}
}

View File

@ -1,17 +1,22 @@
/* global Flash */
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import GroupFilterableList from './groups_filterable_list';
import GroupsComponent from './components/groups.vue';
import GroupFolder from './components/group_folder.vue';
import GroupItem from './components/group_item.vue';
import GroupsStore from './stores/groups_store';
import GroupsService from './services/groups_service';
import eventHub from './event_hub';
import { getParameterByName } from '../lib/utils/common_utils';
import NewGroupChild from './new_group_child';
import GroupsStore from './store/groups_store';
import GroupsService from './service/groups_service';
import groupsApp from './components/app.vue';
import groupFolderComponent from './components/group_folder.vue';
import groupItemComponent from './components/group_item.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('dashboard-group-app');
const el = document.getElementById('js-groups-tree');
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
// Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL
@ -19,176 +24,61 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
Vue.component('groups-component', GroupsComponent);
Vue.component('group-folder', GroupFolder);
Vue.component('group-item', GroupItem);
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
if (newGroupChildWrapper) {
// eslint-disable-next-line no-new
new NewGroupChild(newGroupChildWrapper);
}
// eslint-disable-next-line no-new
new Vue({
el,
components: {
groupsApp,
},
data() {
this.store = new GroupsStore();
this.service = new GroupsService(el.dataset.endpoint);
const dataset = this.$options.el.dataset;
const hideProjects = dataset.hideProjects === 'true';
const store = new GroupsStore(hideProjects);
const service = new GroupsService(dataset.endpoint);
return {
store: this.store,
isLoading: true,
state: this.store.state,
store,
service,
hideProjects,
loading: true,
};
},
computed: {
isEmpty() {
return Object.keys(this.state.groups).length === 0;
},
},
methods: {
fetchGroups(parentGroup) {
let parentId = null;
let getGroups = null;
let page = null;
let sort = null;
let pageParam = null;
let sortParam = null;
let filterGroups = null;
let filterGroupsParam = null;
if (parentGroup) {
parentId = parentGroup.id;
} else {
this.isLoading = true;
}
pageParam = getParameterByName('page');
if (pageParam) {
page = pageParam;
}
filterGroupsParam = getParameterByName('filter_groups');
if (filterGroupsParam) {
filterGroups = filterGroupsParam;
}
sortParam = getParameterByName('sort');
if (sortParam) {
sort = sortParam;
}
getGroups = this.service.getGroups(parentId, page, filterGroups, sort);
getGroups
.then(response => response.json())
.then((response) => {
this.isLoading = false;
this.updateGroups(response, parentGroup);
})
.catch(this.handleErrorResponse);
return getGroups;
},
fetchPage(page, filterGroups, sort) {
this.isLoading = true;
return this.service
.getGroups(null, page, filterGroups, sort)
.then((response) => {
this.isLoading = false;
$.scrollTo(0);
const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
window.history.replaceState({
page: currentPath,
}, document.title, currentPath);
return response.json().then((data) => {
this.updateGroups(data);
this.updatePagination(response.headers);
});
})
.catch(this.handleErrorResponse);
},
toggleSubGroups(parentGroup = null) {
if (!parentGroup.isOpen) {
this.store.resetGroups(parentGroup);
this.fetchGroups(parentGroup);
}
this.store.toggleSubGroups(parentGroup);
},
leaveGroup(group, collection) {
this.service.leaveGroup(group.leavePath)
.then(resp => resp.json())
.then((response) => {
$.scrollTo(0);
this.store.removeGroup(group, collection);
// eslint-disable-next-line no-new
new Flash(response.notice, 'notice');
})
.catch((error) => {
let message = 'An error occurred. Please try again.';
if (error.status === 403) {
message = 'Failed to leave the group. Please make sure you are not the only owner';
}
// eslint-disable-next-line no-new
new Flash(message);
});
},
updateGroups(groups, parentGroup) {
this.store.setGroups(groups, parentGroup);
},
updatePagination(headers) {
this.store.storePagination(headers);
},
handleErrorResponse() {
this.isLoading = false;
$.scrollTo(0);
// eslint-disable-next-line no-new
new Flash('An error occurred. Please try again.');
},
},
created() {
eventHub.$on('fetchPage', this.fetchPage);
eventHub.$on('toggleSubGroups', this.toggleSubGroups);
eventHub.$on('leaveGroup', this.leaveGroup);
eventHub.$on('updateGroups', this.updateGroups);
eventHub.$on('updatePagination', this.updatePagination);
},
beforeMount() {
const dataset = this.$options.el.dataset;
let groupFilterList = null;
const form = document.querySelector('form#group-filter-form');
const filter = document.querySelector('.js-groups-list-filter');
const holder = document.querySelector('.js-groups-list-holder');
const form = document.querySelector(dataset.formSel);
const filter = document.querySelector(dataset.filterSel);
const holder = document.querySelector(dataset.holderSel);
const opts = {
form,
filter,
holder,
filterEndpoint: el.dataset.endpoint,
pagePath: el.dataset.path,
filterEndpoint: dataset.endpoint,
pagePath: dataset.path,
dropdownSel: dataset.dropdownSel,
filterInputField: 'filter',
};
groupFilterList = new GroupFilterableList(opts);
groupFilterList.initSearch();
},
mounted() {
this.fetchGroups()
.then((response) => {
this.updatePagination(response.headers);
this.isLoading = false;
})
.catch(this.handleErrorResponse);
},
beforeDestroy() {
eventHub.$off('fetchPage', this.fetchPage);
eventHub.$off('toggleSubGroups', this.toggleSubGroups);
eventHub.$off('leaveGroup', this.leaveGroup);
eventHub.$off('updateGroups', this.updateGroups);
eventHub.$off('updatePagination', this.updatePagination);
render(createElement) {
return createElement('groups-app', {
props: {
store: this.store,
service: this.service,
hideProjects: this.hideProjects,
},
});
},
});
});

View File

@ -0,0 +1,62 @@
import DropLab from '../droplab/drop_lab';
import ISetter from '../droplab/plugins/input_setter';
const InputSetter = Object.assign({}, ISetter);
const NEW_PROJECT = 'new-project';
const NEW_SUBGROUP = 'new-subgroup';
export default class NewGroupChild {
constructor(buttonWrapper) {
this.buttonWrapper = buttonWrapper;
this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child');
this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle');
this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu');
this.newGroupPath = this.buttonWrapper.dataset.projectPath;
this.subgroupPath = this.buttonWrapper.dataset.subgroupPath;
this.init();
}
init() {
this.initDroplab();
this.bindEvents();
}
initDroplab() {
this.droplab = new DropLab();
this.droplab.init(
this.dropdownToggle,
this.dropdownList,
[InputSetter],
this.getDroplabConfig(),
);
}
getDroplabConfig() {
return {
InputSetter: [{
input: this.newGroupChildButton,
valueAttribute: 'data-value',
inputAttribute: 'data-action',
}, {
input: this.newGroupChildButton,
valueAttribute: 'data-text',
}],
};
}
bindEvents() {
this.newGroupChildButton
.addEventListener('click', this.onClickNewGroupChildButton.bind(this));
}
onClickNewGroupChildButton(e) {
if (e.target.dataset.action === NEW_PROJECT) {
gl.utils.visitUrl(this.newGroupPath);
} else if (e.target.dataset.action === NEW_SUBGROUP) {
gl.utils.visitUrl(this.subgroupPath);
}
}
}

View File

@ -20,7 +20,7 @@ export default class GroupsService {
}
if (filterGroups) {
data.filter_groups = filterGroups;
data.filter = filterGroups;
}
if (sort) {

View File

@ -0,0 +1,105 @@
import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
export default class GroupsStore {
constructor(hideProjects) {
this.state = {};
this.state.groups = [];
this.state.pageInfo = {};
this.hideProjects = hideProjects;
}
setGroups(rawGroups) {
if (rawGroups && rawGroups.length) {
this.state.groups = rawGroups.map(rawGroup => this.formatGroupItem(rawGroup));
} else {
this.state.groups = [];
}
}
setSearchedGroups(rawGroups) {
const formatGroups = groups => groups.map((group) => {
const formattedGroup = this.formatGroupItem(group);
if (formattedGroup.children && formattedGroup.children.length) {
formattedGroup.children = formatGroups(formattedGroup.children);
}
return formattedGroup;
});
if (rawGroups && rawGroups.length) {
this.state.groups = formatGroups(rawGroups);
} else {
this.state.groups = [];
}
}
setGroupChildren(parentGroup, children) {
const updatedParentGroup = parentGroup;
updatedParentGroup.children = children.map(rawChild => this.formatGroupItem(rawChild));
updatedParentGroup.isOpen = true;
updatedParentGroup.isChildrenLoading = false;
}
getGroups() {
return this.state.groups;
}
setPaginationInfo(pagination = {}) {
let paginationInfo;
if (Object.keys(pagination).length) {
const normalizedHeaders = normalizeHeaders(pagination);
paginationInfo = parseIntPagination(normalizedHeaders);
} else {
paginationInfo = pagination;
}
this.state.pageInfo = paginationInfo;
}
getPaginationInfo() {
return this.state.pageInfo;
}
formatGroupItem(rawGroupItem) {
const groupChildren = rawGroupItem.children || [];
const groupIsOpen = (groupChildren.length > 0) || false;
const childrenCount = this.hideProjects ?
rawGroupItem.subgroup_count :
rawGroupItem.children_count;
return {
id: rawGroupItem.id,
name: rawGroupItem.name,
fullName: rawGroupItem.full_name,
description: rawGroupItem.description,
visibility: rawGroupItem.visibility,
avatarUrl: rawGroupItem.avatar_url,
relativePath: rawGroupItem.relative_path,
editPath: rawGroupItem.edit_path,
leavePath: rawGroupItem.leave_path,
canEdit: rawGroupItem.can_edit,
canLeave: rawGroupItem.can_leave,
type: rawGroupItem.type,
permission: rawGroupItem.permission,
children: groupChildren,
isOpen: groupIsOpen,
isChildrenLoading: false,
isBeingRemoved: false,
parentId: rawGroupItem.parent_id,
childrenCount,
projectCount: rawGroupItem.project_count,
subgroupCount: rawGroupItem.subgroup_count,
memberCount: rawGroupItem.number_users_with_delimiter,
starCount: rawGroupItem.star_count,
};
}
removeGroup(group, parentGroup) {
const updatedParentGroup = parentGroup;
if (updatedParentGroup.children && updatedParentGroup.children.length) {
updatedParentGroup.children = parentGroup.children.filter(child => group.id !== child.id);
} else {
this.state.groups = this.state.groups.filter(child => group.id !== child.id);
}
}
}

View File

@ -1,167 +0,0 @@
import Vue from 'vue';
import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
export default class GroupsStore {
constructor() {
this.state = {};
this.state.groups = {};
this.state.pageInfo = {};
}
setGroups(rawGroups, parent) {
const parentGroup = parent;
const tree = this.buildTree(rawGroups, parentGroup);
if (parentGroup) {
parentGroup.subGroups = tree;
} else {
this.state.groups = tree;
}
return tree;
}
// eslint-disable-next-line class-methods-use-this
resetGroups(parent) {
const parentGroup = parent;
parentGroup.subGroups = {};
}
storePagination(pagination = {}) {
let paginationInfo;
if (Object.keys(pagination).length) {
const normalizedHeaders = normalizeHeaders(pagination);
paginationInfo = parseIntPagination(normalizedHeaders);
} else {
paginationInfo = pagination;
}
this.state.pageInfo = paginationInfo;
}
buildTree(rawGroups, parentGroup) {
const groups = this.decorateGroups(rawGroups);
const tree = {};
const mappedGroups = {};
const orphans = [];
// Map groups to an object
groups.map((group) => {
mappedGroups[`id${group.id}`] = group;
mappedGroups[`id${group.id}`].subGroups = {};
return group;
});
Object.keys(mappedGroups).map((key) => {
const currentGroup = mappedGroups[key];
if (currentGroup.parentId) {
// If the group is not at the root level, add it to its parent array of subGroups.
const findParentGroup = mappedGroups[`id${currentGroup.parentId}`];
if (findParentGroup) {
mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup;
mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups
} else if (parentGroup && parentGroup.id === currentGroup.parentId) {
tree[`id${currentGroup.id}`] = currentGroup;
} else {
// No parent found. We save it for later processing
orphans.push(currentGroup);
// Add to tree to preserve original order
tree[`id${currentGroup.id}`] = currentGroup;
}
} else {
// If the group is at the top level, add it to first level elements array.
tree[`id${currentGroup.id}`] = currentGroup;
}
return key;
});
if (orphans.length) {
orphans.map((orphan) => {
let found = false;
const currentOrphan = orphan;
Object.keys(tree).map((key) => {
const group = tree[key];
if (
group &&
currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 &&
// Make sure the currently selected orphan is not the same as the group
// we are checking here otherwise it will end up in an infinite loop
currentOrphan.id !== group.id
) {
group.subGroups[currentOrphan.id] = currentOrphan;
group.isOpen = true;
currentOrphan.isOrphan = true;
found = true;
// Delete if group was put at the top level. If not the group will be displayed twice.
if (tree[`id${currentOrphan.id}`]) {
delete tree[`id${currentOrphan.id}`];
}
}
return key;
});
if (!found) {
currentOrphan.isOrphan = true;
tree[`id${currentOrphan.id}`] = currentOrphan;
}
return orphan;
});
}
return tree;
}
decorateGroups(rawGroups) {
this.groups = rawGroups.map(this.decorateGroup);
return this.groups;
}
// eslint-disable-next-line class-methods-use-this
decorateGroup(rawGroup) {
return {
id: rawGroup.id,
fullName: rawGroup.full_name,
fullPath: rawGroup.full_path,
avatarUrl: rawGroup.avatar_url,
name: rawGroup.name,
hasSubgroups: rawGroup.has_subgroups,
canEdit: rawGroup.can_edit,
description: rawGroup.description,
webUrl: rawGroup.web_url,
groupPath: rawGroup.group_path,
parentId: rawGroup.parent_id,
visibility: rawGroup.visibility,
leavePath: rawGroup.leave_path,
editPath: rawGroup.edit_path,
isOpen: false,
isOrphan: false,
numberProjects: rawGroup.number_projects_with_delimiter,
numberUsers: rawGroup.number_users_with_delimiter,
permissions: {
humanGroupAccess: rawGroup.permissions.human_group_access,
},
subGroups: {},
};
}
// eslint-disable-next-line class-methods-use-this
removeGroup(group, collection) {
Vue.delete(collection, `id${group.id}`);
}
// eslint-disable-next-line class-methods-use-this
toggleSubGroups(toggleGroup) {
const group = toggleGroup;
group.isOpen = !group.isOpen;
return group;
}
}

View File

@ -281,6 +281,57 @@ ul.indent-list {
// Specific styles for tree list
@keyframes spin-avatar {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.groups-list-tree-container {
.has-no-search-results {
text-align: center;
padding: $gl-padding;
font-style: italic;
color: $well-light-text-color;
}
> .group-list-tree > .group-row.has-children:first-child {
border-top: none;
}
}
.group-list-tree .avatar-container.content-loading {
position: relative;
> a,
> a .avatar {
height: 100%;
border-radius: 50%;
}
> a {
padding: 2px;
}
> a .avatar {
border: 2px solid $white-normal;
&.identicon {
line-height: 30px;
}
}
&::after {
content: "";
position: absolute;
height: 100%;
width: 100%;
background-color: transparent;
border: 2px outset $kdb-border;
border-radius: 50%;
animation: spin-avatar 3s infinite linear;
}
}
.group-list-tree {
.folder-toggle-wrap {
float: left;
@ -293,7 +344,7 @@ ul.indent-list {
}
.folder-caret,
.folder-icon {
.item-type-icon {
display: inline-block;
}
@ -301,11 +352,11 @@ ul.indent-list {
width: 15px;
}
.folder-icon {
.item-type-icon {
width: 20px;
}
> .group-row:not(.has-subgroups) {
> .group-row:not(.has-children) {
.folder-caret .fa {
opacity: 0;
}
@ -351,12 +402,23 @@ ul.indent-list {
top: 30px;
bottom: 0;
}
&.being-removed {
opacity: 0.5;
}
}
}
.group-row {
padding: 0;
border: none;
&.has-children {
border-top: none;
}
&:first-child {
border-top: 1px solid $white-normal;
}
&:last-of-type {
.group-row-contents:not(:hover) {
@ -379,6 +441,25 @@ ul.indent-list {
.avatar-container > a {
width: 100%;
}
&.has-more-items {
display: block;
padding: 20px 10px;
}
}
}
ul.group-list-tree {
li.group-row {
&.has-description {
.title {
line-height: inherit;
}
}
.title {
line-height: $list-text-height;
}
}
}

View File

@ -26,14 +26,117 @@
}
}
.groups-header {
@media (min-width: $screen-sm-min) {
.nav-links {
width: 35%;
.group-nav-container .nav-controls {
display: flex;
align-items: flex-start;
padding: $gl-padding-top 0;
border-bottom: 1px solid $border-color;
.group-filter-form {
flex: 1;
}
.dropdown-menu-align-right {
margin-top: 0;
}
.new-project-subgroup {
.dropdown-primary {
min-width: 115px;
}
.nav-controls {
width: 65%;
.dropdown-toggle {
.dropdown-btn-icon {
pointer-events: none;
color: inherit;
margin-left: 0;
}
}
.dropdown-menu {
min-width: 280px;
margin-top: 2px;
}
li:not(.divider) {
padding: 0;
&.droplab-item-selected {
.icon-container {
.list-item-checkmark {
visibility: visible;
}
}
}
.menu-item {
padding: 8px 4px;
&:hover {
background-color: $gray-darker;
color: $theme-gray-900;
}
}
.icon-container {
float: left;
padding-left: 6px;
.list-item-checkmark {
visibility: hidden;
}
}
.description {
font-size: 14px;
strong {
display: block;
font-weight: $gl-font-weight-bold;
}
}
}
}
@media (max-width: $screen-sm-max) {
&,
.dropdown,
.dropdown .dropdown-toggle,
.btn-new {
display: block;
}
.group-filter-form,
.dropdown {
margin-bottom: 10px;
margin-right: 0;
}
.group-filter-form,
.dropdown .dropdown-toggle,
.btn-new {
width: 100%;
}
.dropdown .dropdown-toggle .fa-chevron-down {
position: absolute;
top: 11px;
right: 8px;
}
.new-project-subgroup {
display: flex;
align-items: flex-start;
.dropdown-primary {
flex: 1;
}
.dropdown-menu {
width: 100%;
max-width: inherit;
min-width: inherit;
}
}
}
}

View File

@ -42,6 +42,17 @@ module SortingHelper
options
end
def groups_sort_options_hash
options = {
sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created,
sort_value_recently_updated => sort_title_recently_updated,
sort_value_oldest_updated => sort_title_oldest_updated
}
options
end
def member_sort_options_hash
{
sort_value_access_level_asc => sort_title_access_level_asc,

View File

@ -1,13 +1,13 @@
.top-area
%ul.nav-links
= nav_link(page: dashboard_groups_path) do
= link_to dashboard_groups_path, title: 'Your groups' do
= link_to dashboard_groups_path, title: _("Your groups") do
Your groups
= nav_link(page: explore_groups_path) do
= link_to explore_groups_path, title: 'Explore public groups' do
= link_to explore_groups_path, title: _("Explore public groups") do
Explore public groups
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
- if current_user.can_create_group?
= link_to "New group", new_group_path, class: "btn btn-new"
= link_to _("New group"), new_group_path, class: "btn btn-new"

View File

@ -1,7 +0,0 @@
.groups-empty-state
= custom_icon("icon_empty_groups")
.text-content
%h4 A group is a collection of several projects.
%p If you organize your projects under a group, it works like a folder.
%p You can manage your group members permissions and access to each project in the group.

View File

@ -1,9 +1,2 @@
.js-groups-list-holder
#dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } }
.groups-list-loading
= icon('spinner spin', 'v-show' => 'isLoading')
%template{ 'v-if' => '!isLoading && isEmpty' }
%div{ 'v-cloak' => true }
= render 'empty_state'
%template{ 'v-else-if' => '!isLoading && !isEmpty' }
%groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' }
#js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }

View File

@ -6,7 +6,7 @@
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups'
- if @groups.empty?
= render 'empty_state'
- if params[:filter].blank? && @groups.empty?
= render 'shared/groups/empty_state'
- else
= render 'groups'

View File

@ -1,6 +1,2 @@
.js-groups-list-holder
%ul.content-list
- @groups.each do |group|
= render 'shared/groups/group', group: group
= paginate @groups, theme: 'gitlab'
#js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }

View File

@ -2,6 +2,9 @@
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups'
- if current_user
= render 'dashboard/groups_head'
- else
@ -17,7 +20,7 @@
%p Below you will find all the groups that are public.
%p You can easily contribute to them by requesting to join these groups.
- if @groups.present?
= render 'groups'
- else
- if params[:filter].blank? && @groups.empty?
.nothing-here-block No public groups
- else
= render 'groups'

View File

@ -1,4 +1,5 @@
- if children.any?
render children here
- else
.nothing-here-block No children found
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups'
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }

View File

@ -7,12 +7,35 @@
= render 'groups/home_panel'
.groups-header{ class: container_class }
.top-area
.nav-controls
= render 'shared/projects/search_form'
= render 'shared/projects/dropdown'
.group-nav-container
.nav-controls.clearfix
= render "shared/groups/search_form"
= render "shared/groups/dropdown"
- if can? current_user, :create_projects, @group
= link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do
New Project
- new_project_label = _("New project")
- new_subgroup_label = _("New subgroup")
.btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
%input.btn.btn-success.dropdown-primary.js-new-group-child{ type: "button", value: new_project_label, data: { action: "new-project" } }
%button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown" } }
= icon("caret-down", class: "dropdown-btn-icon")
%ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
%li.droplab-item-selected{ role: "button", data: { value: "new-project", text: new_project_label } }
.menu-item
.icon-container
= icon("check", class: "list-item-checkmark")
.description
%strong= new_project_label
%span= s_("GroupsTree|Create project under this group.")
%li.divider.droplap-item-ignore
%li{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
.menu-item
.icon-container
= icon("check", class: "list-item-checkmark")
.description
%strong= new_subgroup_label
%span= s_("GroupsTree|Create a subgroup under this group.")
= render "children", children: @children
- if params[:filter].blank? && @children.empty?
= render "shared/groups/empty_state"
- else
= render "children", children: @children, group: @group

View File

@ -1,18 +1,21 @@
.dropdown.inline.js-group-filter-dropdown-wrap
- if @sort.present?
- default_sort_by = @sort
- else
- if params[:sort]
- default_sort_by = params[:sort]
- else
- default_sort_by = sort_value_recently_created
.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-label
- if @sort.present?
= sort_options_hash[@sort]
- else
= sort_title_recently_created
= sort_options_hash[default_sort_by]
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right
%li
= link_to filter_groups_path(sort: sort_value_recently_created) do
= sort_title_recently_created
= link_to filter_groups_path(sort: sort_value_oldest_created) do
= sort_title_oldest_created
= link_to filter_groups_path(sort: sort_value_recently_updated) do
= sort_title_recently_updated
= link_to filter_groups_path(sort: sort_value_oldest_updated) do
= sort_title_oldest_updated
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
%li.dropdown-header
= _("Sort by")
- groups_sort_options_hash.each do |value, title|
%li
= link_to filter_groups_path(sort: value), class: "#{ 'is-active' if default_sort_by == value }" do
= title

View File

@ -0,0 +1,7 @@
.groups-empty-state
= custom_icon("icon_empty_groups")
.text-content
%h4= s_("GroupsEmptyState|A group is a collection of several projects.")
%p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
%p= s_("GroupsEmptyState|You can manage your group members permissions and access to each project in the group.")

View File

@ -11,7 +11,7 @@
= link_to edit_group_path(group), class: "btn" do
= icon('cogs')
= link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do
= link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: s_("GroupsTree|Leave this group") do
= icon('sign-out')
.stats

View File

@ -3,4 +3,4 @@
- groups.each_with_index do |group, i|
= render "shared/groups/group", group: group
- else
.nothing-here-block No groups found
.nothing-here-block= s_("GroupsEmptyState|No groups found")

View File

@ -1,2 +1,2 @@
= form_tag request.path, method: :get, class: 'group-filter-form', id: 'group-filter-form' do |f|
= search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name...', class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
= form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f|
= search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Filter by name...'), class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"

View File

@ -1,5 +1,5 @@
- @sort ||= sort_value_latest_activity
.dropdown
.dropdown.js-project-filter-dropdown-wrap
- toggle_text = projects_sort_options_hash[@sort]
= dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' })
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable

View File

@ -16,6 +16,7 @@ feature 'Dashboard Groups page', :js do
sign_in(user)
visit dashboard_groups_path
wait_for_requests
expect(page).to have_content(group.full_name)
expect(page).to have_content(nested_group.full_name)
@ -33,7 +34,7 @@ feature 'Dashboard Groups page', :js do
end
it 'filters groups' do
fill_in 'filter_groups', with: group.name
fill_in 'filter', with: group.name
wait_for_requests
expect(page).to have_content(group.full_name)
@ -42,10 +43,10 @@ feature 'Dashboard Groups page', :js do
end
it 'resets search when user cleans the input' do
fill_in 'filter_groups', with: group.name
fill_in 'filter', with: group.name
wait_for_requests
fill_in 'filter_groups', with: ''
fill_in 'filter', with: ''
wait_for_requests
expect(page).to have_content(group.full_name)

View File

@ -15,6 +15,7 @@ describe 'Explore Groups page', :js do
sign_in(user)
visit explore_groups_path
wait_for_requests
end
it 'shows groups user is member of' do
@ -24,7 +25,7 @@ describe 'Explore Groups page', :js do
end
it 'filters groups' do
fill_in 'filter_groups', with: group.name
fill_in 'filter', with: group.name
wait_for_requests
expect(page).to have_content(group.full_name)
@ -33,10 +34,10 @@ describe 'Explore Groups page', :js do
end
it 'resets search when user cleans the input' do
fill_in 'filter_groups', with: group.name
fill_in 'filter', with: group.name
wait_for_requests
fill_in 'filter_groups', with: ""
fill_in 'filter', with: ""
wait_for_requests
expect(page).to have_content(group.full_name)
@ -47,21 +48,21 @@ describe 'Explore Groups page', :js do
it 'shows non-archived projects count' do
# Initially project is not archived
expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
# Archive project
empty_project.archive!
visit explore_groups_path
# Check project count
expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("0")
expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("0")
# Unarchive project
empty_project.unarchive!
visit explore_groups_path
# Check project count
expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
end
describe 'landing component' do

View File

@ -0,0 +1,440 @@
import Vue from 'vue';
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 {
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);
return new Component({
propsData: {
store,
service,
hideProjects,
},
});
};
const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
if (failed) {
reject(data);
} else {
resolve({
json() {
return data;
},
});
}
});
describe('AppComponent', () => {
let vm;
beforeEach((done) => {
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
vm = createComponent();
Vue.nextTick(() => {
done();
});
});
describe('computed', () => {
beforeEach(() => {
vm.$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('groups', () => {
it('should return list of groups from store', () => {
spyOn(vm.store, 'getGroups');
const groups = vm.groups;
expect(vm.store.getGroups).toHaveBeenCalled();
expect(groups).not.toBeDefined();
});
});
describe('pageInfo', () => {
it('should return pagination info from store', () => {
spyOn(vm.store, 'getPaginationInfo');
const pageInfo = vm.pageInfo;
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', (done) => {
spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups));
vm.fetchGroups({
parentId: 1,
page: 2,
filterGroupsBy: 'git',
sortBy: 'created_desc',
});
setTimeout(() => {
expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc');
done();
}, 0);
});
it('should set headers to store for building pagination info when called with `updatePagination`', (done) => {
spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise({ headers: mockRawPageInfo }));
spyOn(vm, 'updatePagination');
vm.fetchGroups({ updatePagination: true });
setTimeout(() => {
expect(vm.service.getGroups).toHaveBeenCalled();
expect(vm.updatePagination).toHaveBeenCalled();
done();
}, 0);
});
it('should show flash error when request fails', (done) => {
spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true));
spyOn($, 'scrollTo');
spyOn(window, 'Flash');
vm.fetchGroups({});
setTimeout(() => {
expect(vm.isLoading).toBeFalsy();
expect($.scrollTo).toHaveBeenCalledWith(0);
expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
done();
}, 0);
});
});
describe('fetchAllGroups', () => {
it('should fetch default set of groups', (done) => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
spyOn(vm, 'updatePagination').and.callThrough();
spyOn(vm, 'updateGroups').and.callThrough();
vm.fetchAllGroups();
expect(vm.isLoading).toBeTruthy();
expect(vm.fetchGroups).toHaveBeenCalled();
setTimeout(() => {
expect(vm.isLoading).toBeFalsy();
expect(vm.updateGroups).toHaveBeenCalled();
done();
}, 0);
});
it('should fetch matching set of groups when app is loaded with search query', (done) => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups));
spyOn(vm, 'updateGroups').and.callThrough();
vm.fetchAllGroups();
expect(vm.fetchGroups).toHaveBeenCalledWith({
page: null,
filterGroupsBy: null,
sortBy: null,
updatePagination: true,
});
setTimeout(() => {
expect(vm.updateGroups).toHaveBeenCalled();
done();
}, 0);
});
});
describe('fetchPage', () => {
it('should fetch groups for provided page details and update window state', (done) => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
spyOn(vm, 'updateGroups').and.callThrough();
spyOn(gl.utils, 'mergeUrlParams').and.callThrough();
spyOn(window.history, 'replaceState');
spyOn($, 'scrollTo');
vm.fetchPage(2, null, null);
expect(vm.isLoading).toBeTruthy();
expect(vm.fetchGroups).toHaveBeenCalledWith({
page: 2,
filterGroupsBy: null,
sortBy: null,
updatePagination: true,
});
setTimeout(() => {
expect(vm.isLoading).toBeFalsy();
expect($.scrollTo).toHaveBeenCalledWith(0);
expect(gl.utils.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
expect(window.history.replaceState).toHaveBeenCalledWith({
page: jasmine.any(String),
}, jasmine.any(String), jasmine.any(String));
expect(vm.updateGroups).toHaveBeenCalled();
done();
}, 0);
});
});
describe('toggleChildren', () => {
let groupItem;
beforeEach(() => {
groupItem = Object.assign({}, 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', (done) => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren));
spyOn(vm.store, 'setGroupChildren');
vm.toggleChildren(groupItem);
expect(groupItem.isChildrenLoading).toBeTruthy();
expect(vm.fetchGroups).toHaveBeenCalledWith({
parentId: groupItem.id,
});
setTimeout(() => {
expect(vm.store.setGroupChildren).toHaveBeenCalled();
done();
}, 0);
});
it('should skip network request while expanding group if children are already loaded', () => {
spyOn(vm, 'fetchGroups');
groupItem.children = mockRawChildren;
vm.toggleChildren(groupItem);
expect(vm.fetchGroups).not.toHaveBeenCalled();
expect(groupItem.isOpen).toBeTruthy();
});
it('should collapse group if it is already expanded', () => {
spyOn(vm, 'fetchGroups');
groupItem.isOpen = true;
vm.toggleChildren(groupItem);
expect(vm.fetchGroups).not.toHaveBeenCalled();
expect(groupItem.isOpen).toBeFalsy();
});
it('should set `isChildrenLoading` back to `false` if load request fails', (done) => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
vm.toggleChildren(groupItem);
expect(groupItem.isChildrenLoading).toBeTruthy();
setTimeout(() => {
expect(groupItem.isChildrenLoading).toBeFalsy();
done();
}, 0);
});
});
describe('leaveGroup', () => {
let groupItem;
let childGroupItem;
beforeEach(() => {
groupItem = Object.assign({}, mockParentGroupItem);
groupItem.children = mockChildren;
childGroupItem = groupItem.children[0];
groupItem.isChildrenLoading = false;
});
it('should leave group and remove group item from tree', (done) => {
const notice = `You left the "${childGroupItem.fullName}" group.`;
spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice }));
spyOn(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash');
spyOn($, 'scrollTo');
vm.leaveGroup(childGroupItem, groupItem);
expect(childGroupItem.isBeingRemoved).toBeTruthy();
expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
setTimeout(() => {
expect($.scrollTo).toHaveBeenCalledWith(0);
expect(vm.store.removeGroup).toHaveBeenCalledWith(childGroupItem, groupItem);
expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
done();
}, 0);
});
it('should show error flash message if request failed to leave group', (done) => {
const message = 'An error occurred. Please try again.';
spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 500 }, true));
spyOn(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash');
vm.leaveGroup(childGroupItem, groupItem);
expect(childGroupItem.isBeingRemoved).toBeTruthy();
expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
setTimeout(() => {
expect(vm.store.removeGroup).not.toHaveBeenCalled();
expect(window.Flash).toHaveBeenCalledWith(message);
expect(childGroupItem.isBeingRemoved).toBeFalsy();
done();
}, 0);
});
it('should show appropriate error flash message if request forbids to leave group', (done) => {
const message = 'Failed to leave the group. Please make sure you are not the only owner.';
spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 403 }, true));
spyOn(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash');
vm.leaveGroup(childGroupItem, groupItem);
expect(childGroupItem.isBeingRemoved).toBeTruthy();
expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
setTimeout(() => {
expect(vm.store.removeGroup).not.toHaveBeenCalled();
expect(window.Flash).toHaveBeenCalledWith(message);
expect(childGroupItem.isBeingRemoved).toBeFalsy();
done();
}, 0);
});
});
describe('updatePagination', () => {
it('should set pagination info to store from provided headers', () => {
spyOn(vm.store, 'setPaginationInfo');
vm.updatePagination(mockRawPageInfo);
expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo);
});
});
describe('updateGroups', () => {
it('should call setGroups on store if method was called directly', () => {
spyOn(vm.store, 'setGroups');
vm.updateGroups(mockGroups);
expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups);
});
it('should call setSearchedGroups on store if method was called with fromSearch param', () => {
spyOn(vm.store, 'setSearchedGroups');
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).toBeFalsy();
vm.updateGroups([]);
expect(vm.isSearchEmpty).toBeTruthy();
});
});
});
describe('created', () => {
it('should bind event listeners on eventHub', (done) => {
spyOn(eventHub, '$on');
const newVm = createComponent();
newVm.$mount();
Vue.nextTick(() => {
expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
newVm.$destroy();
done();
});
});
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (done) => {
const newVm = createComponent();
newVm.$mount();
Vue.nextTick(() => {
expect(newVm.searchEmptyMessage).toBe('Sorry, no groups or projects matched your search');
newVm.$destroy();
done();
});
});
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (done) => {
const newVm = createComponent(true);
newVm.$mount();
Vue.nextTick(() => {
expect(newVm.searchEmptyMessage).toBe('Sorry, no groups matched your search');
newVm.$destroy();
done();
});
});
});
describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', (done) => {
spyOn(eventHub, '$off');
const newVm = createComponent();
newVm.$mount();
newVm.$destroy();
Vue.nextTick(() => {
expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
done();
});
});
});
describe('template', () => {
beforeEach(() => {
vm.$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render loading icon', (done) => {
vm.isLoading = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
expect(vm.$el.querySelector('i.fa').getAttribute('aria-label')).toBe('Loading groups');
done();
});
});
it('should render groups tree', (done) => {
vm.groups = [mockParentGroupItem];
vm.isLoading = false;
vm.pageInfo = mockPageInfo;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
done();
});
});
});
});

View File

@ -0,0 +1,66 @@
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((done) => {
Vue.component('group-item', groupItemComponent);
vm = createComponent();
vm.$mount();
Vue.nextTick(() => {
done();
});
});
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 = Object.assign({}, 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();
});
});
});

View File

@ -0,0 +1,177 @@
import Vue from 'vue';
import groupItemComponent from '~/groups/components/group_item.vue';
import groupFolderComponent from '~/groups/components/group_folder.vue';
import eventHub from '~/groups/event_hub';
import { mockParentGroupItem, mockChildren } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
const Component = Vue.extend(groupItemComponent);
return mountComponent(Component, {
group,
parentGroup,
});
};
describe('GroupItemComponent', () => {
let vm;
beforeEach((done) => {
Vue.component('group-folder', groupFolderComponent);
vm = createComponent();
Vue.nextTick(() => {
done();
});
});
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.rowClass;
expect(Object.keys(rowClass).length).toBe(classes.length);
Object.keys(rowClass).forEach((className) => {
expect(classes.indexOf(className) > -1).toBeTruthy();
});
});
});
describe('hasChildren', () => {
it('should return boolean value representing if group has any children present', () => {
let newVm;
const group = Object.assign({}, 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 = Object.assign({}, 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 = Object.assign({}, 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', () => {
spyOn(eventHub, '$emit');
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', (done) => {
const group = Object.assign({}, mockParentGroupItem);
group.childrenCount = 0;
const newVm = createComponent(group);
spyOn(gl.utils, 'visitUrl').and.stub();
spyOn(eventHub, '$emit');
newVm.onClickRowGroup(event);
setTimeout(() => {
expect(eventHub.$emit).not.toHaveBeenCalled();
expect(gl.utils.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
done();
}, 0);
});
});
});
describe('template', () => {
it('should render component template correctly', () => {
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(vm.$el.querySelector('.access-type')).toBeDefined();
expect(vm.$el.querySelector('.description')).toBeDefined();
expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
});
});
});

View File

@ -0,0 +1,70 @@
import Vue from 'vue';
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';
import mountComponent from '../../helpers/vue_mount_component_helper';
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((done) => {
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
vm = createComponent();
Vue.nextTick(() => {
done();
});
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('change', () => {
it('should emit `fetchPage` event when page is changed via pagination', () => {
spyOn(eventHub, '$emit').and.stub();
vm.change(2);
expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', 2, jasmine.any(Object), jasmine.any(Object));
});
});
});
describe('template', () => {
it('should render component template correctly', (done) => {
Vue.nextTick(() => {
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 === 0).toBeTruthy();
done();
});
});
it('should render empty search message when `searchEmpty` is `true`', (done) => {
vm.searchEmpty = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
done();
});
});
});
});

View File

@ -0,0 +1,110 @@
import Vue from 'vue';
import itemActionsComponent from '~/groups/components/item_actions.vue';
import eventHub from '~/groups/event_hub';
import { mockParentGroupItem, mockChildren } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
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('computed', () => {
describe('leaveConfirmationMessage', () => {
it('should return appropriate string for leave group confirmation', () => {
expect(vm.leaveConfirmationMessage).toBe('Are you sure you want to leave the "platform / hardware" group?');
});
});
});
describe('methods', () => {
describe('onLeaveGroup', () => {
it('should change `dialogStatus` prop to `true` which shows confirmation dialog', () => {
expect(vm.dialogStatus).toBeFalsy();
vm.onLeaveGroup();
expect(vm.dialogStatus).toBeTruthy();
});
});
describe('leaveGroup', () => {
it('should change `dialogStatus` prop to `false` and emit `leaveGroup` event with required params when called with `leaveConfirmed` as `true`', () => {
spyOn(eventHub, '$emit');
vm.dialogStatus = true;
vm.leaveGroup(true);
expect(vm.dialogStatus).toBeFalsy();
expect(eventHub.$emit).toHaveBeenCalledWith('leaveGroup', vm.group, vm.parentGroup);
});
it('should change `dialogStatus` prop to `false` and should NOT emit `leaveGroup` event when called with `leaveConfirmed` as `false`', () => {
spyOn(eventHub, '$emit');
vm.dialogStatus = true;
vm.leaveGroup(false);
expect(vm.dialogStatus).toBeFalsy();
expect(eventHub.$emit).not.toHaveBeenCalled();
});
});
});
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 = Object.assign({}, 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.querySelector('i.fa.fa-cogs')).toBeDefined();
newVm.$destroy();
});
it('should render Leave Group button with correct attribute values', () => {
const group = Object.assign({}, 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.querySelector('i.fa.fa-sign-out')).toBeDefined();
newVm.$destroy();
});
it('should show modal dialog when `dialogStatus` is set to `true`', () => {
vm.dialogStatus = true;
const modalDialogEl = vm.$el.querySelector('.modal.popup-dialog');
expect(modalDialogEl).toBeDefined();
expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
});
});
});

View File

@ -0,0 +1,40 @@
import Vue from 'vue';
import itemCaretComponent from '~/groups/components/item_caret.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (isGroupOpen = false) => {
const Component = Vue.extend(itemCaretComponent);
return mountComponent(Component, {
isGroupOpen,
});
};
describe('ItemCaretComponent', () => {
describe('template', () => {
it('should render component template correctly', () => {
const vm = createComponent();
vm.$mount();
expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
vm.$destroy();
});
it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
const vm = createComponent(true);
vm.$mount();
expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(1);
expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(0);
vm.$destroy();
});
it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
const vm = createComponent();
vm.$mount();
expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(0);
expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(1);
vm.$destroy();
});
});
});

View File

@ -0,0 +1,159 @@
import Vue from 'vue';
import itemStatsComponent from '~/groups/components/item_stats.vue';
import {
mockParentGroupItem,
ITEM_TYPE,
VISIBILITY_TYPE_ICON,
GROUP_VISIBILITY_TYPE,
PROJECT_VISIBILITY_TYPE,
} from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
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 = Object.assign({}, mockParentGroupItem, { visibility });
const vm = createComponent(item);
vm.$mount();
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 = Object.assign({}, mockParentGroupItem, {
visibility,
type: ITEM_TYPE.GROUP,
});
const vm = createComponent(item);
vm.$mount();
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 = Object.assign({}, mockParentGroupItem, {
visibility,
type: ITEM_TYPE.PROJECT,
});
const vm = createComponent(item);
vm.$mount();
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 = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
vm = createComponent(item);
vm.$mount();
expect(vm.isProject).toBeTruthy();
vm.$destroy();
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
vm = createComponent(item);
vm.$mount();
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 = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
vm = createComponent(item);
vm.$mount();
expect(vm.isGroup).toBeTruthy();
vm.$destroy();
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
vm = createComponent(item);
vm.$mount();
expect(vm.isGroup).toBeFalsy();
vm.$destroy();
});
});
});
describe('template', () => {
it('should render component template correctly', () => {
const vm = createComponent();
vm.$mount();
const visibilityIconEl = vm.$el.querySelector('.item-visibility');
expect(vm.$el.classList.contains('.stats')).toBeDefined();
expect(visibilityIconEl).toBeDefined();
expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
expect(visibilityIconEl.querySelector('i.fa')).toBeDefined();
vm.$destroy();
});
it('should render stat icons if `item.type` is Group', () => {
const item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
const vm = createComponent(item);
vm.$mount();
const subgroupIconEl = vm.$el.querySelector('span.number-subgroups');
expect(subgroupIconEl).toBeDefined();
expect(subgroupIconEl.dataset.originalTitle).toBe('Subgroups');
expect(subgroupIconEl.querySelector('i.fa.fa-folder')).toBeDefined();
expect(subgroupIconEl.innerText.trim()).toBe(`${vm.item.subgroupCount}`);
const projectsIconEl = vm.$el.querySelector('span.number-projects');
expect(projectsIconEl).toBeDefined();
expect(projectsIconEl.dataset.originalTitle).toBe('Projects');
expect(projectsIconEl.querySelector('i.fa.fa-bookmark')).toBeDefined();
expect(projectsIconEl.innerText.trim()).toBe(`${vm.item.projectCount}`);
const membersIconEl = vm.$el.querySelector('span.number-users');
expect(membersIconEl).toBeDefined();
expect(membersIconEl.dataset.originalTitle).toBe('Members');
expect(membersIconEl.querySelector('i.fa.fa-users')).toBeDefined();
expect(membersIconEl.innerText.trim()).toBe(`${vm.item.memberCount}`);
vm.$destroy();
});
it('should render stat icons if `item.type` is Project', () => {
const item = Object.assign({}, mockParentGroupItem, {
type: ITEM_TYPE.PROJECT,
starCount: 4,
});
const vm = createComponent(item);
vm.$mount();
const projectStarIconEl = vm.$el.querySelector('.project-stars');
expect(projectStarIconEl).toBeDefined();
expect(projectStarIconEl.querySelector('i.fa.fa-star')).toBeDefined();
expect(projectStarIconEl.innerText.trim()).toBe(`${vm.item.starCount}`);
vm.$destroy();
});
});
});

View File

@ -0,0 +1,54 @@
import Vue from 'vue';
import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
import { ITEM_TYPE } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
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();
vm.$mount();
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);
vm.$mount();
expect(vm.$el.querySelector('i.fa.fa-folder-open')).toBeDefined();
vm.$destroy();
vm = createComponent(ITEM_TYPE.GROUP);
vm.$mount();
expect(vm.$el.querySelector('i.fa.fa-folder')).toBeDefined();
vm.$destroy();
});
it('should render bookmark icon based on `isProject` prop value', () => {
let vm;
vm = createComponent(ITEM_TYPE.PROJECT);
vm.$mount();
expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(1);
vm.$destroy();
vm = createComponent(ITEM_TYPE.GROUP);
vm.$mount();
expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(0);
vm.$destroy();
});
});
});

View File

@ -1,102 +0,0 @@
import Vue from 'vue';
import groupItemComponent from '~/groups/components/group_item.vue';
import GroupsStore from '~/groups/stores/groups_store';
import { group1 } from './mock_data';
describe('Groups Component', () => {
let GroupItemComponent;
let component;
let store;
let group;
describe('group with default data', () => {
beforeEach((done) => {
GroupItemComponent = Vue.extend(groupItemComponent);
store = new GroupsStore();
group = store.decorateGroup(group1);
component = new GroupItemComponent({
propsData: {
group,
},
}).$mount();
Vue.nextTick(() => {
done();
});
});
afterEach(() => {
component.$destroy();
});
it('should render the group item correctly', () => {
expect(component.$el.classList.contains('group-row')).toBe(true);
expect(component.$el.classList.contains('.no-description')).toBe(false);
expect(component.$el.querySelector('.number-projects').textContent).toContain(group.numberProjects);
expect(component.$el.querySelector('.number-users').textContent).toContain(group.numberUsers);
expect(component.$el.querySelector('.group-visibility')).toBeDefined();
expect(component.$el.querySelector('.avatar-container')).toBeDefined();
expect(component.$el.querySelector('.title').textContent).toContain(group.name);
expect(component.$el.querySelector('.access-type').textContent).toContain(group.permissions.humanGroupAccess);
expect(component.$el.querySelector('.description').textContent).toContain(group.description);
expect(component.$el.querySelector('.edit-group')).toBeDefined();
expect(component.$el.querySelector('.leave-group')).toBeDefined();
});
});
describe('group without description', () => {
beforeEach((done) => {
GroupItemComponent = Vue.extend(groupItemComponent);
store = new GroupsStore();
group1.description = '';
group = store.decorateGroup(group1);
component = new GroupItemComponent({
propsData: {
group,
},
}).$mount();
Vue.nextTick(() => {
done();
});
});
afterEach(() => {
component.$destroy();
});
it('should render group item correctly', () => {
expect(component.$el.querySelector('.description').textContent).toBe('');
expect(component.$el.classList.contains('.no-description')).toBe(false);
});
});
describe('user has not access to group', () => {
beforeEach((done) => {
GroupItemComponent = Vue.extend(groupItemComponent);
store = new GroupsStore();
group1.permissions.human_group_access = null;
group = store.decorateGroup(group1);
component = new GroupItemComponent({
propsData: {
group,
},
}).$mount();
Vue.nextTick(() => {
done();
});
});
afterEach(() => {
component.$destroy();
});
it('should not display access type', () => {
expect(component.$el.querySelector('.access-type')).toBeNull();
});
});
});

View File

@ -1,99 +0,0 @@
import Vue from 'vue';
import eventHub from '~/groups/event_hub';
import groupFolderComponent from '~/groups/components/group_folder.vue';
import groupItemComponent from '~/groups/components/group_item.vue';
import groupsComponent from '~/groups/components/groups.vue';
import GroupsStore from '~/groups/stores/groups_store';
import { groupsData } from './mock_data';
describe('Groups Component', () => {
let GroupsComponent;
let store;
let component;
let groups;
beforeEach((done) => {
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
store = new GroupsStore();
groups = store.setGroups(groupsData.groups);
store.storePagination(groupsData.pagination);
GroupsComponent = Vue.extend(groupsComponent);
component = new GroupsComponent({
propsData: {
groups: store.state.groups,
pageInfo: store.state.pageInfo,
},
}).$mount();
Vue.nextTick(() => {
done();
});
});
afterEach(() => {
component.$destroy();
});
describe('with data', () => {
it('should render a list of groups', () => {
expect(component.$el.classList.contains('groups-list-tree-container')).toBe(true);
expect(component.$el.querySelector('#group-12')).toBeDefined();
expect(component.$el.querySelector('#group-1119')).toBeDefined();
expect(component.$el.querySelector('#group-1120')).toBeDefined();
});
it('should respect the order of groups', () => {
const wrap = component.$el.querySelector('.groups-list-tree-container > .group-list-tree');
expect(wrap.querySelector('.group-row:nth-child(1)').id).toBe('group-12');
expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119');
});
it('should render group and its subgroup', () => {
const lists = component.$el.querySelectorAll('.group-list-tree');
expect(lists.length).toBe(3); // one parent and two subgroups
expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBe(true);
expect(lists[0].querySelector('#group-1119').classList.contains('has-subgroups')).toBe(true);
expect(lists[2].querySelector('#group-1120').textContent).toContain(groups.id1119.subGroups.id1120.name);
});
it('should render group identicon when group avatar is not present', () => {
const avatar = component.$el.querySelector('#group-12 .avatar-container .avatar');
expect(avatar.nodeName).toBe('DIV');
expect(avatar.classList.contains('identicon')).toBeTruthy();
expect(avatar.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
});
it('should render group avatar when group avatar is present', () => {
const avatar = component.$el.querySelector('#group-1120 .avatar-container .avatar');
expect(avatar.nodeName).toBe('IMG');
expect(avatar.classList.contains('identicon')).toBeFalsy();
});
it('should remove prefix of parent group', () => {
expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4');
});
it('should remove the group after leaving the group', (done) => {
spyOn(window, 'confirm').and.returnValue(true);
eventHub.$on('leaveGroup', (group, collection) => {
store.removeGroup(group, collection);
});
component.$el.querySelector('#group-12 .leave-group').click();
Vue.nextTick(() => {
expect(component.$el.querySelector('#group-12')).toBeNull();
done();
});
});
});
});

View File

@ -1,114 +1,380 @@
const group1 = {
id: 12,
name: 'level1',
path: 'level1',
description: 'foo',
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.',
};
export const VISIBILITY_TYPE_ICON = {
public: 'fa-globe',
internal: 'fa-shield',
private: 'fa-lock',
};
export const mockParentGroupItem = {
id: 55,
name: 'hardware',
description: '',
visibility: 'public',
avatar_url: null,
web_url: 'http://localhost:3000/groups/level1',
group_path: '/level1',
full_name: 'level1',
full_path: 'level1',
parent_id: null,
created_at: '2017-05-15T19:01:23.670Z',
updated_at: '2017-05-15T19:01:23.670Z',
number_projects_with_delimiter: '1',
number_users_with_delimiter: '1',
has_subgroups: true,
permissions: {
human_group_access: 'Master',
},
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,
};
// This group has no direct parent, should be placed as subgroup of group1
const group14 = {
id: 1128,
name: 'level4',
path: 'level4',
description: 'foo',
visibility: 'public',
avatar_url: null,
web_url: 'http://localhost:3000/groups/level1/level2/level3/level4',
group_path: '/level1/level2/level3/level4',
full_name: 'level1 / level2 / level3 / level4',
full_path: 'level1/level2/level3/level4',
parent_id: 1127,
created_at: '2017-05-15T19:02:01.645Z',
updated_at: '2017-05-15T19:02:01.645Z',
number_projects_with_delimiter: '1',
number_users_with_delimiter: '1',
has_subgroups: true,
permissions: {
human_group_access: 'Master',
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: [],
},
];
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,
},
];
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
];
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,
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,
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,
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,
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,
},
{
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,
},
],
},
],
},
],
},
],
},
];
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,
};
const group2 = {
id: 1119,
name: 'devops',
path: 'devops',
description: 'foo',
visibility: 'public',
avatar_url: null,
web_url: 'http://localhost:3000/groups/devops',
group_path: '/devops',
full_name: 'devops',
full_path: 'devops',
parent_id: null,
created_at: '2017-05-11T19:35:09.635Z',
updated_at: '2017-05-11T19:35:09.635Z',
number_projects_with_delimiter: '1',
number_users_with_delimiter: '1',
has_subgroups: true,
permissions: {
human_group_access: 'Master',
},
export const mockPageInfo = {
perPage: 10,
page: 10,
total: 10,
totalPages: 10,
nextPage: 10,
prevPage: 10,
};
const group21 = {
id: 1120,
name: 'chef',
path: 'chef',
description: 'foo',
visibility: 'public',
avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
web_url: 'http://localhost:3000/groups/devops/chef',
group_path: '/devops/chef',
full_name: 'devops / chef',
full_path: 'devops/chef',
parent_id: 1119,
created_at: '2017-05-11T19:51:04.060Z',
updated_at: '2017-05-11T19:51:04.060Z',
number_projects_with_delimiter: '1',
number_users_with_delimiter: '1',
has_subgroups: true,
permissions: {
human_group_access: 'Master',
},
};
const groupsData = {
groups: [group1, group14, group2, group21],
pagination: {
Date: 'Mon, 22 May 2017 22:31:52 GMT',
'X-Prev-Page': '1',
'X-Content-Type-Options': 'nosniff',
'X-Total': '31',
'Transfer-Encoding': 'chunked',
'X-Runtime': '0.611144',
'X-Xss-Protection': '1; mode=block',
'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09',
'X-Ua-Compatible': 'IE=edge',
'X-Per-Page': '20',
Link: '<http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="prev", <http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="first", <http://localhost:3000/dashboard/groups.json?page=2&per_page=20>; rel="last"',
'X-Next-Page': '',
Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"',
'X-Frame-Options': 'DENY',
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'max-age=0, private, must-revalidate',
'X-Total-Pages': '2',
'X-Page': '2',
},
};
export { groupsData, group1 };

View File

@ -0,0 +1,41 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
import GroupsService from '~/groups/service/groups_service';
import { mockEndpoint, mockParentGroupItem } from '../mock_data';
Vue.use(VueResource);
describe('GroupsService', () => {
let service;
beforeEach(() => {
service = new GroupsService(mockEndpoint);
});
describe('getGroups', () => {
it('should return promise for `GET` request on provided endpoint', () => {
spyOn(service.groups, 'get').and.stub();
const queryParams = {
page: 2,
filter: 'git',
sort: 'created_asc',
};
service.getGroups(55, 2, 'git', 'created_asc');
expect(service.groups.get).toHaveBeenCalledWith({ parent_id: 55 });
service.getGroups(null, 2, 'git', 'created_asc');
expect(service.groups.get).toHaveBeenCalledWith(queryParams);
});
});
describe('leaveGroup', () => {
it('should return promise for `DELETE` request on provided endpoint', () => {
spyOn(Vue.http, 'delete').and.stub();
service.leaveGroup(mockParentGroupItem.leavePath);
expect(Vue.http.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
});
});
});

View File

@ -0,0 +1,110 @@
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();
spyOn(store, 'formatGroupItem').and.callThrough();
store.setGroups(mockGroups);
expect(store.state.groups.length).toBe(mockGroups.length);
expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
});
});
describe('setSearchedGroups', () => {
it('should set searched groups to state', () => {
const store = new GroupsStore();
spyOn(store, 'formatGroupItem').and.callThrough();
store.setSearchedGroups(mockSearchedGroups);
expect(store.state.groups.length).toBe(mockSearchedGroups.length);
expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName') > -1).toBeTruthy();
});
});
describe('setGroupChildren', () => {
it('should set children to group item in state', () => {
const store = new GroupsStore();
spyOn(store, 'formatGroupItem').and.callThrough();
store.setGroupChildren(mockParentGroupItem, mockRawChildren);
expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
expect(mockParentGroupItem.children.length).toBe(1);
expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName') > -1).toBeTruthy();
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') > -1).toBeTruthy();
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') > -1).toBeTruthy();
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 = Object.assign({}, mockGroups[0]);
const rawChildGroup = Object.assign({}, 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);
});
});
});