Groups tree enhancements for Groups Dashboard and Group Homepage
This commit is contained in:
parent
67815272dc
commit
de55396134
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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',
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ export default class GroupsService {
|
|||
}
|
||||
|
||||
if (filterGroups) {
|
||||
data.filter_groups = filterGroups;
|
||||
data.filter = filterGroups;
|
||||
}
|
||||
|
||||
if (sort) {
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 member’s permissions and access to each project in the group.
|
|
@ -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' } }
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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' } }
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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' } }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 member’s permissions and access to each project in the group.")
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue