Merge branch 'bvl-group-trees' into 'master'
Show collapsible tree on the project show page Closes #30343 See merge request gitlab-org/gitlab-ce!14055
This commit is contained in:
commit
79e889122b
99 changed files with 5086 additions and 1200 deletions
|
@ -73,6 +73,7 @@ import initProjectVisibilitySelector from './project_visibility';
|
|||
import GpgBadges from './gpg_badges';
|
||||
import UserFeatureHelper from './helpers/user_feature_helper';
|
||||
import initChangesDropdown from './init_changes_dropdown';
|
||||
import NewGroupChild from './groups/new_group_child';
|
||||
import AbuseReports from './abuse_reports';
|
||||
import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
|
||||
import AjaxLoadingSpinner from './ajax_loading_spinner';
|
||||
|
@ -392,10 +393,15 @@ import memberExpirationDate from './member_expiration_date';
|
|||
new gl.Activities();
|
||||
break;
|
||||
case 'groups:show':
|
||||
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
|
||||
shortcut_handler = new ShortcutsNavigation();
|
||||
new NotificationsForm();
|
||||
new NotificationsDropdown();
|
||||
new ProjectsList();
|
||||
|
||||
if (newGroupChildWrapper) {
|
||||
new NewGroupChild(newGroupChildWrapper);
|
||||
}
|
||||
break;
|
||||
case 'groups:group_members:index':
|
||||
memberExpirationDate();
|
||||
|
|
|
@ -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);
|
||||
|
|
194
app/assets/javascripts/groups/components/app.vue
Normal file
194
app/assets/javascripts/groups/components/app.vue
Normal file
|
@ -0,0 +1,194 @@
|
|||
<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, archived, updatePagination }) {
|
||||
return this.service.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
|
||||
.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 archived = getParameterByName('archived') || null;
|
||||
const filterGroupsBy = getParameterByName('filter') || null;
|
||||
|
||||
this.isLoading = true;
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
this.fetchGroups({
|
||||
page,
|
||||
filterGroupsBy,
|
||||
sortBy,
|
||||
archived,
|
||||
updatePagination: true,
|
||||
}).then((res) => {
|
||||
this.isLoading = false;
|
||||
this.updateGroups(res, Boolean(filterGroupsBy));
|
||||
});
|
||||
},
|
||||
fetchPage(page, filterGroupsBy, sortBy, archived) {
|
||||
this.isLoading = true;
|
||||
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
this.fetchGroups({
|
||||
page,
|
||||
filterGroupsBy,
|
||||
sortBy,
|
||||
archived,
|
||||
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,24 +4,33 @@ 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) {
|
||||
const filterGroupsParam = getParameterByName('filter_groups');
|
||||
const sortParam = getParameterByName('sort');
|
||||
eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam);
|
||||
const archivedParam = getParameterByName('archived');
|
||||
eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -29,10 +38,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"
|
||||
/>
|
||||
|
|
93
app/assets/javascripts/groups/components/item_actions.vue
Normal file
93
app/assets/javascripts/groups/components/item_actions.vue
Normal file
|
@ -0,0 +1,93 @@
|
|||
<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?')"
|
||||
:text="__('Are you sure you want to leave this group?')"
|
||||
:body="leaveConfirmationMessage"
|
||||
@submit="leaveGroup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
25
app/assets/javascripts/groups/components/item_caret.vue
Normal file
25
app/assets/javascripts/groups/components/item_caret.vue
Normal file
|
@ -0,0 +1,25 @@
|
|||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isGroupOpen: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
iconClass() {
|
||||
return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="folder-caret">
|
||||
<i
|
||||
:class="iconClass"
|
||||
class="fa"
|
||||
aria-hidden="true"/>
|
||||
</span>
|
||||
</template>
|
98
app/assets/javascripts/groups/components/item_stats.vue
Normal file
98
app/assets/javascripts/groups/components/item_stats.vue
Normal file
|
@ -0,0 +1,98 @@
|
|||
<script>
|
||||
import tooltip from '../../vue_shared/directives/tooltip';
|
||||
import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE } from '../constants';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
tooltip,
|
||||
},
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
visibilityIcon() {
|
||||
return VISIBILITY_TYPE_ICON[this.item.visibility];
|
||||
},
|
||||
visibilityTooltip() {
|
||||
if (this.item.type === ITEM_TYPE.GROUP) {
|
||||
return GROUP_VISIBILITY_TYPE[this.item.visibility];
|
||||
}
|
||||
return PROJECT_VISIBILITY_TYPE[this.item.visibility];
|
||||
},
|
||||
isProject() {
|
||||
return this.item.type === ITEM_TYPE.PROJECT;
|
||||
},
|
||||
isGroup() {
|
||||
return this.item.type === ITEM_TYPE.GROUP;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats">
|
||||
<span
|
||||
v-tooltip
|
||||
v-if="isGroup"
|
||||
:title="s__('Subgroups')"
|
||||
class="number-subgroups"
|
||||
data-placement="top"
|
||||
data-container="body">
|
||||
<i
|
||||
class="fa fa-folder"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{item.subgroupCount}}
|
||||
</span>
|
||||
<span
|
||||
v-tooltip
|
||||
v-if="isGroup"
|
||||
:title="s__('Projects')"
|
||||
class="number-projects"
|
||||
data-placement="top"
|
||||
data-container="body">
|
||||
<i
|
||||
class="fa fa-bookmark"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{item.projectCount}}
|
||||
</span>
|
||||
<span
|
||||
v-tooltip
|
||||
v-if="isGroup"
|
||||
:title="s__('Members')"
|
||||
class="number-users"
|
||||
data-placement="top"
|
||||
data-container="body">
|
||||
<i
|
||||
class="fa fa-users"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{item.memberCount}}
|
||||
</span>
|
||||
<span
|
||||
v-if="isProject"
|
||||
class="project-stars">
|
||||
<i
|
||||
class="fa fa-star"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{item.starCount}}
|
||||
</span>
|
||||
<span
|
||||
v-tooltip
|
||||
:title="visibilityTooltip"
|
||||
data-placement="left"
|
||||
data-container="body"
|
||||
class="item-visibility">
|
||||
<i
|
||||
:class="visibilityIcon"
|
||||
class="fa"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
34
app/assets/javascripts/groups/components/item_type_icon.vue
Normal file
34
app/assets/javascripts/groups/components/item_type_icon.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<script>
|
||||
import { ITEM_TYPE } from '../constants';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
itemType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isGroupOpen: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
iconClass() {
|
||||
if (this.itemType === ITEM_TYPE.GROUP) {
|
||||
return this.isGroupOpen ? 'fa-folder-open' : 'fa-folder';
|
||||
}
|
||||
return 'fa-bookmark';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="item-type-icon">
|
||||
<i
|
||||
:class="iconClass"
|
||||
class="fa"
|
||||
aria-hidden="true"/>
|
||||
</span>
|
||||
</template>
|
35
app/assets/javascripts/groups/constants.js
Normal file
35
app/assets/javascripts/groups/constants.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { __, s__ } from '../locale';
|
||||
|
||||
export const MAX_CHILDREN_COUNT = 20;
|
||||
|
||||
export const COMMON_STR = {
|
||||
FAILURE: __('An error occurred. Please try again.'),
|
||||
LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'),
|
||||
LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
|
||||
EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
|
||||
GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'),
|
||||
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'),
|
||||
};
|
||||
|
||||
export const ITEM_TYPE = {
|
||||
PROJECT: 'project',
|
||||
GROUP: 'group',
|
||||
};
|
||||
|
||||
export const GROUP_VISIBILITY_TYPE = {
|
||||
public: __('Public - The group and any public projects can be viewed without any authentication.'),
|
||||
internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'),
|
||||
private: __('Private - The group and its projects can only be viewed by members.'),
|
||||
};
|
||||
|
||||
export const PROJECT_VISIBILITY_TYPE = {
|
||||
public: __('Public - The project can be accessed without any authentication.'),
|
||||
internal: __('Internal - The project can be accessed by any logged in user.'),
|
||||
private: __('Private - Project access must be granted explicitly to each user.'),
|
||||
};
|
||||
|
||||
export const VISIBILITY_TYPE_ICON = {
|
||||
public: 'fa-globe',
|
||||
internal: 'fa-shield',
|
||||
private: 'fa-lock',
|
||||
};
|
|
@ -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() {
|
||||
|
@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList {
|
|||
bindEvents() {
|
||||
super.bindEvents();
|
||||
|
||||
this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
|
||||
this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
|
||||
|
||||
this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
|
||||
this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
|
||||
}
|
||||
|
||||
onFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const $form = $(this.form);
|
||||
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
|
||||
onFilterInput() {
|
||||
const queryData = {};
|
||||
const $form = $(this.form);
|
||||
const archivedParam = getParameterByName('archived', window.location.href);
|
||||
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
|
||||
|
||||
if (filterGroupsParam) {
|
||||
queryData.filter_groups = filterGroupsParam;
|
||||
queryData[this.filterInputField] = filterGroupsParam;
|
||||
}
|
||||
|
||||
if (archivedParam) {
|
||||
queryData.archived = archivedParam;
|
||||
}
|
||||
|
||||
this.filterResults(queryData);
|
||||
this.setDefaultFilterOption();
|
||||
|
||||
if (this.setDefaultFilterOption) {
|
||||
this.setDefaultFilterOption();
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultFilterOption() {
|
||||
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
|
||||
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text());
|
||||
this.$dropdown.find('.dropdown-label').text(defaultOption);
|
||||
}
|
||||
|
||||
|
@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList {
|
|||
e.preventDefault();
|
||||
|
||||
const queryData = {};
|
||||
const sortParam = getParameterByName('sort', e.currentTarget.href);
|
||||
|
||||
// Get type of option selected from dropdown
|
||||
const currentTargetClassList = e.currentTarget.parentElement.classList;
|
||||
const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order');
|
||||
const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects');
|
||||
|
||||
// Get option query param, also preserve currently applied query param
|
||||
const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href);
|
||||
const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href);
|
||||
|
||||
if (sortParam) {
|
||||
queryData.sort = sortParam;
|
||||
}
|
||||
|
||||
if (archivedParam) {
|
||||
queryData.archived = archivedParam;
|
||||
}
|
||||
|
||||
this.filterResults(queryData);
|
||||
|
||||
// Active selected option
|
||||
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
|
||||
if (isOptionFilterBySort) {
|
||||
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
|
||||
this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active');
|
||||
} else if (isOptionFilterByArchivedProjects) {
|
||||
this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects 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 +106,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,16 +1,17 @@
|
|||
import Vue from 'vue';
|
||||
import Flash from '../flash';
|
||||
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 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');
|
||||
|
||||
// Don't do anything if element doesn't exist (No groups)
|
||||
// This is for when the user enters directly to the page via URL
|
||||
|
@ -18,176 +19,56 @@ 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);
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
62
app/assets/javascripts/groups/new_group_child.js
Normal file
62
app/assets/javascripts/groups/new_group_child.js
Normal file
|
@ -0,0 +1,62 @@
|
|||
import DropLab from '../droplab/drop_lab';
|
||||
import ISetter from '../droplab/plugins/input_setter';
|
||||
|
||||
const InputSetter = Object.assign({}, ISetter);
|
||||
|
||||
const NEW_PROJECT = 'new-project';
|
||||
const NEW_SUBGROUP = 'new-subgroup';
|
||||
|
||||
export default class NewGroupChild {
|
||||
constructor(buttonWrapper) {
|
||||
this.buttonWrapper = buttonWrapper;
|
||||
this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child');
|
||||
this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle');
|
||||
this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu');
|
||||
|
||||
this.newGroupPath = this.buttonWrapper.dataset.projectPath;
|
||||
this.subgroupPath = this.buttonWrapper.dataset.subgroupPath;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initDroplab();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
initDroplab() {
|
||||
this.droplab = new DropLab();
|
||||
this.droplab.init(
|
||||
this.dropdownToggle,
|
||||
this.dropdownList,
|
||||
[InputSetter],
|
||||
this.getDroplabConfig(),
|
||||
);
|
||||
}
|
||||
|
||||
getDroplabConfig() {
|
||||
return {
|
||||
InputSetter: [{
|
||||
input: this.newGroupChildButton,
|
||||
valueAttribute: 'data-value',
|
||||
inputAttribute: 'data-action',
|
||||
}, {
|
||||
input: this.newGroupChildButton,
|
||||
valueAttribute: 'data-text',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.newGroupChildButton
|
||||
.addEventListener('click', this.onClickNewGroupChildButton.bind(this));
|
||||
}
|
||||
|
||||
onClickNewGroupChildButton(e) {
|
||||
if (e.target.dataset.action === NEW_PROJECT) {
|
||||
gl.utils.visitUrl(this.newGroupPath);
|
||||
} else if (e.target.dataset.action === NEW_SUBGROUP) {
|
||||
gl.utils.visitUrl(this.subgroupPath);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ export default class GroupsService {
|
|||
this.groups = Vue.resource(endpoint);
|
||||
}
|
||||
|
||||
getGroups(parentId, page, filterGroups, sort) {
|
||||
getGroups(parentId, page, filterGroups, sort, archived) {
|
||||
const data = {};
|
||||
|
||||
if (parentId) {
|
||||
|
@ -20,12 +20,16 @@ export default class GroupsService {
|
|||
}
|
||||
|
||||
if (filterGroups) {
|
||||
data.filter_groups = filterGroups;
|
||||
data.filter = filterGroups;
|
||||
}
|
||||
|
||||
if (sort) {
|
||||
data.sort = sort;
|
||||
}
|
||||
|
||||
if (archived) {
|
||||
data.archived = archived;
|
||||
}
|
||||
}
|
||||
|
||||
return this.groups.get(data);
|
105
app/assets/javascripts/groups/store/groups_store.js
Normal file
105
app/assets/javascripts/groups/store/groups_store.js
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
|
||||
|
||||
export default class GroupsStore {
|
||||
constructor(hideProjects) {
|
||||
this.state = {};
|
||||
this.state.groups = [];
|
||||
this.state.pageInfo = {};
|
||||
this.hideProjects = hideProjects;
|
||||
}
|
||||
|
||||
setGroups(rawGroups) {
|
||||
if (rawGroups && rawGroups.length) {
|
||||
this.state.groups = rawGroups.map(rawGroup => this.formatGroupItem(rawGroup));
|
||||
} else {
|
||||
this.state.groups = [];
|
||||
}
|
||||
}
|
||||
|
||||
setSearchedGroups(rawGroups) {
|
||||
const formatGroups = groups => groups.map((group) => {
|
||||
const formattedGroup = this.formatGroupItem(group);
|
||||
if (formattedGroup.children && formattedGroup.children.length) {
|
||||
formattedGroup.children = formatGroups(formattedGroup.children);
|
||||
}
|
||||
return formattedGroup;
|
||||
});
|
||||
|
||||
if (rawGroups && rawGroups.length) {
|
||||
this.state.groups = formatGroups(rawGroups);
|
||||
} else {
|
||||
this.state.groups = [];
|
||||
}
|
||||
}
|
||||
|
||||
setGroupChildren(parentGroup, children) {
|
||||
const updatedParentGroup = parentGroup;
|
||||
updatedParentGroup.children = children.map(rawChild => this.formatGroupItem(rawChild));
|
||||
updatedParentGroup.isOpen = true;
|
||||
updatedParentGroup.isChildrenLoading = false;
|
||||
}
|
||||
|
||||
getGroups() {
|
||||
return this.state.groups;
|
||||
}
|
||||
|
||||
setPaginationInfo(pagination = {}) {
|
||||
let paginationInfo;
|
||||
|
||||
if (Object.keys(pagination).length) {
|
||||
const normalizedHeaders = normalizeHeaders(pagination);
|
||||
paginationInfo = parseIntPagination(normalizedHeaders);
|
||||
} else {
|
||||
paginationInfo = pagination;
|
||||
}
|
||||
|
||||
this.state.pageInfo = paginationInfo;
|
||||
}
|
||||
|
||||
getPaginationInfo() {
|
||||
return this.state.pageInfo;
|
||||
}
|
||||
|
||||
formatGroupItem(rawGroupItem) {
|
||||
const groupChildren = rawGroupItem.children || [];
|
||||
const groupIsOpen = (groupChildren.length > 0) || false;
|
||||
const childrenCount = this.hideProjects ?
|
||||
rawGroupItem.subgroup_count :
|
||||
rawGroupItem.children_count;
|
||||
|
||||
return {
|
||||
id: rawGroupItem.id,
|
||||
name: rawGroupItem.name,
|
||||
fullName: rawGroupItem.full_name,
|
||||
description: rawGroupItem.description,
|
||||
visibility: rawGroupItem.visibility,
|
||||
avatarUrl: rawGroupItem.avatar_url,
|
||||
relativePath: rawGroupItem.relative_path,
|
||||
editPath: rawGroupItem.edit_path,
|
||||
leavePath: rawGroupItem.leave_path,
|
||||
canEdit: rawGroupItem.can_edit,
|
||||
canLeave: rawGroupItem.can_leave,
|
||||
type: rawGroupItem.type,
|
||||
permission: rawGroupItem.permission,
|
||||
children: groupChildren,
|
||||
isOpen: groupIsOpen,
|
||||
isChildrenLoading: false,
|
||||
isBeingRemoved: false,
|
||||
parentId: rawGroupItem.parent_id,
|
||||
childrenCount,
|
||||
projectCount: rawGroupItem.project_count,
|
||||
subgroupCount: rawGroupItem.subgroup_count,
|
||||
memberCount: rawGroupItem.number_users_with_delimiter,
|
||||
starCount: rawGroupItem.star_count,
|
||||
};
|
||||
}
|
||||
|
||||
removeGroup(group, parentGroup) {
|
||||
const updatedParentGroup = parentGroup;
|
||||
if (updatedParentGroup.children && updatedParentGroup.children.length) {
|
||||
updatedParentGroup.children = parentGroup.children.filter(child => group.id !== child.id);
|
||||
} else {
|
||||
this.state.groups = this.state.groups.filter(child => group.id !== child.id);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
24
app/controllers/concerns/group_tree.rb
Normal file
24
app/controllers/concerns/group_tree.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
module GroupTree
|
||||
def render_group_tree(groups)
|
||||
@groups = if params[:filter].present?
|
||||
Gitlab::GroupHierarchy.new(groups.search(params[:filter]))
|
||||
.base_and_ancestors
|
||||
else
|
||||
# Only show root groups if no parent-id is given
|
||||
groups.where(parent_id: params[:parent_id])
|
||||
end
|
||||
@groups = @groups.with_selects_for_list(archived: params[:archived])
|
||||
.sort(@sort = params[:sort])
|
||||
.page(params[:page])
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json do
|
||||
serializer = GroupChildSerializer.new(current_user: current_user)
|
||||
.with_pagination(request, response)
|
||||
serializer.expand_hierarchy if params[:filter].present?
|
||||
render json: serializer.represent(@groups)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,33 +1,8 @@
|
|||
class Dashboard::GroupsController < Dashboard::ApplicationController
|
||||
include GroupTree
|
||||
|
||||
def index
|
||||
@sort = params[:sort] || 'created_desc'
|
||||
|
||||
@groups =
|
||||
if params[:parent_id] && Group.supports_nested_groups?
|
||||
parent = Group.find_by(id: params[:parent_id])
|
||||
|
||||
if can?(current_user, :read_group, parent)
|
||||
GroupsFinder.new(current_user, parent: parent).execute
|
||||
else
|
||||
Group.none
|
||||
end
|
||||
else
|
||||
current_user.groups
|
||||
end
|
||||
|
||||
@groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
|
||||
@groups = @groups.includes(:route)
|
||||
@groups = @groups.sort(@sort)
|
||||
@groups = @groups.page(params[:page])
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json do
|
||||
render json: GroupSerializer
|
||||
.new(current_user: @current_user)
|
||||
.with_pagination(request, response)
|
||||
.represent(@groups)
|
||||
end
|
||||
end
|
||||
groups = GroupsFinder.new(current_user, all_available: false).execute
|
||||
render_group_tree(groups)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,17 +1,7 @@
|
|||
class Explore::GroupsController < Explore::ApplicationController
|
||||
def index
|
||||
@groups = GroupsFinder.new(current_user).execute
|
||||
@groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
|
||||
@groups = @groups.sort(@sort = params[:sort])
|
||||
@groups = @groups.page(params[:page])
|
||||
include GroupTree
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json do
|
||||
render json: {
|
||||
html: view_to_html_string("explore/groups/_groups", locals: { groups: @groups })
|
||||
}
|
||||
end
|
||||
end
|
||||
def index
|
||||
render_group_tree GroupsFinder.new(current_user).execute
|
||||
end
|
||||
end
|
||||
|
|
39
app/controllers/groups/children_controller.rb
Normal file
39
app/controllers/groups/children_controller.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
module Groups
|
||||
class ChildrenController < Groups::ApplicationController
|
||||
before_action :group
|
||||
|
||||
def index
|
||||
parent = if params[:parent_id].present?
|
||||
GroupFinder.new(current_user).execute(id: params[:parent_id])
|
||||
else
|
||||
@group
|
||||
end
|
||||
|
||||
if parent.nil?
|
||||
render_404
|
||||
return
|
||||
end
|
||||
|
||||
setup_children(parent)
|
||||
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
serializer = GroupChildSerializer
|
||||
.new(current_user: current_user)
|
||||
.with_pagination(request, response)
|
||||
serializer.expand_hierarchy(parent) if params[:filter].present?
|
||||
render json: serializer.represent(@children)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def setup_children(parent)
|
||||
@children = GroupDescendantsFinder.new(current_user: current_user,
|
||||
parent_group: parent,
|
||||
params: params).execute
|
||||
@children = @children.page(params[:page])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -46,15 +46,11 @@ class GroupsController < Groups::ApplicationController
|
|||
end
|
||||
|
||||
def show
|
||||
setup_projects
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
|
||||
format.json do
|
||||
render json: {
|
||||
html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
|
||||
}
|
||||
format.html do
|
||||
@has_children = GroupDescendantsFinder.new(current_user: current_user,
|
||||
parent_group: @group,
|
||||
params: params).has_children?
|
||||
end
|
||||
|
||||
format.atom do
|
||||
|
@ -64,13 +60,6 @@ class GroupsController < Groups::ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def subgroups
|
||||
return not_found unless Group.supports_nested_groups?
|
||||
|
||||
@nested_groups = GroupsFinder.new(current_user, parent: group).execute
|
||||
@nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present?
|
||||
end
|
||||
|
||||
def activity
|
||||
respond_to do |format|
|
||||
format.html
|
||||
|
@ -107,20 +96,6 @@ class GroupsController < Groups::ApplicationController
|
|||
|
||||
protected
|
||||
|
||||
def setup_projects
|
||||
set_non_archived_param
|
||||
params[:sort] ||= 'latest_activity_desc'
|
||||
@sort = params[:sort]
|
||||
|
||||
options = {}
|
||||
options[:only_owned] = true if params[:shared] == '0'
|
||||
options[:only_shared] = true if params[:shared] == '1'
|
||||
|
||||
@projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user).execute
|
||||
@projects = @projects.includes(:namespace)
|
||||
@projects = @projects.page(params[:page]) if params[:name].blank?
|
||||
end
|
||||
|
||||
def authorize_create_group!
|
||||
allowed = if params[:parent_id].present?
|
||||
parent = Group.find_by(id: params[:parent_id])
|
||||
|
|
153
app/finders/group_descendants_finder.rb
Normal file
153
app/finders/group_descendants_finder.rb
Normal file
|
@ -0,0 +1,153 @@
|
|||
# GroupDescendantsFinder
|
||||
#
|
||||
# Used to find and filter all subgroups and projects of a passed parent group
|
||||
# visible to a specified user.
|
||||
#
|
||||
# When passing a `filter` param, the search is performed over all nested levels
|
||||
# of the `parent_group`. All ancestors for a search result are loaded
|
||||
#
|
||||
# Arguments:
|
||||
# current_user: The user for which the children should be visible
|
||||
# parent_group: The group to find children of
|
||||
# params:
|
||||
# Supports all params that the `ProjectsFinder` and `GroupProjectsFinder`
|
||||
# support.
|
||||
#
|
||||
# filter: string - is aliased to `search` for consistency with the frontend
|
||||
# archived: string - `only` or `true`.
|
||||
# `non_archived` is passed to the `ProjectFinder`s if none
|
||||
# was given.
|
||||
class GroupDescendantsFinder
|
||||
attr_reader :current_user, :parent_group, :params
|
||||
|
||||
def initialize(current_user: nil, parent_group:, params: {})
|
||||
@current_user = current_user
|
||||
@parent_group = parent_group
|
||||
@params = params.reverse_merge(non_archived: params[:archived].blank?)
|
||||
end
|
||||
|
||||
def execute
|
||||
# The children array might be extended with the ancestors of projects when
|
||||
# filtering. In that case, take the maximum so the array does not get limited
|
||||
# Otherwise, allow paginating through all results
|
||||
#
|
||||
all_required_elements = children
|
||||
all_required_elements |= ancestors_for_projects if params[:filter]
|
||||
total_count = [all_required_elements.size, paginator.total_count].max
|
||||
|
||||
Kaminari.paginate_array(all_required_elements, total_count: total_count)
|
||||
end
|
||||
|
||||
def has_children?
|
||||
projects.any? || subgroups.any?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def children
|
||||
@children ||= paginator.paginate(params[:page])
|
||||
end
|
||||
|
||||
def paginator
|
||||
@paginator ||= Gitlab::MultiCollectionPaginator.new(subgroups, projects,
|
||||
per_page: params[:per_page])
|
||||
end
|
||||
|
||||
def direct_child_groups
|
||||
GroupsFinder.new(current_user,
|
||||
parent: parent_group,
|
||||
all_available: true).execute
|
||||
end
|
||||
|
||||
def all_visible_descendant_groups
|
||||
groups_table = Group.arel_table
|
||||
visible_to_user = groups_table[:visibility_level]
|
||||
.in(Gitlab::VisibilityLevel.levels_for_user(current_user))
|
||||
if current_user
|
||||
authorized_groups = GroupsFinder.new(current_user,
|
||||
all_available: false)
|
||||
.execute.as('authorized')
|
||||
authorized_to_user = groups_table.project(1).from(authorized_groups)
|
||||
.where(authorized_groups[:id].eq(groups_table[:id]))
|
||||
.exists
|
||||
visible_to_user = visible_to_user.or(authorized_to_user)
|
||||
end
|
||||
|
||||
hierarchy_for_parent
|
||||
.descendants
|
||||
.where(visible_to_user)
|
||||
end
|
||||
|
||||
def subgroups_matching_filter
|
||||
all_visible_descendant_groups
|
||||
.search(params[:filter])
|
||||
end
|
||||
|
||||
# When filtering we want all to preload all the ancestors upto the specified
|
||||
# parent group.
|
||||
#
|
||||
# - root
|
||||
# - subgroup
|
||||
# - nested-group
|
||||
# - project
|
||||
#
|
||||
# So when searching 'project', on the 'subgroup' page we want to preload
|
||||
# 'nested-group' but not 'subgroup' or 'root'
|
||||
def ancestors_for_groups(base_for_ancestors)
|
||||
Gitlab::GroupHierarchy.new(base_for_ancestors)
|
||||
.base_and_ancestors(upto: parent_group.id)
|
||||
end
|
||||
|
||||
def ancestors_for_projects
|
||||
projects_to_load_ancestors_of = projects.where.not(namespace: parent_group)
|
||||
groups_to_load_ancestors_of = Group.where(id: projects_to_load_ancestors_of.select(:namespace_id))
|
||||
ancestors_for_groups(groups_to_load_ancestors_of)
|
||||
.with_selects_for_list(archived: params[:archived])
|
||||
end
|
||||
|
||||
def subgroups
|
||||
return Group.none unless Group.supports_nested_groups?
|
||||
|
||||
# When filtering subgroups, we want to find all matches withing the tree of
|
||||
# descendants to show to the user
|
||||
groups = if params[:filter]
|
||||
ancestors_for_groups(subgroups_matching_filter)
|
||||
else
|
||||
direct_child_groups
|
||||
end
|
||||
groups.with_selects_for_list(archived: params[:archived]).order_by(sort)
|
||||
end
|
||||
|
||||
def direct_child_projects
|
||||
GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
|
||||
.execute
|
||||
end
|
||||
|
||||
# Finds all projects nested under `parent_group` or any of its descendant
|
||||
# groups
|
||||
def projects_matching_filter
|
||||
projects_nested_in_group = Project.where(namespace_id: hierarchy_for_parent.base_and_descendants.select(:id))
|
||||
params_with_search = params.merge(search: params[:filter])
|
||||
|
||||
ProjectsFinder.new(params: params_with_search,
|
||||
current_user: current_user,
|
||||
project_ids_relation: projects_nested_in_group).execute
|
||||
end
|
||||
|
||||
def projects
|
||||
projects = if params[:filter]
|
||||
projects_matching_filter
|
||||
else
|
||||
direct_child_projects
|
||||
end
|
||||
projects.with_route.order_by(sort)
|
||||
end
|
||||
|
||||
def sort
|
||||
params.fetch(:sort, 'id_asc')
|
||||
end
|
||||
|
||||
def hierarchy_for_parent
|
||||
@hierarchy ||= Gitlab::GroupHierarchy.new(Group.where(id: parent_group.id))
|
||||
end
|
||||
end
|
|
@ -34,7 +34,6 @@ class GroupProjectsFinder < ProjectsFinder
|
|||
else
|
||||
collection_without_user
|
||||
end
|
||||
|
||||
union(projects)
|
||||
end
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
56
app/models/concerns/group_descendant.rb
Normal file
56
app/models/concerns/group_descendant.rb
Normal file
|
@ -0,0 +1,56 @@
|
|||
module GroupDescendant
|
||||
# Returns the hierarchy of a project or group in the from of a hash upto a
|
||||
# given top.
|
||||
#
|
||||
# > project.hierarchy
|
||||
# => { parent_group => { child_group => project } }
|
||||
def hierarchy(hierarchy_top = nil, preloaded = nil)
|
||||
preloaded ||= ancestors_upto(hierarchy_top)
|
||||
expand_hierarchy_for_child(self, self, hierarchy_top, preloaded)
|
||||
end
|
||||
|
||||
# Merges all hierarchies of the given groups or projects into an array of
|
||||
# hashes. All ancestors need to be loaded into the given `descendants` to avoid
|
||||
# queries down the line.
|
||||
#
|
||||
# > GroupDescendant.merge_hierarchy([project, child_group, child_group2, parent])
|
||||
# => { parent => [{ child_group => project}, child_group2] }
|
||||
def self.build_hierarchy(descendants, hierarchy_top = nil)
|
||||
descendants = Array.wrap(descendants).uniq
|
||||
return [] if descendants.empty?
|
||||
|
||||
unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) }
|
||||
raise ArgumentError.new('element is not a hierarchy')
|
||||
end
|
||||
|
||||
all_hierarchies = descendants.map do |descendant|
|
||||
descendant.hierarchy(hierarchy_top, descendants)
|
||||
end
|
||||
|
||||
Gitlab::Utils::MergeHash.merge(all_hierarchies)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def expand_hierarchy_for_child(child, hierarchy, hierarchy_top, preloaded)
|
||||
parent = hierarchy_top if hierarchy_top && child.parent_id == hierarchy_top.id
|
||||
parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id }
|
||||
|
||||
if parent.nil? && !child.parent_id.nil?
|
||||
raise ArgumentError.new('parent was not preloaded')
|
||||
end
|
||||
|
||||
if parent.nil? && hierarchy_top.present?
|
||||
raise ArgumentError.new('specified top is not part of the tree')
|
||||
end
|
||||
|
||||
if parent && parent != hierarchy_top
|
||||
expand_hierarchy_for_child(parent,
|
||||
{ parent => hierarchy },
|
||||
hierarchy_top,
|
||||
preloaded)
|
||||
else
|
||||
hierarchy
|
||||
end
|
||||
end
|
||||
end
|
72
app/models/concerns/loaded_in_group_list.rb
Normal file
72
app/models/concerns/loaded_in_group_list.rb
Normal file
|
@ -0,0 +1,72 @@
|
|||
module LoadedInGroupList
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
module ClassMethods
|
||||
def with_counts(archived:)
|
||||
selects_including_counts = [
|
||||
'namespaces.*',
|
||||
"(#{project_count_sql(archived).to_sql}) AS preloaded_project_count",
|
||||
"(#{member_count_sql.to_sql}) AS preloaded_member_count",
|
||||
"(#{subgroup_count_sql.to_sql}) AS preloaded_subgroup_count"
|
||||
]
|
||||
|
||||
select(selects_including_counts)
|
||||
end
|
||||
|
||||
def with_selects_for_list(archived: nil)
|
||||
with_route.with_counts(archived: archived)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def project_count_sql(archived = nil)
|
||||
projects = Project.arel_table
|
||||
namespaces = Namespace.arel_table
|
||||
|
||||
base_count = projects.project(Arel.star.count.as('preloaded_project_count'))
|
||||
.where(projects[:namespace_id].eq(namespaces[:id]))
|
||||
if archived == 'only'
|
||||
base_count.where(projects[:archived].eq(true))
|
||||
elsif Gitlab::Utils.to_boolean(archived)
|
||||
base_count
|
||||
else
|
||||
base_count.where(projects[:archived].not_eq(true))
|
||||
end
|
||||
end
|
||||
|
||||
def subgroup_count_sql
|
||||
namespaces = Namespace.arel_table
|
||||
children = namespaces.alias('children')
|
||||
|
||||
namespaces.project(Arel.star.count.as('preloaded_subgroup_count'))
|
||||
.from(children)
|
||||
.where(children[:parent_id].eq(namespaces[:id]))
|
||||
end
|
||||
|
||||
def member_count_sql
|
||||
members = Member.arel_table
|
||||
namespaces = Namespace.arel_table
|
||||
|
||||
members.project(Arel.star.count.as('preloaded_member_count'))
|
||||
.where(members[:source_type].eq(Namespace.name))
|
||||
.where(members[:source_id].eq(namespaces[:id]))
|
||||
.where(members[:requested_at].eq(nil))
|
||||
end
|
||||
end
|
||||
|
||||
def children_count
|
||||
@children_count ||= project_count + subgroup_count
|
||||
end
|
||||
|
||||
def project_count
|
||||
@project_count ||= try(:preloaded_project_count) || projects.non_archived.count
|
||||
end
|
||||
|
||||
def subgroup_count
|
||||
@subgroup_count ||= try(:preloaded_subgroup_count) || children.count
|
||||
end
|
||||
|
||||
def member_count
|
||||
@member_count ||= try(:preloaded_member_count) || users.count
|
||||
end
|
||||
end
|
|
@ -6,6 +6,8 @@ class Group < Namespace
|
|||
include Avatarable
|
||||
include Referable
|
||||
include SelectForProjectAuthorization
|
||||
include LoadedInGroupList
|
||||
include GroupDescendant
|
||||
|
||||
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
|
||||
alias_method :members, :group_members
|
||||
|
|
|
@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base
|
|||
.base_and_ancestors
|
||||
end
|
||||
|
||||
# returns all ancestors upto but excluding the the given namespace
|
||||
# when no namespace is given, all ancestors upto the top are returned
|
||||
def ancestors_upto(top = nil)
|
||||
Gitlab::GroupHierarchy.new(self.class.where(id: id))
|
||||
.ancestors(upto: top)
|
||||
end
|
||||
|
||||
def self_and_ancestors
|
||||
return self.class.where(id: id) unless parent_id
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ class Project < ActiveRecord::Base
|
|||
include ProjectFeaturesCompatibility
|
||||
include SelectForProjectAuthorization
|
||||
include Routable
|
||||
include GroupDescendant
|
||||
|
||||
extend Gitlab::ConfigHelper
|
||||
extend Gitlab::CurrentSettings
|
||||
|
@ -81,6 +82,8 @@ class Project < ActiveRecord::Base
|
|||
belongs_to :creator, class_name: 'User'
|
||||
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
|
||||
belongs_to :namespace
|
||||
alias_method :parent, :namespace
|
||||
alias_attribute :parent_id, :namespace_id
|
||||
|
||||
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
|
||||
has_many :boards, before_add: :validate_board_limit
|
||||
|
@ -479,6 +482,13 @@ class Project < ActiveRecord::Base
|
|||
end
|
||||
end
|
||||
|
||||
# returns all ancestor-groups upto but excluding the given namespace
|
||||
# when no namespace is given, all ancestors upto the top are returned
|
||||
def ancestors_upto(top = nil)
|
||||
Gitlab::GroupHierarchy.new(Group.where(id: namespace_id))
|
||||
.base_and_ancestors(upto: top)
|
||||
end
|
||||
|
||||
def lfs_enabled?
|
||||
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
|
||||
|
||||
|
@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base
|
|||
map.public_path_for_source_path(path)
|
||||
end
|
||||
|
||||
def parent
|
||||
namespace
|
||||
end
|
||||
|
||||
def parent_changed?
|
||||
namespace_id_changed?
|
||||
end
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
class BaseSerializer
|
||||
def initialize(parameters = {})
|
||||
@request = EntityRequest.new(parameters)
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params = {})
|
||||
@params = params
|
||||
@request = EntityRequest.new(params)
|
||||
end
|
||||
|
||||
def represent(resource, opts = {}, entity_class = nil)
|
||||
|
|
22
app/serializers/concerns/with_pagination.rb
Normal file
22
app/serializers/concerns/with_pagination.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
module WithPagination
|
||||
attr_accessor :paginator
|
||||
|
||||
def with_pagination(request, response)
|
||||
tap { self.paginator = Gitlab::Serializer::Pagination.new(request, response) }
|
||||
end
|
||||
|
||||
def paginated?
|
||||
paginator.present?
|
||||
end
|
||||
|
||||
# super is `BaseSerializer#represent` here.
|
||||
#
|
||||
# we shouldn't try to paginate single resources
|
||||
def represent(resource, opts = {})
|
||||
if paginated? && resource.respond_to?(:page)
|
||||
super(@paginator.paginate(resource), opts)
|
||||
else
|
||||
super(resource, opts)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,4 +1,6 @@
|
|||
class EnvironmentSerializer < BaseSerializer
|
||||
include WithPagination
|
||||
|
||||
Item = Struct.new(:name, :size, :latest)
|
||||
|
||||
entity EnvironmentEntity
|
||||
|
@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer
|
|||
tap { @itemize = true }
|
||||
end
|
||||
|
||||
def with_pagination(request, response)
|
||||
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
|
||||
end
|
||||
|
||||
def itemized?
|
||||
@itemize
|
||||
end
|
||||
|
||||
def paginated?
|
||||
@paginator.present?
|
||||
end
|
||||
|
||||
def represent(resource, opts = {})
|
||||
if itemized?
|
||||
itemize(resource).map do |item|
|
||||
|
@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer
|
|||
latest: super(item.latest, opts) }
|
||||
end
|
||||
else
|
||||
resource = @paginator.paginate(resource) if paginated?
|
||||
|
||||
super(resource, opts)
|
||||
end
|
||||
end
|
||||
|
|
77
app/serializers/group_child_entity.rb
Normal file
77
app/serializers/group_child_entity.rb
Normal file
|
@ -0,0 +1,77 @@
|
|||
class GroupChildEntity < Grape::Entity
|
||||
include ActionView::Helpers::NumberHelper
|
||||
include RequestAwareEntity
|
||||
|
||||
expose :id, :name, :description, :visibility, :full_name,
|
||||
:created_at, :updated_at, :avatar_url
|
||||
|
||||
expose :type do |instance|
|
||||
type
|
||||
end
|
||||
|
||||
expose :can_edit do |instance|
|
||||
return false unless request.respond_to?(:current_user)
|
||||
|
||||
can?(request.current_user, "admin_#{type}", instance)
|
||||
end
|
||||
|
||||
expose :edit_path do |instance|
|
||||
# We know `type` will be one either `project` or `group`.
|
||||
# The `edit_polymorphic_path` helper would try to call the path helper
|
||||
# with a plural: `edit_groups_path(instance)` or `edit_projects_path(instance)`
|
||||
# while our methods are `edit_group_path` or `edit_group_path`
|
||||
public_send("edit_#{type}_path", instance) # rubocop:disable GitlabSecurity/PublicSend
|
||||
end
|
||||
|
||||
expose :relative_path do |instance|
|
||||
polymorphic_path(instance)
|
||||
end
|
||||
|
||||
expose :permission do |instance|
|
||||
membership&.human_access
|
||||
end
|
||||
|
||||
# Project only attributes
|
||||
expose :star_count,
|
||||
if: lambda { |_instance, _options| project? }
|
||||
|
||||
# Group only attributes
|
||||
expose :children_count, :parent_id, :project_count, :subgroup_count,
|
||||
unless: lambda { |_instance, _options| project? }
|
||||
|
||||
expose :leave_path, unless: lambda { |_instance, _options| project? } do |instance|
|
||||
leave_group_members_path(instance)
|
||||
end
|
||||
|
||||
expose :can_leave, unless: lambda { |_instance, _options| project? } do |instance|
|
||||
if membership
|
||||
can?(request.current_user, :destroy_group_member, membership)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
expose :number_projects_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
|
||||
number_with_delimiter(instance.project_count)
|
||||
end
|
||||
|
||||
expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
|
||||
number_with_delimiter(instance.member_count)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def membership
|
||||
return unless request.current_user
|
||||
|
||||
@membership ||= request.current_user.members.find_by(source: object)
|
||||
end
|
||||
|
||||
def project?
|
||||
object.is_a?(Project)
|
||||
end
|
||||
|
||||
def type
|
||||
object.class.name.downcase
|
||||
end
|
||||
end
|
51
app/serializers/group_child_serializer.rb
Normal file
51
app/serializers/group_child_serializer.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
class GroupChildSerializer < BaseSerializer
|
||||
include WithPagination
|
||||
|
||||
attr_reader :hierarchy_root, :should_expand_hierarchy
|
||||
|
||||
entity GroupChildEntity
|
||||
|
||||
def expand_hierarchy(hierarchy_root = nil)
|
||||
@hierarchy_root = hierarchy_root
|
||||
@should_expand_hierarchy = true
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def represent(resource, opts = {}, entity_class = nil)
|
||||
if should_expand_hierarchy
|
||||
paginator.paginate(resource) if paginated?
|
||||
represent_hierarchies(resource, opts)
|
||||
else
|
||||
super(resource, opts)
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def represent_hierarchies(children, opts)
|
||||
if children.is_a?(GroupDescendant)
|
||||
represent_hierarchy(children.hierarchy(hierarchy_root), opts).first
|
||||
else
|
||||
hierarchies = GroupDescendant.build_hierarchy(children, hierarchy_root)
|
||||
# When an array was passed, we always want to represent an array.
|
||||
# Even if the hierarchy only contains one element
|
||||
represent_hierarchy(Array.wrap(hierarchies), opts)
|
||||
end
|
||||
end
|
||||
|
||||
def represent_hierarchy(hierarchy, opts)
|
||||
serializer = self.class.new(params)
|
||||
|
||||
if hierarchy.is_a?(Hash)
|
||||
hierarchy.map do |parent, children|
|
||||
serializer.represent(parent, opts)
|
||||
.merge(children: Array.wrap(serializer.represent_hierarchy(children, opts)))
|
||||
end
|
||||
elsif hierarchy.is_a?(Array)
|
||||
hierarchy.flat_map { |child| serializer.represent_hierarchy(child, opts) }
|
||||
else
|
||||
serializer.represent(hierarchy, opts)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,19 +1,5 @@
|
|||
class GroupSerializer < BaseSerializer
|
||||
include WithPagination
|
||||
|
||||
entity GroupEntity
|
||||
|
||||
def with_pagination(request, response)
|
||||
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
|
||||
end
|
||||
|
||||
def paginated?
|
||||
@paginator.present?
|
||||
end
|
||||
|
||||
def represent(resource, opts = {})
|
||||
if paginated?
|
||||
super(@paginator.paginate(resource), opts)
|
||||
else
|
||||
super(resource, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,16 +1,10 @@
|
|||
class PipelineSerializer < BaseSerializer
|
||||
include WithPagination
|
||||
|
||||
InvalidResourceError = Class.new(StandardError)
|
||||
|
||||
entity PipelineDetailsEntity
|
||||
|
||||
def with_pagination(request, response)
|
||||
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
|
||||
end
|
||||
|
||||
def paginated?
|
||||
@paginator.present?
|
||||
end
|
||||
|
||||
def represent(resource, opts = {})
|
||||
if resource.is_a?(ActiveRecord::Relation)
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
5
app/views/groups/_children.html.haml
Normal file
5
app/views/groups/_children.html.haml
Normal file
|
@ -0,0 +1,5 @@
|
|||
= 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' } }
|
|
@ -1,8 +0,0 @@
|
|||
%ul.nav-links
|
||||
= nav_link(page: group_path(@group)) do
|
||||
= link_to group_path(@group) do
|
||||
Projects
|
||||
- if Group.supports_nested_groups?
|
||||
= nav_link(page: subgroups_group_path(@group)) do
|
||||
= link_to subgroups_group_path(@group) do
|
||||
Subgroups
|
|
@ -1,5 +1,6 @@
|
|||
- @no_container = true
|
||||
- breadcrumb_title "Details"
|
||||
- can_create_subgroups = can?(current_user, :create_subgroup, @group)
|
||||
|
||||
= content_for :meta_tags do
|
||||
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
|
||||
|
@ -7,13 +8,38 @@
|
|||
= render 'groups/home_panel'
|
||||
|
||||
.groups-header{ class: container_class }
|
||||
.top-area
|
||||
= render 'groups/show_nav'
|
||||
.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", show_archive_options: true
|
||||
- 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")
|
||||
- if can_create_subgroups
|
||||
.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 a project in 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 in this group.")
|
||||
- else
|
||||
= link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
|
||||
|
||||
= render "projects", projects: @projects
|
||||
- if params[:filter].blank? && !@has_children
|
||||
= render "shared/groups/empty_state"
|
||||
- else
|
||||
= render "children", children: @children, group: @group
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
- breadcrumb_title "Details"
|
||||
- @no_container = true
|
||||
|
||||
= render 'groups/home_panel'
|
||||
|
||||
.groups-header{ class: container_class }
|
||||
.top-area
|
||||
= render 'groups/show_nav'
|
||||
.nav-controls
|
||||
= form_tag request.path, method: :get do |f|
|
||||
= search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false
|
||||
- if can?(current_user, :create_subgroup, @group)
|
||||
= link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do
|
||||
New Subgroup
|
||||
|
||||
- if @nested_groups.present?
|
||||
%ul.content-list
|
||||
= render partial: 'shared/groups/group', collection: @nested_groups, locals: { full_name: false }
|
||||
- else
|
||||
.nothing-here-block
|
||||
There are no subgroups to show.
|
|
@ -1,18 +1,32 @@
|
|||
.dropdown.inline.js-group-filter-dropdown-wrap
|
||||
- show_archive_options = local_assigns.fetch(:show_archive_options, false)
|
||||
- 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.js-filter-sort-order
|
||||
= link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do
|
||||
= title
|
||||
- if show_archive_options
|
||||
%li.divider
|
||||
%li.js-filter-archived-projects
|
||||
= link_to group_children_path(@group, archived: nil), class: ("is-active" unless params[:archived].present?) do
|
||||
Hide archived projects
|
||||
%li.js-filter-archived-projects
|
||||
= link_to group_children_path(@group, archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
|
||||
Show archived projects
|
||||
%li.js-filter-archived-projects
|
||||
= link_to group_children_path(@group, archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
|
||||
Show archived projects only
|
||||
|
|
7
app/views/shared/groups/_empty_state.html.haml
Normal file
7
app/views/shared/groups/_empty_state.html.haml
Normal file
|
@ -0,0 +1,7 @@
|
|||
.groups-empty-state
|
||||
= custom_icon("icon_empty_groups")
|
||||
|
||||
.text-content
|
||||
%h4= s_("GroupsEmptyState|A group is a collection of several projects.")
|
||||
%p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
|
||||
%p= s_("GroupsEmptyState|You can manage your group 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
|
||||
|
|
5
changelogs/unreleased/bvl-group-trees.yml
Normal file
5
changelogs/unreleased/bvl-group-trees.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Show collapsible project lists
|
||||
merge_request: 14055
|
||||
author:
|
||||
type: changed
|
|
@ -29,6 +29,7 @@ module Gitlab
|
|||
#{config.root}/app/models/project_services
|
||||
#{config.root}/app/workers/concerns
|
||||
#{config.root}/app/services/concerns
|
||||
#{config.root}/app/serializers/concerns
|
||||
#{config.root}/app/finders/concerns])
|
||||
|
||||
config.generators.templates.push("#{config.root}/generator_templates")
|
||||
|
|
|
@ -32,6 +32,8 @@ scope(path: 'groups/*group_id',
|
|||
end
|
||||
|
||||
resources :variables, only: [:index, :show, :update, :create, :destroy]
|
||||
|
||||
resources :children, only: [:index]
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -43,7 +45,6 @@ scope(path: 'groups/*id',
|
|||
get :merge_requests, as: :merge_requests_group
|
||||
get :projects, as: :projects_group
|
||||
get :activity, as: :activity_group
|
||||
get :subgroups, as: :subgroups_group
|
||||
get '/', action: :show, as: :group_canonical
|
||||
end
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ Feature: Explore Groups
|
|||
Background:
|
||||
Given group "TestGroup" has private project "Enterprise"
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group with private and internal projects as user
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
When I sign in as a user
|
||||
|
@ -10,6 +11,7 @@ Feature: Explore Groups
|
|||
Then I should see project "Internal" items
|
||||
And I should not see project "Enterprise" items
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group issues for internal project as user
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
When I sign in as a user
|
||||
|
@ -17,6 +19,7 @@ Feature: Explore Groups
|
|||
Then I should see project "Internal" items
|
||||
And I should not see project "Enterprise" items
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group merge requests for internal project as user
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
When I sign in as a user
|
||||
|
@ -24,6 +27,7 @@ Feature: Explore Groups
|
|||
Then I should see project "Internal" items
|
||||
And I should not see project "Enterprise" items
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group with private, internal and public projects as visitor
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
Given group "TestGroup" has public project "Community"
|
||||
|
@ -32,6 +36,7 @@ Feature: Explore Groups
|
|||
And I should not see project "Internal" items
|
||||
And I should not see project "Enterprise" items
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group issues for public project as visitor
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
Given group "TestGroup" has public project "Community"
|
||||
|
@ -40,6 +45,7 @@ Feature: Explore Groups
|
|||
And I should not see project "Internal" items
|
||||
And I should not see project "Enterprise" items
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group merge requests for public project as visitor
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
Given group "TestGroup" has public project "Community"
|
||||
|
@ -48,6 +54,7 @@ Feature: Explore Groups
|
|||
And I should not see project "Internal" items
|
||||
And I should not see project "Enterprise" items
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group with private, internal and public projects as user
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
Given group "TestGroup" has public project "Community"
|
||||
|
@ -57,6 +64,7 @@ Feature: Explore Groups
|
|||
And I should see project "Internal" items
|
||||
And I should not see project "Enterprise" items
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group issues for internal and public projects as user
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
Given group "TestGroup" has public project "Community"
|
||||
|
@ -66,6 +74,7 @@ Feature: Explore Groups
|
|||
And I should see project "Internal" items
|
||||
And I should not see project "Enterprise" items
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group merge requests for internal and public projects as user
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
Given group "TestGroup" has public project "Community"
|
||||
|
@ -75,17 +84,20 @@ Feature: Explore Groups
|
|||
And I should see project "Internal" items
|
||||
And I should not see project "Enterprise" items
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group with public project in public groups area
|
||||
Given group "TestGroup" has public project "Community"
|
||||
When I visit the public groups area
|
||||
Then I should see group "TestGroup"
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group with public project in public groups area as user
|
||||
Given group "TestGroup" has public project "Community"
|
||||
When I sign in as a user
|
||||
And I visit the public groups area
|
||||
Then I should see group "TestGroup"
|
||||
|
||||
@javascript
|
||||
Scenario: I should see group with internal project in public groups area as user
|
||||
Given group "TestGroup" has internal project "Internal"
|
||||
When I sign in as a user
|
||||
|
|
|
@ -17,12 +17,32 @@ module Gitlab
|
|||
@model = ancestors_base.model
|
||||
end
|
||||
|
||||
# Returns the set of descendants of a given relation, but excluding the given
|
||||
# relation
|
||||
def descendants
|
||||
base_and_descendants.where.not(id: descendants_base.select(:id))
|
||||
end
|
||||
|
||||
# Returns the set of ancestors of a given relation, but excluding the given
|
||||
# relation
|
||||
#
|
||||
# Passing an `upto` will stop the recursion once the specified parent_id is
|
||||
# reached. So all ancestors *lower* than the specified ancestor will be
|
||||
# included.
|
||||
def ancestors(upto: nil)
|
||||
base_and_ancestors(upto: upto).where.not(id: ancestors_base.select(:id))
|
||||
end
|
||||
|
||||
# Returns a relation that includes the ancestors_base set of groups
|
||||
# and all their ancestors (recursively).
|
||||
def base_and_ancestors
|
||||
#
|
||||
# Passing an `upto` will stop the recursion once the specified parent_id is
|
||||
# reached. So all ancestors *lower* than the specified acestor will be
|
||||
# included.
|
||||
def base_and_ancestors(upto: nil)
|
||||
return ancestors_base unless Group.supports_nested_groups?
|
||||
|
||||
read_only(base_and_ancestors_cte.apply_to(model.all))
|
||||
read_only(base_and_ancestors_cte(upto).apply_to(model.all))
|
||||
end
|
||||
|
||||
# Returns a relation that includes the descendants_base set of groups
|
||||
|
@ -78,17 +98,19 @@ module Gitlab
|
|||
|
||||
private
|
||||
|
||||
def base_and_ancestors_cte
|
||||
def base_and_ancestors_cte(stop_id = nil)
|
||||
cte = SQL::RecursiveCTE.new(:base_and_ancestors)
|
||||
|
||||
cte << ancestors_base.except(:order)
|
||||
|
||||
# Recursively get all the ancestors of the base set.
|
||||
cte << model
|
||||
parent_query = model
|
||||
.from([groups_table, cte.table])
|
||||
.where(groups_table[:id].eq(cte.table[:parent_id]))
|
||||
.except(:order)
|
||||
parent_query = parent_query.where(cte.table[:parent_id].not_eq(stop_id)) if stop_id
|
||||
|
||||
cte << parent_query
|
||||
cte
|
||||
end
|
||||
|
||||
|
|
61
lib/gitlab/multi_collection_paginator.rb
Normal file
61
lib/gitlab/multi_collection_paginator.rb
Normal file
|
@ -0,0 +1,61 @@
|
|||
module Gitlab
|
||||
class MultiCollectionPaginator
|
||||
attr_reader :first_collection, :second_collection, :per_page
|
||||
|
||||
def initialize(*collections, per_page: nil)
|
||||
raise ArgumentError.new('Only 2 collections are supported') if collections.size != 2
|
||||
|
||||
@per_page = per_page || Kaminari.config.default_per_page
|
||||
@first_collection, @second_collection = collections
|
||||
end
|
||||
|
||||
def paginate(page)
|
||||
page = page.to_i
|
||||
paginated_first_collection(page) + paginated_second_collection(page)
|
||||
end
|
||||
|
||||
def total_count
|
||||
@total_count ||= first_collection.size + second_collection.size
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def paginated_first_collection(page)
|
||||
@first_collection_pages ||= Hash.new do |hash, page|
|
||||
hash[page] = first_collection.page(page).per(per_page)
|
||||
end
|
||||
|
||||
@first_collection_pages[page]
|
||||
end
|
||||
|
||||
def paginated_second_collection(page)
|
||||
@second_collection_pages ||= Hash.new do |hash, page|
|
||||
second_collection_page = page - first_collection_page_count
|
||||
|
||||
offset = if second_collection_page < 1 || first_collection_page_count.zero?
|
||||
0
|
||||
else
|
||||
per_page - first_collection_last_page_size
|
||||
end
|
||||
hash[page] = second_collection.page(second_collection_page)
|
||||
.per(per_page - paginated_first_collection(page).size)
|
||||
.padding(offset)
|
||||
end
|
||||
|
||||
@second_collection_pages[page]
|
||||
end
|
||||
|
||||
def first_collection_page_count
|
||||
return @first_collection_page_count if defined?(@first_collection_page_count)
|
||||
|
||||
first_collection_page = paginated_first_collection(0)
|
||||
@first_collection_page_count = first_collection_page.total_pages
|
||||
end
|
||||
|
||||
def first_collection_last_page_size
|
||||
return @first_collection_last_page_size if defined?(@first_collection_last_page_size)
|
||||
|
||||
@first_collection_last_page_size = paginated_first_collection(first_collection_page_count).count
|
||||
end
|
||||
end
|
||||
end
|
|
@ -128,7 +128,6 @@ module Gitlab
|
|||
notification_setting
|
||||
pipeline_quota
|
||||
projects
|
||||
subgroups
|
||||
].freeze
|
||||
|
||||
ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES
|
||||
|
|
|
@ -26,7 +26,11 @@ module Gitlab
|
|||
@relations.map { |rel| rel.reorder(nil).to_sql }.reject(&:blank?)
|
||||
end
|
||||
|
||||
fragments.join("\n#{union_keyword}\n")
|
||||
if fragments.any?
|
||||
fragments.join("\n#{union_keyword}\n")
|
||||
else
|
||||
'NULL'
|
||||
end
|
||||
end
|
||||
|
||||
def union_keyword
|
||||
|
|
117
lib/gitlab/utils/merge_hash.rb
Normal file
117
lib/gitlab/utils/merge_hash.rb
Normal file
|
@ -0,0 +1,117 @@
|
|||
module Gitlab
|
||||
module Utils
|
||||
module MergeHash
|
||||
extend self
|
||||
# Deep merges an array of hashes
|
||||
#
|
||||
# [{ hello: ["world"] },
|
||||
# { hello: "Everyone" },
|
||||
# { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] } },
|
||||
# "Goodbye", "Hallo"]
|
||||
# => [
|
||||
# {
|
||||
# hello:
|
||||
# [
|
||||
# "world",
|
||||
# "Everyone",
|
||||
# { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] }
|
||||
# ]
|
||||
# },
|
||||
# "Goodbye"
|
||||
# ]
|
||||
def merge(elements)
|
||||
merged, *other_elements = elements
|
||||
|
||||
other_elements.each do |element|
|
||||
merged = merge_hash_tree(merged, element)
|
||||
end
|
||||
|
||||
merged
|
||||
end
|
||||
|
||||
# This extracts all keys and values from a hash into an array
|
||||
#
|
||||
# { hello: "world", this: { crushes: ["an entire", "hash"] } }
|
||||
# => [:hello, "world", :this, :crushes, "an entire", "hash"]
|
||||
def crush(array_or_hash)
|
||||
if array_or_hash.is_a?(Array)
|
||||
crush_array(array_or_hash)
|
||||
else
|
||||
crush_hash(array_or_hash)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def merge_hash_into_array(array, new_hash)
|
||||
crushed_new_hash = crush_hash(new_hash)
|
||||
# Merge the hash into an existing element of the array if there is overlap
|
||||
if mergeable_index = array.index { |element| crushable?(element) && (crush(element) & crushed_new_hash).any? }
|
||||
array[mergeable_index] = merge_hash_tree(array[mergeable_index], new_hash)
|
||||
else
|
||||
array << new_hash
|
||||
end
|
||||
|
||||
array
|
||||
end
|
||||
|
||||
def merge_hash_tree(first_element, second_element)
|
||||
# If one of the elements is an object, and the other is a Hash or Array
|
||||
# we can check if the object is already included. If so, we don't need to do anything
|
||||
#
|
||||
# Handled cases
|
||||
# [Hash, Object], [Array, Object]
|
||||
if crushable?(first_element) && crush(first_element).include?(second_element)
|
||||
first_element
|
||||
elsif crushable?(second_element) && crush(second_element).include?(first_element)
|
||||
second_element
|
||||
# When the first is an array, we need to go over every element to see if
|
||||
# we can merge deeper. If no match is found, we add the element to the array
|
||||
#
|
||||
# Handled cases:
|
||||
# [Array, Hash]
|
||||
elsif first_element.is_a?(Array) && second_element.is_a?(Hash)
|
||||
merge_hash_into_array(first_element, second_element)
|
||||
elsif first_element.is_a?(Hash) && second_element.is_a?(Array)
|
||||
merge_hash_into_array(second_element, first_element)
|
||||
# If both of them are hashes, we can deep_merge with the same logic
|
||||
#
|
||||
# Handled cases:
|
||||
# [Hash, Hash]
|
||||
elsif first_element.is_a?(Hash) && second_element.is_a?(Hash)
|
||||
first_element.deep_merge(second_element) { |key, first, second| merge_hash_tree(first, second) }
|
||||
# If both elements are arrays, we try to merge each element separatly
|
||||
#
|
||||
# Handled cases
|
||||
# [Array, Array]
|
||||
elsif first_element.is_a?(Array) && second_element.is_a?(Array)
|
||||
first_element.map { |child_element| merge_hash_tree(child_element, second_element) }
|
||||
# If one or both elements are a GroupDescendant, we wrap create an array
|
||||
# combining them.
|
||||
#
|
||||
# Handled cases:
|
||||
# [Object, Object], [Array, Array]
|
||||
else
|
||||
(Array.wrap(first_element) + Array.wrap(second_element)).uniq
|
||||
end
|
||||
end
|
||||
|
||||
def crushable?(element)
|
||||
element.is_a?(Hash) || element.is_a?(Array)
|
||||
end
|
||||
|
||||
def crush_hash(hash)
|
||||
hash.flat_map do |key, value|
|
||||
crushed_value = crushable?(value) ? crush(value) : value
|
||||
Array.wrap(key) + Array.wrap(crushed_value)
|
||||
end
|
||||
end
|
||||
|
||||
def crush_array(array)
|
||||
array.flat_map do |element|
|
||||
crushable?(element) ? crush(element) : element
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -8,8 +8,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: gitlab 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2017-10-06 18:33+0200\n"
|
||||
"PO-Revision-Date: 2017-10-06 18:33+0200\n"
|
||||
"POT-Creation-Date: 2017-10-10 17:50+0200\n"
|
||||
"PO-Revision-Date: 2017-10-10 17:50+0200\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
|
@ -109,6 +109,9 @@ msgstr ""
|
|||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
msgid "An error occurred. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Appearance"
|
||||
msgstr ""
|
||||
|
||||
|
@ -375,6 +378,9 @@ msgstr ""
|
|||
msgid "Clone repository"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cluster"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
|
||||
msgstr ""
|
||||
|
||||
|
@ -420,13 +426,10 @@ msgstr ""
|
|||
msgid "ClusterIntegration|Google Container Engine project"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Google Container Engine"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|See machine types"
|
||||
msgid "ClusterIntegration|Machine type"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
|
||||
|
@ -438,9 +441,15 @@ msgstr ""
|
|||
msgid "ClusterIntegration|Number of nodes"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Project namespace (optional, unique)"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Remove cluster integration"
|
||||
msgstr ""
|
||||
|
||||
|
@ -450,7 +459,10 @@ msgstr ""
|
|||
msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Save changes"
|
||||
msgid "ClusterIntegration|Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|See machine types"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|See your projects"
|
||||
|
@ -462,18 +474,12 @@ msgstr ""
|
|||
msgid "ClusterIntegration|Something went wrong on our end."
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine."
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
|
||||
msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Toggle Cluster"
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
|
||||
msgstr ""
|
||||
|
||||
msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
|
||||
msgstr ""
|
||||
|
||||
|
@ -691,6 +697,9 @@ msgstr ""
|
|||
msgid "Discard changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Dismiss Cycle Analytics introduction box"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't show again"
|
||||
msgstr ""
|
||||
|
||||
|
@ -760,6 +769,9 @@ msgstr ""
|
|||
msgid "Explore projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "Explore public groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to change the owner"
|
||||
msgstr ""
|
||||
|
||||
|
@ -846,6 +858,51 @@ msgstr ""
|
|||
msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsEmptyState|A group is a collection of several projects."
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsEmptyState|If you organize your projects under a group, it works like a folder."
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsEmptyState|No groups found"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTreeRole|as"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Are you sure you want to leave the \"${this.group.fullName}\" group?"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Create a project in this group."
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Create a subgroup in this group."
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Edit group"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Filter by name..."
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Leave this group"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Loading groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Sorry, no groups matched your search"
|
||||
msgstr ""
|
||||
|
||||
msgid "GroupsTree|Sorry, no groups or projects matched your search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Health Check"
|
||||
msgstr ""
|
||||
|
||||
|
@ -876,6 +933,12 @@ msgstr ""
|
|||
msgid "Install a Runner compatible with GitLab CI"
|
||||
msgstr ""
|
||||
|
||||
msgid "Internal - The group and any internal projects can be viewed by any logged in user."
|
||||
msgstr ""
|
||||
|
||||
msgid "Internal - The project can be accessed by any logged in user."
|
||||
msgstr ""
|
||||
|
||||
msgid "Interval Pattern"
|
||||
msgstr ""
|
||||
|
||||
|
@ -935,6 +998,9 @@ msgstr ""
|
|||
msgid "Learn more in the|pipeline schedules documentation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Leave"
|
||||
msgstr ""
|
||||
|
||||
msgid "Leave group"
|
||||
msgstr ""
|
||||
|
||||
|
@ -946,6 +1012,15 @@ msgid_plural "Limited to showing %d events at most"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "Lock"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locked"
|
||||
msgstr ""
|
||||
|
||||
msgid "Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Median"
|
||||
msgstr ""
|
||||
|
||||
|
@ -973,6 +1048,9 @@ msgstr ""
|
|||
msgid "More information is available|here"
|
||||
msgstr ""
|
||||
|
||||
msgid "New Cluster"
|
||||
msgstr ""
|
||||
|
||||
msgid "New Issue"
|
||||
msgid_plural "New Issues"
|
||||
msgstr[0] ""
|
||||
|
@ -990,18 +1068,27 @@ msgstr ""
|
|||
msgid "New file"
|
||||
msgstr ""
|
||||
|
||||
msgid "New group"
|
||||
msgstr ""
|
||||
|
||||
msgid "New issue"
|
||||
msgstr ""
|
||||
|
||||
msgid "New merge request"
|
||||
msgstr ""
|
||||
|
||||
msgid "New project"
|
||||
msgstr ""
|
||||
|
||||
msgid "New schedule"
|
||||
msgstr ""
|
||||
|
||||
msgid "New snippet"
|
||||
msgstr ""
|
||||
|
||||
msgid "New subgroup"
|
||||
msgstr ""
|
||||
|
||||
msgid "New tag"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1080,6 +1167,9 @@ msgstr ""
|
|||
msgid "OfSearchInADropdown|Filter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only project members can comment."
|
||||
msgstr ""
|
||||
|
||||
msgid "OpenedNDaysAgo|Opened"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1110,6 +1200,9 @@ msgstr ""
|
|||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
msgid "People without permission will never get a notification and won\\'t be able to comment."
|
||||
msgstr ""
|
||||
|
||||
msgid "Pipeline"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1209,9 +1302,51 @@ msgstr ""
|
|||
msgid "Preferences"
|
||||
msgstr ""
|
||||
|
||||
msgid "Private - Project access must be granted explicitly to each user."
|
||||
msgstr ""
|
||||
|
||||
msgid "Private - The group and its projects can only be viewed by members."
|
||||
msgstr ""
|
||||
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|Account scheduled for removal."
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|Delete Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|Delete account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|Delete your account?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|Deleting an account has the following effects:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|Invalid password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|Invalid username"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|Type your %{confirmationValue} to confirm:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|You don't have access to delete this user."
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|You must transfer ownership or delete these groups before you can delete your account."
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|Your account is currently an owner in these groups:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profiles|your account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Project '%{project_name}' queued for deletion."
|
||||
msgstr ""
|
||||
|
||||
|
@ -1266,6 +1401,9 @@ msgstr ""
|
|||
msgid "ProjectNetworkGraph|Graph"
|
||||
msgstr ""
|
||||
|
||||
msgid "Projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectsDropdown|Frequently visited"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1287,6 +1425,12 @@ msgstr ""
|
|||
msgid "ProjectsDropdown|This feature requires browser localStorage support"
|
||||
msgstr ""
|
||||
|
||||
msgid "Public - The group and any public projects can be viewed without any authentication."
|
||||
msgstr ""
|
||||
|
||||
msgid "Public - The project can be accessed without any authentication."
|
||||
msgstr ""
|
||||
|
||||
msgid "Push events"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1415,12 +1559,18 @@ msgstr ""
|
|||
msgid "Something went wrong on our end."
|
||||
msgstr ""
|
||||
|
||||
msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Something went wrong while fetching the projects."
|
||||
msgstr ""
|
||||
|
||||
msgid "Something went wrong while fetching the registry list."
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "SortOptions|Access level, ascending"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1529,6 +1679,9 @@ msgstr ""
|
|||
msgid "Start the Runner!"
|
||||
msgstr ""
|
||||
|
||||
msgid "Subgroups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Switch branch/tag"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1600,12 +1753,24 @@ msgstr ""
|
|||
msgid "There are problems accessing Git storage: "
|
||||
msgstr ""
|
||||
|
||||
msgid "This is a confidential issue."
|
||||
msgstr ""
|
||||
|
||||
msgid "This is the author's first Merge Request to this project."
|
||||
msgstr ""
|
||||
|
||||
msgid "This issue is confidential and locked."
|
||||
msgstr ""
|
||||
|
||||
msgid "This issue is locked."
|
||||
msgstr ""
|
||||
|
||||
msgid "This means you can not push code until you create an empty repository or import existing one."
|
||||
msgstr ""
|
||||
|
||||
msgid "This merge request is locked."
|
||||
msgstr ""
|
||||
|
||||
msgid "Time before an issue gets scheduled"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1760,6 +1925,12 @@ msgstr ""
|
|||
msgid "Total test time for all commits/merges"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unlock"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unlocked"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unstar"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1922,9 +2093,15 @@ msgstr ""
|
|||
msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
|
||||
msgstr ""
|
||||
|
||||
msgid "You are on a read-only GitLab instance."
|
||||
msgstr ""
|
||||
|
||||
msgid "You can only add files when you are on a branch"
|
||||
msgstr ""
|
||||
|
||||
msgid "You cannot write to this read-only GitLab instance."
|
||||
msgstr ""
|
||||
|
||||
msgid "You have reached your project limit"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1955,6 +2132,12 @@ msgstr ""
|
|||
msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
|
||||
msgstr ""
|
||||
|
||||
msgid "Your comment will not be visible to the public."
|
||||
msgstr ""
|
||||
|
||||
msgid "Your groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Your name"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1977,5 +2160,11 @@ msgid_plural "parents"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "password"
|
||||
msgstr ""
|
||||
|
||||
msgid "personal access token"
|
||||
msgstr ""
|
||||
|
||||
msgid "username"
|
||||
msgstr ""
|
||||
|
|
89
spec/controllers/concerns/group_tree_spec.rb
Normal file
89
spec/controllers/concerns/group_tree_spec.rb
Normal file
|
@ -0,0 +1,89 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe GroupTree do
|
||||
let(:group) { create(:group, :public) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
controller(ApplicationController) do
|
||||
# `described_class` is not available in this context
|
||||
include GroupTree # rubocop:disable RSpec/DescribedClass
|
||||
|
||||
def index
|
||||
render_group_tree GroupsFinder.new(current_user).execute
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
group.add_owner(user)
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
it 'filters groups' do
|
||||
other_group = create(:group, name: 'filter')
|
||||
other_group.add_owner(user)
|
||||
|
||||
get :index, filter: 'filt', format: :json
|
||||
|
||||
expect(assigns(:groups)).to contain_exactly(other_group)
|
||||
end
|
||||
|
||||
context 'for subgroups', :nested_groups do
|
||||
it 'only renders root groups when no parent was given' do
|
||||
create(:group, :public, parent: group)
|
||||
|
||||
get :index, format: :json
|
||||
|
||||
expect(assigns(:groups)).to contain_exactly(group)
|
||||
end
|
||||
|
||||
it 'contains only the subgroup when a parent was given' do
|
||||
subgroup = create(:group, :public, parent: group)
|
||||
|
||||
get :index, parent_id: group.id, format: :json
|
||||
|
||||
expect(assigns(:groups)).to contain_exactly(subgroup)
|
||||
end
|
||||
|
||||
it 'allows filtering for subgroups and includes the parents for rendering' do
|
||||
subgroup = create(:group, :public, parent: group, name: 'filter')
|
||||
|
||||
get :index, filter: 'filt', format: :json
|
||||
|
||||
expect(assigns(:groups)).to contain_exactly(group, subgroup)
|
||||
end
|
||||
|
||||
it 'does not include groups the user does not have access to' do
|
||||
parent = create(:group, :private)
|
||||
subgroup = create(:group, :private, parent: parent, name: 'filter')
|
||||
subgroup.add_developer(user)
|
||||
_other_subgroup = create(:group, :private, parent: parent, name: 'filte')
|
||||
|
||||
get :index, filter: 'filt', format: :json
|
||||
|
||||
expect(assigns(:groups)).to contain_exactly(parent, subgroup)
|
||||
end
|
||||
end
|
||||
|
||||
context 'json content' do
|
||||
it 'shows groups as json' do
|
||||
get :index, format: :json
|
||||
|
||||
expect(json_response.first['id']).to eq(group.id)
|
||||
end
|
||||
|
||||
context 'nested groups', :nested_groups do
|
||||
it 'expands the tree when filtering' do
|
||||
subgroup = create(:group, :public, parent: group, name: 'filter')
|
||||
|
||||
get :index, filter: 'filt', format: :json
|
||||
|
||||
children_response = json_response.first['children']
|
||||
|
||||
expect(json_response.first['id']).to eq(group.id)
|
||||
expect(children_response.first['id']).to eq(subgroup.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
23
spec/controllers/dashboard/groups_controller_spec.rb
Normal file
23
spec/controllers/dashboard/groups_controller_spec.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Dashboard::GroupsController do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'renders group trees' do
|
||||
expect(described_class).to include(GroupTree)
|
||||
end
|
||||
|
||||
it 'only includes projects the user is a member of' do
|
||||
member_of_group = create(:group)
|
||||
member_of_group.add_developer(user)
|
||||
create(:group, :public)
|
||||
|
||||
get :index
|
||||
|
||||
expect(assigns(:groups)).to contain_exactly(member_of_group)
|
||||
end
|
||||
end
|
23
spec/controllers/explore/groups_controller_spec.rb
Normal file
23
spec/controllers/explore/groups_controller_spec.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Explore::GroupsController do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'renders group trees' do
|
||||
expect(described_class).to include(GroupTree)
|
||||
end
|
||||
|
||||
it 'includes public projects' do
|
||||
member_of_group = create(:group)
|
||||
member_of_group.add_developer(user)
|
||||
public_group = create(:group, :public)
|
||||
|
||||
get :index
|
||||
|
||||
expect(assigns(:groups)).to contain_exactly(member_of_group, public_group)
|
||||
end
|
||||
end
|
286
spec/controllers/groups/children_controller_spec.rb
Normal file
286
spec/controllers/groups/children_controller_spec.rb
Normal file
|
@ -0,0 +1,286 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Groups::ChildrenController do
|
||||
let(:group) { create(:group, :public) }
|
||||
let(:user) { create(:user) }
|
||||
let!(:group_member) { create(:group_member, group: group, user: user) }
|
||||
|
||||
describe 'GET #index' do
|
||||
context 'for projects' do
|
||||
let!(:public_project) { create(:project, :public, namespace: group) }
|
||||
let!(:private_project) { create(:project, :private, namespace: group) }
|
||||
|
||||
context 'as a user' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'shows all children' do
|
||||
get :index, group_id: group.to_param, format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(public_project, private_project)
|
||||
end
|
||||
|
||||
context 'being member of private subgroup' do
|
||||
it 'shows public and private children the user is member of' do
|
||||
group_member.destroy!
|
||||
private_project.add_guest(user)
|
||||
|
||||
get :index, group_id: group.to_param, format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(public_project, private_project)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'as a guest' do
|
||||
it 'shows the public children' do
|
||||
get :index, group_id: group.to_param, format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(public_project)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for subgroups', :nested_groups do
|
||||
let!(:public_subgroup) { create(:group, :public, parent: group) }
|
||||
let!(:private_subgroup) { create(:group, :private, parent: group) }
|
||||
let!(:public_project) { create(:project, :public, namespace: group) }
|
||||
let!(:private_project) { create(:project, :private, namespace: group) }
|
||||
|
||||
context 'as a user' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'shows all children' do
|
||||
get :index, group_id: group.to_param, format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
|
||||
end
|
||||
|
||||
context 'being member of private subgroup' do
|
||||
it 'shows public and private children the user is member of' do
|
||||
group_member.destroy!
|
||||
private_subgroup.add_guest(user)
|
||||
private_project.add_guest(user)
|
||||
|
||||
get :index, group_id: group.to_param, format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'as a guest' do
|
||||
it 'shows the public children' do
|
||||
get :index, group_id: group.to_param, format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(public_subgroup, public_project)
|
||||
end
|
||||
end
|
||||
|
||||
context 'filtering children' do
|
||||
it 'expands the tree for matching projects' do
|
||||
project = create(:project, :public, namespace: public_subgroup, name: 'filterme')
|
||||
|
||||
get :index, group_id: group.to_param, filter: 'filter', format: :json
|
||||
|
||||
group_json = json_response.first
|
||||
project_json = group_json['children'].first
|
||||
|
||||
expect(group_json['id']).to eq(public_subgroup.id)
|
||||
expect(project_json['id']).to eq(project.id)
|
||||
end
|
||||
|
||||
it 'expands the tree for matching subgroups' do
|
||||
matched_group = create(:group, :public, parent: public_subgroup, name: 'filterme')
|
||||
|
||||
get :index, group_id: group.to_param, filter: 'filter', format: :json
|
||||
|
||||
group_json = json_response.first
|
||||
matched_group_json = group_json['children'].first
|
||||
|
||||
expect(group_json['id']).to eq(public_subgroup.id)
|
||||
expect(matched_group_json['id']).to eq(matched_group.id)
|
||||
end
|
||||
|
||||
it 'merges the trees correctly' do
|
||||
shared_subgroup = create(:group, :public, parent: group, path: 'hardware')
|
||||
matched_project_1 = create(:project, :public, namespace: shared_subgroup, name: 'mobile-soc')
|
||||
|
||||
l2_subgroup = create(:group, :public, parent: shared_subgroup, path: 'broadcom')
|
||||
l3_subgroup = create(:group, :public, parent: l2_subgroup, path: 'wifi-group')
|
||||
matched_project_2 = create(:project, :public, namespace: l3_subgroup, name: 'mobile')
|
||||
|
||||
get :index, group_id: group.to_param, filter: 'mobile', format: :json
|
||||
|
||||
shared_group_json = json_response.first
|
||||
expect(shared_group_json['id']).to eq(shared_subgroup.id)
|
||||
|
||||
matched_project_1_json = shared_group_json['children'].detect { |child| child['type'] == 'project' }
|
||||
expect(matched_project_1_json['id']).to eq(matched_project_1.id)
|
||||
|
||||
l2_subgroup_json = shared_group_json['children'].detect { |child| child['type'] == 'group' }
|
||||
expect(l2_subgroup_json['id']).to eq(l2_subgroup.id)
|
||||
|
||||
l3_subgroup_json = l2_subgroup_json['children'].first
|
||||
expect(l3_subgroup_json['id']).to eq(l3_subgroup.id)
|
||||
|
||||
matched_project_2_json = l3_subgroup_json['children'].first
|
||||
expect(matched_project_2_json['id']).to eq(matched_project_2.id)
|
||||
end
|
||||
|
||||
it 'expands the tree upto a specified parent' do
|
||||
subgroup = create(:group, :public, parent: group)
|
||||
l2_subgroup = create(:group, :public, parent: subgroup)
|
||||
create(:project, :public, namespace: l2_subgroup, name: 'test')
|
||||
|
||||
get :index, group_id: subgroup.to_param, filter: 'test', format: :json
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'returns an array with one element when only one result is matched' do
|
||||
create(:project, :public, namespace: group, name: 'match')
|
||||
|
||||
get :index, group_id: group.to_param, filter: 'match', format: :json
|
||||
|
||||
expect(json_response).to be_kind_of(Array)
|
||||
expect(json_response.size).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns an empty array when there are no search results' do
|
||||
subgroup = create(:group, :public, parent: group)
|
||||
l2_subgroup = create(:group, :public, parent: subgroup)
|
||||
create(:project, :public, namespace: l2_subgroup, name: 'no-match')
|
||||
|
||||
get :index, group_id: subgroup.to_param, filter: 'test', format: :json
|
||||
|
||||
expect(json_response).to eq([])
|
||||
end
|
||||
|
||||
it 'includes pagination headers' do
|
||||
2.times { |i| create(:group, :public, parent: public_subgroup, name: "filterme#{i}") }
|
||||
|
||||
get :index, group_id: group.to_param, filter: 'filter', per_page: 1, format: :json
|
||||
|
||||
expect(response).to include_pagination_headers
|
||||
end
|
||||
end
|
||||
|
||||
context 'queries per rendered element', :request_store do
|
||||
# We need to make sure the following counts are preloaded
|
||||
# otherwise they will cause an extra query
|
||||
# 1. Count of visible projects in the element
|
||||
# 2. Count of visible subgroups in the element
|
||||
# 3. Count of members of a group
|
||||
let(:expected_queries_per_group) { 0 }
|
||||
let(:expected_queries_per_project) { 0 }
|
||||
|
||||
def get_list
|
||||
get :index, group_id: group.to_param, format: :json
|
||||
end
|
||||
|
||||
it 'queries the expected amount for a group row' do
|
||||
control = ActiveRecord::QueryRecorder.new { get_list }
|
||||
|
||||
_new_group = create(:group, :public, parent: group)
|
||||
|
||||
expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_group)
|
||||
end
|
||||
|
||||
it 'queries the expected amount for a project row' do
|
||||
control = ActiveRecord::QueryRecorder.new { get_list }
|
||||
_new_project = create(:project, :public, namespace: group)
|
||||
|
||||
expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_project)
|
||||
end
|
||||
|
||||
context 'when rendering hierarchies' do
|
||||
# When loading hierarchies we load the all the ancestors for matched projects
|
||||
# in 1 separate query
|
||||
let(:extra_queries_for_hierarchies) { 1 }
|
||||
|
||||
def get_filtered_list
|
||||
get :index, group_id: group.to_param, filter: 'filter', format: :json
|
||||
end
|
||||
|
||||
it 'queries the expected amount when nested rows are increased for a group' do
|
||||
matched_group = create(:group, :public, parent: group, name: 'filterme')
|
||||
|
||||
control = ActiveRecord::QueryRecorder.new { get_filtered_list }
|
||||
|
||||
matched_group.update!(parent: public_subgroup)
|
||||
|
||||
expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
|
||||
end
|
||||
|
||||
it 'queries the expected amount when a new group match is added' do
|
||||
create(:group, :public, parent: public_subgroup, name: 'filterme')
|
||||
|
||||
control = ActiveRecord::QueryRecorder.new { get_filtered_list }
|
||||
|
||||
create(:group, :public, parent: public_subgroup, name: 'filterme2')
|
||||
create(:group, :public, parent: public_subgroup, name: 'filterme3')
|
||||
|
||||
expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
|
||||
end
|
||||
|
||||
it 'queries the expected amount when nested rows are increased for a project' do
|
||||
matched_project = create(:project, :public, namespace: group, name: 'filterme')
|
||||
|
||||
control = ActiveRecord::QueryRecorder.new { get_filtered_list }
|
||||
|
||||
matched_project.update!(namespace: public_subgroup)
|
||||
|
||||
expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'pagination' do
|
||||
let(:per_page) { 3 }
|
||||
|
||||
before do
|
||||
allow(Kaminari.config).to receive(:default_per_page).and_return(per_page)
|
||||
end
|
||||
|
||||
context 'with only projects' do
|
||||
let!(:other_project) { create(:project, :public, namespace: group) }
|
||||
let!(:first_page_projects) { create_list(:project, per_page, :public, namespace: group ) }
|
||||
|
||||
it 'has projects on the first page' do
|
||||
get :index, group_id: group.to_param, sort: 'id_desc', format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(*first_page_projects)
|
||||
end
|
||||
|
||||
it 'has projects on the second page' do
|
||||
get :index, group_id: group.to_param, sort: 'id_desc', page: 2, format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(other_project)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with subgroups and projects', :nested_groups do
|
||||
let!(:first_page_subgroups) { create_list(:group, per_page, :public, parent: group) }
|
||||
let!(:other_subgroup) { create(:group, :public, parent: group) }
|
||||
let!(:next_page_projects) { create_list(:project, per_page, :public, namespace: group) }
|
||||
|
||||
it 'contains all subgroups' do
|
||||
get :index, group_id: group.to_param, sort: 'id_asc', format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(*first_page_subgroups)
|
||||
end
|
||||
|
||||
it 'contains the project and group on the second page' do
|
||||
get :index, group_id: group.to_param, sort: 'id_asc', page: 2, format: :json
|
||||
|
||||
expect(assigns(:children)).to contain_exactly(other_subgroup, *next_page_projects.take(per_page - 1))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
require 'rails_helper'
|
||||
require 'spec_helper'
|
||||
|
||||
describe GroupsController do
|
||||
let(:user) { create(:user) }
|
||||
|
@ -150,42 +150,6 @@ describe GroupsController do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'GET #subgroups', :nested_groups do
|
||||
let!(:public_subgroup) { create(:group, :public, parent: group) }
|
||||
let!(:private_subgroup) { create(:group, :private, parent: group) }
|
||||
|
||||
context 'as a user' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'shows all subgroups' do
|
||||
get :subgroups, id: group.to_param
|
||||
|
||||
expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
|
||||
end
|
||||
|
||||
context 'being member of private subgroup' do
|
||||
it 'shows public and private subgroups the user is member of' do
|
||||
group_member.destroy!
|
||||
private_subgroup.add_guest(user)
|
||||
|
||||
get :subgroups, id: group.to_param
|
||||
|
||||
expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'as a guest' do
|
||||
it 'shows the public subgroups' do
|
||||
get :subgroups, id: group.to_param
|
||||
|
||||
expect(assigns(:nested_groups)).to contain_exactly(public_subgroup)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #issues' do
|
||||
let(:issue_1) { create(:issue, project: project) }
|
||||
let(:issue_2) { create(:issue, project: project) }
|
||||
|
@ -425,62 +389,62 @@ describe GroupsController do
|
|||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for a POST request' do
|
||||
context 'when requesting the canonical path with different casing' do
|
||||
it 'does not 404' do
|
||||
post :update, id: group.to_param.upcase, group: { path: 'new_path' }
|
||||
context 'for a POST request' do
|
||||
context 'when requesting the canonical path with different casing' do
|
||||
it 'does not 404' do
|
||||
post :update, id: group.to_param.upcase, group: { path: 'new_path' }
|
||||
|
||||
expect(response).not_to have_http_status(404)
|
||||
expect(response).not_to have_http_status(404)
|
||||
end
|
||||
|
||||
it 'does not redirect to the correct casing' do
|
||||
post :update, id: group.to_param.upcase, group: { path: 'new_path' }
|
||||
|
||||
expect(response).not_to have_http_status(301)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not redirect to the correct casing' do
|
||||
post :update, id: group.to_param.upcase, group: { path: 'new_path' }
|
||||
context 'when requesting a redirected path' do
|
||||
let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
|
||||
|
||||
expect(response).not_to have_http_status(301)
|
||||
it 'returns not found' do
|
||||
post :update, id: redirect_route.path, group: { path: 'new_path' }
|
||||
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when requesting a redirected path' do
|
||||
let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
|
||||
context 'for a DELETE request' do
|
||||
context 'when requesting the canonical path with different casing' do
|
||||
it 'does not 404' do
|
||||
delete :destroy, id: group.to_param.upcase
|
||||
|
||||
it 'returns not found' do
|
||||
post :update, id: redirect_route.path, group: { path: 'new_path' }
|
||||
expect(response).not_to have_http_status(404)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(404)
|
||||
it 'does not redirect to the correct casing' do
|
||||
delete :destroy, id: group.to_param.upcase
|
||||
|
||||
expect(response).not_to have_http_status(301)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when requesting a redirected path' do
|
||||
let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
|
||||
|
||||
it 'returns not found' do
|
||||
delete :destroy, id: redirect_route.path
|
||||
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for a DELETE request' do
|
||||
context 'when requesting the canonical path with different casing' do
|
||||
it 'does not 404' do
|
||||
delete :destroy, id: group.to_param.upcase
|
||||
|
||||
expect(response).not_to have_http_status(404)
|
||||
end
|
||||
|
||||
it 'does not redirect to the correct casing' do
|
||||
delete :destroy, id: group.to_param.upcase
|
||||
|
||||
expect(response).not_to have_http_status(301)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when requesting a redirected path' do
|
||||
let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
|
||||
|
||||
it 'returns not found' do
|
||||
delete :destroy, id: redirect_route.path
|
||||
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
def group_moved_message(redirect_route, group)
|
||||
"Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
|
||||
end
|
||||
end
|
||||
|
||||
def group_moved_message(redirect_route, group)
|
||||
"Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,13 @@ feature 'Dashboard Groups page', :js do
|
|||
let(:nested_group) { create(:group, :nested) }
|
||||
let(:another_group) { create(:group) }
|
||||
|
||||
def click_group_caret(group)
|
||||
within("#group-#{group.id}") do
|
||||
first('.folder-caret').click
|
||||
end
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
it 'shows groups user is member of' do
|
||||
group.add_owner(user)
|
||||
nested_group.add_owner(user)
|
||||
|
@ -13,13 +20,27 @@ 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)
|
||||
expect(page).not_to have_content(another_group.full_name)
|
||||
expect(page).to have_content(group.name)
|
||||
|
||||
expect(page).not_to have_content(another_group.name)
|
||||
end
|
||||
|
||||
describe 'when filtering groups' do
|
||||
it 'shows subgroups the user is member of', :nested_groups do
|
||||
group.add_owner(user)
|
||||
nested_group.add_owner(user)
|
||||
|
||||
sign_in(user)
|
||||
visit dashboard_groups_path
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_content(nested_group.parent.name)
|
||||
click_group_caret(nested_group.parent)
|
||||
expect(page).to have_content(nested_group.name)
|
||||
end
|
||||
|
||||
describe 'when filtering groups', :nested_groups do
|
||||
before do
|
||||
group.add_owner(user)
|
||||
nested_group.add_owner(user)
|
||||
|
@ -30,25 +51,26 @@ feature 'Dashboard Groups page', :js do
|
|||
visit dashboard_groups_path
|
||||
end
|
||||
|
||||
it 'filters groups' do
|
||||
fill_in 'filter_groups', with: group.name
|
||||
it 'expands when filtering groups' do
|
||||
fill_in 'filter', with: nested_group.name
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_content(group.full_name)
|
||||
expect(page).not_to have_content(nested_group.full_name)
|
||||
expect(page).not_to have_content(another_group.full_name)
|
||||
expect(page).not_to have_content(group.name)
|
||||
expect(page).to have_content(nested_group.parent.name)
|
||||
expect(page).to have_content(nested_group.name)
|
||||
expect(page).not_to have_content(another_group.name)
|
||||
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)
|
||||
expect(page).to have_content(nested_group.full_name)
|
||||
expect(page).not_to have_content(another_group.full_name)
|
||||
expect(page).to have_content(group.name)
|
||||
expect(page).to have_content(nested_group.parent.name)
|
||||
expect(page).not_to have_content(another_group.name)
|
||||
expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
|
||||
end
|
||||
end
|
||||
|
@ -66,28 +88,29 @@ feature 'Dashboard Groups page', :js do
|
|||
end
|
||||
|
||||
it 'shows subgroups inside of its parent group' do
|
||||
expect(page).to have_selector('.groups-list-tree-container .group-list-tree', count: 2)
|
||||
expect(page).to have_selector(".groups-list-tree-container #group-#{group.id} #group-#{subgroup.id}", count: 1)
|
||||
expect(page).to have_selector("#group-#{group.id}")
|
||||
click_group_caret(group)
|
||||
expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
|
||||
end
|
||||
|
||||
it 'can toggle parent group' do
|
||||
# Expanded by default
|
||||
expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
|
||||
expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
|
||||
# Collapsed by default
|
||||
expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
|
||||
expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
|
||||
|
||||
# Collapse
|
||||
find("#group-#{group.id}").trigger('click')
|
||||
# expand
|
||||
click_group_caret(group)
|
||||
|
||||
expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down")
|
||||
expect(page).to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
|
||||
expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
|
||||
|
||||
# Expand
|
||||
find("#group-#{group.id}").trigger('click')
|
||||
|
||||
expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
|
||||
expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
|
||||
expect(page).to have_selector("#group-#{group.id} .fa-caret-down")
|
||||
expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
|
||||
expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
|
||||
|
||||
# collapse
|
||||
click_group_caret(group)
|
||||
|
||||
expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
|
||||
expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
|
||||
expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -13,6 +13,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
|
||||
|
@ -22,7 +23,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)
|
||||
|
@ -31,10 +32,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)
|
||||
|
@ -45,21 +46,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
|
||||
|
|
|
@ -24,4 +24,35 @@ feature 'Group show page' do
|
|||
|
||||
it_behaves_like "an autodiscoverable RSS feed without an RSS token"
|
||||
end
|
||||
|
||||
context 'subgroup support' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
group.add_owner(user)
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
context 'when subgroups are supported', :js, :nested_groups do
|
||||
before do
|
||||
allow(Group).to receive(:supports_nested_groups?) { true }
|
||||
visit path
|
||||
end
|
||||
|
||||
it 'allows creating subgroups' do
|
||||
expect(page).to have_css("li[data-text='New subgroup']", visible: false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when subgroups are not supported' do
|
||||
before do
|
||||
allow(Group).to receive(:supports_nested_groups?) { false }
|
||||
visit path
|
||||
end
|
||||
|
||||
it 'allows creating subgroups' do
|
||||
expect(page).not_to have_selector("li[data-text='New subgroup']", visible: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -90,8 +90,7 @@ feature 'Group' do
|
|||
|
||||
context 'as admin' do
|
||||
before do
|
||||
visit subgroups_group_path(group)
|
||||
click_link 'New Subgroup'
|
||||
visit new_group_path(group, parent_id: group.id)
|
||||
end
|
||||
|
||||
it 'creates a nested group' do
|
||||
|
@ -111,8 +110,8 @@ feature 'Group' do
|
|||
sign_out(:user)
|
||||
sign_in(user)
|
||||
|
||||
visit subgroups_group_path(group)
|
||||
click_link 'New Subgroup'
|
||||
visit new_group_path(group, parent_id: group.id)
|
||||
|
||||
fill_in 'Group path', with: 'bar'
|
||||
click_button 'Create group'
|
||||
|
||||
|
@ -120,16 +119,6 @@ feature 'Group' do
|
|||
expect(page).to have_content("Group 'bar' was successfully created.")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when nested group feature is disabled' do
|
||||
it 'renders 404' do
|
||||
allow(Group).to receive(:supports_nested_groups?).and_return(false)
|
||||
|
||||
visit subgroups_group_path(group)
|
||||
|
||||
expect(page.status_code).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'checks permissions to avoid exposing groups by parent_id' do
|
||||
|
@ -210,13 +199,15 @@ feature 'Group' do
|
|||
describe 'group page with nested groups', :nested_groups, :js do
|
||||
let!(:group) { create(:group) }
|
||||
let!(:nested_group) { create(:group, parent: group) }
|
||||
let!(:project) { create(:project, namespace: group) }
|
||||
let!(:path) { group_path(group) }
|
||||
|
||||
it 'has nested groups tab with nested groups inside' do
|
||||
it 'it renders projects and groups on the page' do
|
||||
visit path
|
||||
click_link 'Subgroups'
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_content(nested_group.name)
|
||||
expect(page).to have_content(project.name)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
166
spec/finders/group_descendants_finder_spec.rb
Normal file
166
spec/finders/group_descendants_finder_spec.rb
Normal file
|
@ -0,0 +1,166 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe GroupDescendantsFinder do
|
||||
let(:user) { create(:user) }
|
||||
let(:group) { create(:group) }
|
||||
let(:params) { {} }
|
||||
subject(:finder) do
|
||||
described_class.new(current_user: user, parent_group: group, params: params)
|
||||
end
|
||||
|
||||
before do
|
||||
group.add_owner(user)
|
||||
end
|
||||
|
||||
describe '#has_children?' do
|
||||
it 'is true when there are projects' do
|
||||
create(:project, namespace: group)
|
||||
|
||||
expect(finder.has_children?).to be_truthy
|
||||
end
|
||||
|
||||
context 'when there are subgroups', :nested_groups do
|
||||
it 'is true when there are projects' do
|
||||
create(:group, parent: group)
|
||||
|
||||
expect(finder.has_children?).to be_truthy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
it 'includes projects' do
|
||||
project = create(:project, namespace: group)
|
||||
|
||||
expect(finder.execute).to contain_exactly(project)
|
||||
end
|
||||
|
||||
context 'when archived is `true`' do
|
||||
let(:params) { { archived: 'true' } }
|
||||
|
||||
it 'includes archived projects' do
|
||||
archived_project = create(:project, namespace: group, archived: true)
|
||||
project = create(:project, namespace: group)
|
||||
|
||||
expect(finder.execute).to contain_exactly(archived_project, project)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when archived is `only`' do
|
||||
let(:params) { { archived: 'only' } }
|
||||
|
||||
it 'includes only archived projects' do
|
||||
archived_project = create(:project, namespace: group, archived: true)
|
||||
_project = create(:project, namespace: group)
|
||||
|
||||
expect(finder.execute).to contain_exactly(archived_project)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not include archived projects' do
|
||||
_archived_project = create(:project, :archived, namespace: group)
|
||||
|
||||
expect(finder.execute).to be_empty
|
||||
end
|
||||
|
||||
context 'with a filter' do
|
||||
let(:params) { { filter: 'test' } }
|
||||
|
||||
it 'includes only projects matching the filter' do
|
||||
_other_project = create(:project, namespace: group)
|
||||
matching_project = create(:project, namespace: group, name: 'testproject')
|
||||
|
||||
expect(finder.execute).to contain_exactly(matching_project)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nested groups', :nested_groups do
|
||||
let!(:project) { create(:project, namespace: group) }
|
||||
let!(:subgroup) { create(:group, :private, parent: group) }
|
||||
|
||||
describe '#execute' do
|
||||
it 'contains projects and subgroups' do
|
||||
expect(finder.execute).to contain_exactly(subgroup, project)
|
||||
end
|
||||
|
||||
it 'does not include subgroups the user does not have access to' do
|
||||
subgroup.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
|
||||
|
||||
public_subgroup = create(:group, :public, parent: group, path: 'public-group')
|
||||
other_subgroup = create(:group, :private, parent: group, path: 'visible-private-group')
|
||||
other_user = create(:user)
|
||||
other_subgroup.add_developer(other_user)
|
||||
|
||||
finder = described_class.new(current_user: other_user, parent_group: group)
|
||||
|
||||
expect(finder.execute).to contain_exactly(public_subgroup, other_subgroup)
|
||||
end
|
||||
|
||||
it 'only includes public groups when no user is given' do
|
||||
public_subgroup = create(:group, :public, parent: group)
|
||||
_private_subgroup = create(:group, :private, parent: group)
|
||||
|
||||
finder = described_class.new(current_user: nil, parent_group: group)
|
||||
|
||||
expect(finder.execute).to contain_exactly(public_subgroup)
|
||||
end
|
||||
|
||||
context 'when archived is `true`' do
|
||||
let(:params) { { archived: 'true' } }
|
||||
|
||||
it 'includes archived projects in the count of subgroups' do
|
||||
create(:project, namespace: subgroup, archived: true)
|
||||
|
||||
expect(finder.execute.first.preloaded_project_count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a filter' do
|
||||
let(:params) { { filter: 'test' } }
|
||||
|
||||
it 'contains only matching projects and subgroups' do
|
||||
matching_project = create(:project, namespace: group, name: 'Testproject')
|
||||
matching_subgroup = create(:group, name: 'testgroup', parent: group)
|
||||
|
||||
expect(finder.execute).to contain_exactly(matching_subgroup, matching_project)
|
||||
end
|
||||
|
||||
it 'does not include subgroups the user does not have access to' do
|
||||
_invisible_subgroup = create(:group, :private, parent: group, name: 'test1')
|
||||
other_subgroup = create(:group, :private, parent: group, name: 'test2')
|
||||
public_subgroup = create(:group, :public, parent: group, name: 'test3')
|
||||
other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4')
|
||||
other_user = create(:user)
|
||||
other_subgroup.add_developer(other_user)
|
||||
|
||||
finder = described_class.new(current_user: other_user,
|
||||
parent_group: group,
|
||||
params: params)
|
||||
|
||||
expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup)
|
||||
end
|
||||
|
||||
context 'with matching children' do
|
||||
it 'includes a group that has a subgroup matching the query and its parent' do
|
||||
matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup)
|
||||
|
||||
expect(finder.execute).to contain_exactly(subgroup, matching_subgroup)
|
||||
end
|
||||
|
||||
it 'includes the parent of a matching project' do
|
||||
matching_project = create(:project, namespace: subgroup, name: 'Testproject')
|
||||
|
||||
expect(finder.execute).to contain_exactly(subgroup, matching_project)
|
||||
end
|
||||
|
||||
it 'does not include the parent itself' do
|
||||
group.update!(name: 'test')
|
||||
|
||||
expect(finder.execute).not_to include(group)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
443
spec/javascripts/groups/components/app_spec.js
Normal file
443
spec/javascripts/groups/components/app_spec.js
Normal file
|
@ -0,0 +1,443 @@
|
|||
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',
|
||||
archived: true,
|
||||
});
|
||||
setTimeout(() => {
|
||||
expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true);
|
||||
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,
|
||||
archived: null,
|
||||
});
|
||||
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, true);
|
||||
expect(vm.isLoading).toBeTruthy();
|
||||
expect(vm.fetchGroups).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
filterGroupsBy: null,
|
||||
sortBy: null,
|
||||
updatePagination: true,
|
||||
archived: 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
66
spec/javascripts/groups/components/group_folder_spec.js
Normal file
66
spec/javascripts/groups/components/group_folder_spec.js
Normal file
|
@ -0,0 +1,66 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import groupFolderComponent from '~/groups/components/group_folder.vue';
|
||||
import groupItemComponent from '~/groups/components/group_item.vue';
|
||||
import { mockGroups, mockParentGroupItem } from '../mock_data';
|
||||
|
||||
const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
|
||||
const Component = Vue.extend(groupFolderComponent);
|
||||
|
||||
return new Component({
|
||||
propsData: {
|
||||
groups,
|
||||
parentGroup,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('GroupFolderComponent', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach((done) => {
|
||||
Vue.component('group-item', groupItemComponent);
|
||||
|
||||
vm = createComponent();
|
||||
vm.$mount();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('computed', () => {
|
||||
describe('hasMoreChildren', () => {
|
||||
it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
|
||||
expect(vm.hasMoreChildren).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moreChildrenStats', () => {
|
||||
it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
|
||||
expect(vm.moreChildrenStats).toBe('3 more items');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('should render component template correctly', () => {
|
||||
expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
|
||||
expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
|
||||
});
|
||||
|
||||
it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
|
||||
const parentGroup = Object.assign({}, mockParentGroupItem);
|
||||
parentGroup.childrenCount = 21;
|
||||
|
||||
const newVm = createComponent(mockGroups, parentGroup);
|
||||
newVm.$mount();
|
||||
expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
|
||||
newVm.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
177
spec/javascripts/groups/components/group_item_spec.js
Normal file
177
spec/javascripts/groups/components/group_item_spec.js
Normal file
|
@ -0,0 +1,177 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import groupItemComponent from '~/groups/components/group_item.vue';
|
||||
import groupFolderComponent from '~/groups/components/group_folder.vue';
|
||||
import eventHub from '~/groups/event_hub';
|
||||
import { mockParentGroupItem, mockChildren } from '../mock_data';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
|
||||
const Component = Vue.extend(groupItemComponent);
|
||||
|
||||
return mountComponent(Component, {
|
||||
group,
|
||||
parentGroup,
|
||||
});
|
||||
};
|
||||
|
||||
describe('GroupItemComponent', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach((done) => {
|
||||
Vue.component('group-folder', groupFolderComponent);
|
||||
|
||||
vm = createComponent();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('computed', () => {
|
||||
describe('groupDomId', () => {
|
||||
it('should return ID string suffixed with group ID', () => {
|
||||
expect(vm.groupDomId).toBe('group-55');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rowClass', () => {
|
||||
it('should return map of classes based on group details', () => {
|
||||
const classes = ['is-open', 'has-children', 'has-description', 'being-removed'];
|
||||
const rowClass = vm.rowClass;
|
||||
|
||||
expect(Object.keys(rowClass).length).toBe(classes.length);
|
||||
Object.keys(rowClass).forEach((className) => {
|
||||
expect(classes.indexOf(className) > -1).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChildren', () => {
|
||||
it('should return boolean value representing if group has any children present', () => {
|
||||
let newVm;
|
||||
const group = Object.assign({}, mockParentGroupItem);
|
||||
|
||||
group.childrenCount = 5;
|
||||
newVm = createComponent(group);
|
||||
expect(newVm.hasChildren).toBeTruthy();
|
||||
newVm.$destroy();
|
||||
|
||||
group.childrenCount = 0;
|
||||
newVm = createComponent(group);
|
||||
expect(newVm.hasChildren).toBeFalsy();
|
||||
newVm.$destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAvatar', () => {
|
||||
it('should return boolean value representing if group has any avatar present', () => {
|
||||
let newVm;
|
||||
const group = Object.assign({}, mockParentGroupItem);
|
||||
|
||||
group.avatarUrl = null;
|
||||
newVm = createComponent(group);
|
||||
expect(newVm.hasAvatar).toBeFalsy();
|
||||
newVm.$destroy();
|
||||
|
||||
group.avatarUrl = '/uploads/group_avatar.png';
|
||||
newVm = createComponent(group);
|
||||
expect(newVm.hasAvatar).toBeTruthy();
|
||||
newVm.$destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGroup', () => {
|
||||
it('should return boolean value representing if group item is of type `group` or not', () => {
|
||||
let newVm;
|
||||
const group = Object.assign({}, mockParentGroupItem);
|
||||
|
||||
group.type = 'group';
|
||||
newVm = createComponent(group);
|
||||
expect(newVm.isGroup).toBeTruthy();
|
||||
newVm.$destroy();
|
||||
|
||||
group.type = 'project';
|
||||
newVm = createComponent(group);
|
||||
expect(newVm.isGroup).toBeFalsy();
|
||||
newVm.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
describe('onClickRowGroup', () => {
|
||||
let event;
|
||||
|
||||
beforeEach(() => {
|
||||
const classList = {
|
||||
contains() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
event = {
|
||||
target: {
|
||||
classList,
|
||||
parentElement: {
|
||||
classList,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => {
|
||||
spyOn(eventHub, '$emit');
|
||||
|
||||
vm.onClickRowGroup(event);
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group);
|
||||
});
|
||||
|
||||
it('should navigate page to group homepage if group does not have any children present', (done) => {
|
||||
const group = Object.assign({}, mockParentGroupItem);
|
||||
group.childrenCount = 0;
|
||||
const newVm = createComponent(group);
|
||||
spyOn(gl.utils, 'visitUrl').and.stub();
|
||||
spyOn(eventHub, '$emit');
|
||||
|
||||
newVm.onClickRowGroup(event);
|
||||
setTimeout(() => {
|
||||
expect(eventHub.$emit).not.toHaveBeenCalled();
|
||||
expect(gl.utils.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('should render component template correctly', () => {
|
||||
expect(vm.$el.getAttribute('id')).toBe('group-55');
|
||||
expect(vm.$el.classList.contains('group-row')).toBeTruthy();
|
||||
|
||||
expect(vm.$el.querySelector('.group-row-contents')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined();
|
||||
|
||||
expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined();
|
||||
|
||||
expect(vm.$el.querySelector('.avatar-container')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined();
|
||||
|
||||
expect(vm.$el.querySelector('.title')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.access-type')).toBeDefined();
|
||||
expect(vm.$el.querySelector('.description')).toBeDefined();
|
||||
|
||||
expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
70
spec/javascripts/groups/components/groups_spec.js
Normal file
70
spec/javascripts/groups/components/groups_spec.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import groupsComponent from '~/groups/components/groups.vue';
|
||||
import groupFolderComponent from '~/groups/components/group_folder.vue';
|
||||
import groupItemComponent from '~/groups/components/group_item.vue';
|
||||
import eventHub from '~/groups/event_hub';
|
||||
import { mockGroups, mockPageInfo } from '../mock_data';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
const createComponent = (searchEmpty = false) => {
|
||||
const Component = Vue.extend(groupsComponent);
|
||||
|
||||
return mountComponent(Component, {
|
||||
groups: mockGroups,
|
||||
pageInfo: mockPageInfo,
|
||||
searchEmptyMessage: 'No matching results',
|
||||
searchEmpty,
|
||||
});
|
||||
};
|
||||
|
||||
describe('GroupsComponent', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach((done) => {
|
||||
Vue.component('group-folder', groupFolderComponent);
|
||||
Vue.component('group-item', groupItemComponent);
|
||||
|
||||
vm = createComponent();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
describe('change', () => {
|
||||
it('should emit `fetchPage` event when page is changed via pagination', () => {
|
||||
spyOn(eventHub, '$emit').and.stub();
|
||||
|
||||
vm.change(2);
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', 2, jasmine.any(Object), jasmine.any(Object), 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
110
spec/javascripts/groups/components/item_actions_spec.js
Normal file
110
spec/javascripts/groups/components/item_actions_spec.js
Normal file
|
@ -0,0 +1,110 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import itemActionsComponent from '~/groups/components/item_actions.vue';
|
||||
import eventHub from '~/groups/event_hub';
|
||||
import { mockParentGroupItem, mockChildren } from '../mock_data';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
|
||||
const Component = Vue.extend(itemActionsComponent);
|
||||
|
||||
return mountComponent(Component, {
|
||||
group,
|
||||
parentGroup,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ItemActionsComponent', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('computed', () => {
|
||||
describe('leaveConfirmationMessage', () => {
|
||||
it('should return appropriate string for leave group confirmation', () => {
|
||||
expect(vm.leaveConfirmationMessage).toBe('Are you sure you want to leave the "platform / hardware" group?');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
describe('onLeaveGroup', () => {
|
||||
it('should change `dialogStatus` prop to `true` which shows confirmation dialog', () => {
|
||||
expect(vm.dialogStatus).toBeFalsy();
|
||||
vm.onLeaveGroup();
|
||||
expect(vm.dialogStatus).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('leaveGroup', () => {
|
||||
it('should change `dialogStatus` prop to `false` and emit `leaveGroup` event with required params when called with `leaveConfirmed` as `true`', () => {
|
||||
spyOn(eventHub, '$emit');
|
||||
vm.dialogStatus = true;
|
||||
vm.leaveGroup(true);
|
||||
expect(vm.dialogStatus).toBeFalsy();
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('leaveGroup', vm.group, vm.parentGroup);
|
||||
});
|
||||
|
||||
it('should change `dialogStatus` prop to `false` and should NOT emit `leaveGroup` event when called with `leaveConfirmed` as `false`', () => {
|
||||
spyOn(eventHub, '$emit');
|
||||
vm.dialogStatus = true;
|
||||
vm.leaveGroup(false);
|
||||
expect(vm.dialogStatus).toBeFalsy();
|
||||
expect(eventHub.$emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('should render component template correctly', () => {
|
||||
expect(vm.$el.classList.contains('controls')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render Edit Group button with correct attribute values', () => {
|
||||
const group = Object.assign({}, mockParentGroupItem);
|
||||
group.canEdit = true;
|
||||
const newVm = createComponent(group);
|
||||
|
||||
const editBtn = newVm.$el.querySelector('a.edit-group');
|
||||
expect(editBtn).toBeDefined();
|
||||
expect(editBtn.classList.contains('no-expand')).toBeTruthy();
|
||||
expect(editBtn.getAttribute('href')).toBe(group.editPath);
|
||||
expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
|
||||
expect(editBtn.dataset.originalTitle).toBe('Edit group');
|
||||
expect(editBtn.querySelector('i.fa.fa-cogs')).toBeDefined();
|
||||
|
||||
newVm.$destroy();
|
||||
});
|
||||
|
||||
it('should render Leave Group button with correct attribute values', () => {
|
||||
const group = Object.assign({}, mockParentGroupItem);
|
||||
group.canLeave = true;
|
||||
const newVm = createComponent(group);
|
||||
|
||||
const leaveBtn = newVm.$el.querySelector('a.leave-group');
|
||||
expect(leaveBtn).toBeDefined();
|
||||
expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
|
||||
expect(leaveBtn.getAttribute('href')).toBe(group.leavePath);
|
||||
expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group');
|
||||
expect(leaveBtn.dataset.originalTitle).toBe('Leave this group');
|
||||
expect(leaveBtn.querySelector('i.fa.fa-sign-out')).toBeDefined();
|
||||
|
||||
newVm.$destroy();
|
||||
});
|
||||
|
||||
it('should show modal dialog when `dialogStatus` is set to `true`', () => {
|
||||
vm.dialogStatus = true;
|
||||
const modalDialogEl = vm.$el.querySelector('.modal.popup-dialog');
|
||||
expect(modalDialogEl).toBeDefined();
|
||||
expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
|
||||
expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
|
||||
});
|
||||
});
|
||||
});
|
40
spec/javascripts/groups/components/item_caret_spec.js
Normal file
40
spec/javascripts/groups/components/item_caret_spec.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import itemCaretComponent from '~/groups/components/item_caret.vue';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
const createComponent = (isGroupOpen = false) => {
|
||||
const Component = Vue.extend(itemCaretComponent);
|
||||
|
||||
return mountComponent(Component, {
|
||||
isGroupOpen,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ItemCaretComponent', () => {
|
||||
describe('template', () => {
|
||||
it('should render component template correctly', () => {
|
||||
const vm = createComponent();
|
||||
vm.$mount();
|
||||
expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
|
||||
const vm = createComponent(true);
|
||||
vm.$mount();
|
||||
expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(1);
|
||||
expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(0);
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
|
||||
const vm = createComponent();
|
||||
vm.$mount();
|
||||
expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(0);
|
||||
expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(1);
|
||||
vm.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
159
spec/javascripts/groups/components/item_stats_spec.js
Normal file
159
spec/javascripts/groups/components/item_stats_spec.js
Normal file
|
@ -0,0 +1,159 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import itemStatsComponent from '~/groups/components/item_stats.vue';
|
||||
import {
|
||||
mockParentGroupItem,
|
||||
ITEM_TYPE,
|
||||
VISIBILITY_TYPE_ICON,
|
||||
GROUP_VISIBILITY_TYPE,
|
||||
PROJECT_VISIBILITY_TYPE,
|
||||
} from '../mock_data';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
const createComponent = (item = mockParentGroupItem) => {
|
||||
const Component = Vue.extend(itemStatsComponent);
|
||||
|
||||
return mountComponent(Component, {
|
||||
item,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ItemStatsComponent', () => {
|
||||
describe('computed', () => {
|
||||
describe('visibilityIcon', () => {
|
||||
it('should return icon class based on `item.visibility` value', () => {
|
||||
Object.keys(VISIBILITY_TYPE_ICON).forEach((visibility) => {
|
||||
const item = Object.assign({}, mockParentGroupItem, { visibility });
|
||||
const vm = createComponent(item);
|
||||
vm.$mount();
|
||||
expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
|
||||
vm.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibilityTooltip', () => {
|
||||
it('should return tooltip string for Group based on `item.visibility` value', () => {
|
||||
Object.keys(GROUP_VISIBILITY_TYPE).forEach((visibility) => {
|
||||
const item = Object.assign({}, mockParentGroupItem, {
|
||||
visibility,
|
||||
type: ITEM_TYPE.GROUP,
|
||||
});
|
||||
const vm = createComponent(item);
|
||||
vm.$mount();
|
||||
expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
|
||||
vm.$destroy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return tooltip string for Project based on `item.visibility` value', () => {
|
||||
Object.keys(PROJECT_VISIBILITY_TYPE).forEach((visibility) => {
|
||||
const item = Object.assign({}, mockParentGroupItem, {
|
||||
visibility,
|
||||
type: ITEM_TYPE.PROJECT,
|
||||
});
|
||||
const vm = createComponent(item);
|
||||
vm.$mount();
|
||||
expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
|
||||
vm.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProject', () => {
|
||||
it('should return boolean value representing whether `item.type` is Project or not', () => {
|
||||
let item;
|
||||
let vm;
|
||||
|
||||
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
|
||||
vm = createComponent(item);
|
||||
vm.$mount();
|
||||
expect(vm.isProject).toBeTruthy();
|
||||
vm.$destroy();
|
||||
|
||||
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
|
||||
vm = createComponent(item);
|
||||
vm.$mount();
|
||||
expect(vm.isProject).toBeFalsy();
|
||||
vm.$destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGroup', () => {
|
||||
it('should return boolean value representing whether `item.type` is Group or not', () => {
|
||||
let item;
|
||||
let vm;
|
||||
|
||||
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
|
||||
vm = createComponent(item);
|
||||
vm.$mount();
|
||||
expect(vm.isGroup).toBeTruthy();
|
||||
vm.$destroy();
|
||||
|
||||
item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
|
||||
vm = createComponent(item);
|
||||
vm.$mount();
|
||||
expect(vm.isGroup).toBeFalsy();
|
||||
vm.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('should render component template correctly', () => {
|
||||
const vm = createComponent();
|
||||
vm.$mount();
|
||||
|
||||
const visibilityIconEl = vm.$el.querySelector('.item-visibility');
|
||||
expect(vm.$el.classList.contains('.stats')).toBeDefined();
|
||||
expect(visibilityIconEl).toBeDefined();
|
||||
expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
|
||||
expect(visibilityIconEl.querySelector('i.fa')).toBeDefined();
|
||||
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render stat icons if `item.type` is Group', () => {
|
||||
const item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
|
||||
const vm = createComponent(item);
|
||||
vm.$mount();
|
||||
|
||||
const subgroupIconEl = vm.$el.querySelector('span.number-subgroups');
|
||||
expect(subgroupIconEl).toBeDefined();
|
||||
expect(subgroupIconEl.dataset.originalTitle).toBe('Subgroups');
|
||||
expect(subgroupIconEl.querySelector('i.fa.fa-folder')).toBeDefined();
|
||||
expect(subgroupIconEl.innerText.trim()).toBe(`${vm.item.subgroupCount}`);
|
||||
|
||||
const projectsIconEl = vm.$el.querySelector('span.number-projects');
|
||||
expect(projectsIconEl).toBeDefined();
|
||||
expect(projectsIconEl.dataset.originalTitle).toBe('Projects');
|
||||
expect(projectsIconEl.querySelector('i.fa.fa-bookmark')).toBeDefined();
|
||||
expect(projectsIconEl.innerText.trim()).toBe(`${vm.item.projectCount}`);
|
||||
|
||||
const membersIconEl = vm.$el.querySelector('span.number-users');
|
||||
expect(membersIconEl).toBeDefined();
|
||||
expect(membersIconEl.dataset.originalTitle).toBe('Members');
|
||||
expect(membersIconEl.querySelector('i.fa.fa-users')).toBeDefined();
|
||||
expect(membersIconEl.innerText.trim()).toBe(`${vm.item.memberCount}`);
|
||||
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render stat icons if `item.type` is Project', () => {
|
||||
const item = Object.assign({}, mockParentGroupItem, {
|
||||
type: ITEM_TYPE.PROJECT,
|
||||
starCount: 4,
|
||||
});
|
||||
const vm = createComponent(item);
|
||||
vm.$mount();
|
||||
|
||||
const projectStarIconEl = vm.$el.querySelector('.project-stars');
|
||||
expect(projectStarIconEl).toBeDefined();
|
||||
expect(projectStarIconEl.querySelector('i.fa.fa-star')).toBeDefined();
|
||||
expect(projectStarIconEl.innerText.trim()).toBe(`${vm.item.starCount}`);
|
||||
|
||||
vm.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
54
spec/javascripts/groups/components/item_type_icon_spec.js
Normal file
54
spec/javascripts/groups/components/item_type_icon_spec.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
|
||||
import { ITEM_TYPE } from '../mock_data';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
|
||||
const Component = Vue.extend(itemTypeIconComponent);
|
||||
|
||||
return mountComponent(Component, {
|
||||
itemType,
|
||||
isGroupOpen,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ItemTypeIconComponent', () => {
|
||||
describe('template', () => {
|
||||
it('should render component template correctly', () => {
|
||||
const vm = createComponent();
|
||||
vm.$mount();
|
||||
expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render folder open or close icon based `isGroupOpen` prop value', () => {
|
||||
let vm;
|
||||
|
||||
vm = createComponent(ITEM_TYPE.GROUP, true);
|
||||
vm.$mount();
|
||||
expect(vm.$el.querySelector('i.fa.fa-folder-open')).toBeDefined();
|
||||
vm.$destroy();
|
||||
|
||||
vm = createComponent(ITEM_TYPE.GROUP);
|
||||
vm.$mount();
|
||||
expect(vm.$el.querySelector('i.fa.fa-folder')).toBeDefined();
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render bookmark icon based on `isProject` prop value', () => {
|
||||
let vm;
|
||||
|
||||
vm = createComponent(ITEM_TYPE.PROJECT);
|
||||
vm.$mount();
|
||||
expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(1);
|
||||
vm.$destroy();
|
||||
|
||||
vm = createComponent(ITEM_TYPE.GROUP);
|
||||
vm.$mount();
|
||||
expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(0);
|
||||
vm.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
|
|
42
spec/javascripts/groups/service/groups_service_spec.js
Normal file
42
spec/javascripts/groups/service/groups_service_spec.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
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',
|
||||
archived: true,
|
||||
};
|
||||
|
||||
service.getGroups(55, 2, 'git', 'created_asc', true);
|
||||
expect(service.groups.get).toHaveBeenCalledWith({ parent_id: 55 });
|
||||
|
||||
service.getGroups(null, 2, 'git', 'created_asc', true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
110
spec/javascripts/groups/store/groups_store_spec.js
Normal file
110
spec/javascripts/groups/store/groups_store_spec.js
Normal file
|
@ -0,0 +1,110 @@
|
|||
import GroupsStore from '~/groups/store/groups_store';
|
||||
import {
|
||||
mockGroups, mockSearchedGroups,
|
||||
mockParentGroupItem, mockRawChildren,
|
||||
mockRawPageInfo,
|
||||
} from '../mock_data';
|
||||
|
||||
describe('ProjectsStore', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize default state', () => {
|
||||
let store;
|
||||
|
||||
store = new GroupsStore();
|
||||
expect(Object.keys(store.state).length).toBe(2);
|
||||
expect(Array.isArray(store.state.groups)).toBeTruthy();
|
||||
expect(Object.keys(store.state.pageInfo).length).toBe(0);
|
||||
expect(store.hideProjects).not.toBeDefined();
|
||||
|
||||
store = new GroupsStore(true);
|
||||
expect(store.hideProjects).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGroups', () => {
|
||||
it('should set groups to state', () => {
|
||||
const store = new GroupsStore();
|
||||
spyOn(store, 'formatGroupItem').and.callThrough();
|
||||
|
||||
store.setGroups(mockGroups);
|
||||
expect(store.state.groups.length).toBe(mockGroups.length);
|
||||
expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
|
||||
expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSearchedGroups', () => {
|
||||
it('should set searched groups to state', () => {
|
||||
const store = new GroupsStore();
|
||||
spyOn(store, 'formatGroupItem').and.callThrough();
|
||||
|
||||
store.setSearchedGroups(mockSearchedGroups);
|
||||
expect(store.state.groups.length).toBe(mockSearchedGroups.length);
|
||||
expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
|
||||
expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
|
||||
expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName') > -1).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGroupChildren', () => {
|
||||
it('should set children to group item in state', () => {
|
||||
const store = new GroupsStore();
|
||||
spyOn(store, 'formatGroupItem').and.callThrough();
|
||||
|
||||
store.setGroupChildren(mockParentGroupItem, mockRawChildren);
|
||||
expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
|
||||
expect(mockParentGroupItem.children.length).toBe(1);
|
||||
expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName') > -1).toBeTruthy();
|
||||
expect(mockParentGroupItem.isOpen).toBeTruthy();
|
||||
expect(mockParentGroupItem.isChildrenLoading).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPaginationInfo', () => {
|
||||
it('should parse and set pagination info in state', () => {
|
||||
const store = new GroupsStore();
|
||||
|
||||
store.setPaginationInfo(mockRawPageInfo);
|
||||
expect(store.state.pageInfo.perPage).toBe(10);
|
||||
expect(store.state.pageInfo.page).toBe(10);
|
||||
expect(store.state.pageInfo.total).toBe(10);
|
||||
expect(store.state.pageInfo.totalPages).toBe(10);
|
||||
expect(store.state.pageInfo.nextPage).toBe(10);
|
||||
expect(store.state.pageInfo.previousPage).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGroupItem', () => {
|
||||
it('should parse group item object and return updated object', () => {
|
||||
let store;
|
||||
let updatedGroupItem;
|
||||
|
||||
store = new GroupsStore();
|
||||
updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
|
||||
expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
|
||||
expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
|
||||
expect(updatedGroupItem.isChildrenLoading).toBe(false);
|
||||
expect(updatedGroupItem.isBeingRemoved).toBe(false);
|
||||
|
||||
store = new GroupsStore(true);
|
||||
updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
|
||||
expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
|
||||
expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeGroup', () => {
|
||||
it('should remove children from group item in state', () => {
|
||||
const store = new GroupsStore();
|
||||
const rawParentGroup = Object.assign({}, mockGroups[0]);
|
||||
const rawChildGroup = Object.assign({}, mockGroups[1]);
|
||||
|
||||
store.setGroups([rawParentGroup]);
|
||||
store.setGroupChildren(store.state.groups[0], [rawChildGroup]);
|
||||
const childItem = store.state.groups[0].children[0];
|
||||
|
||||
store.removeGroup(childItem, store.state.groups[0]);
|
||||
expect(store.state.groups[0].children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -18,6 +18,12 @@ describe Gitlab::GroupHierarchy, :postgresql do
|
|||
expect(relation).to include(parent, child1)
|
||||
end
|
||||
|
||||
it 'can find ancestors upto a certain level' do
|
||||
relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1)
|
||||
|
||||
expect(relation).to contain_exactly(child2)
|
||||
end
|
||||
|
||||
it 'uses ancestors_base #initialize argument' do
|
||||
relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors
|
||||
|
||||
|
@ -55,6 +61,28 @@ describe Gitlab::GroupHierarchy, :postgresql do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#descendants' do
|
||||
it 'includes only the descendants' do
|
||||
relation = described_class.new(Group.where(id: parent)).descendants
|
||||
|
||||
expect(relation).to contain_exactly(child1, child2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#ancestors' do
|
||||
it 'includes only the ancestors' do
|
||||
relation = described_class.new(Group.where(id: child2)).ancestors
|
||||
|
||||
expect(relation).to contain_exactly(child1, parent)
|
||||
end
|
||||
|
||||
it 'can find ancestors upto a certain level' do
|
||||
relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1)
|
||||
|
||||
expect(relation).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '#all_groups' do
|
||||
let(:relation) do
|
||||
described_class.new(Group.where(id: child1.id)).all_groups
|
||||
|
|
46
spec/lib/gitlab/multi_collection_paginator_spec.rb
Normal file
46
spec/lib/gitlab/multi_collection_paginator_spec.rb
Normal file
|
@ -0,0 +1,46 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::MultiCollectionPaginator do
|
||||
subject(:paginator) { described_class.new(Project.all.order(:id), Group.all.order(:id), per_page: 3) }
|
||||
|
||||
it 'combines both collections' do
|
||||
project = create(:project)
|
||||
group = create(:group)
|
||||
|
||||
expect(paginator.paginate(1)).to eq([project, group])
|
||||
end
|
||||
|
||||
it 'includes elements second collection if first collection is empty' do
|
||||
group = create(:group)
|
||||
|
||||
expect(paginator.paginate(1)).to eq([group])
|
||||
end
|
||||
|
||||
context 'with a full first page' do
|
||||
let!(:all_groups) { create_list(:group, 4) }
|
||||
let!(:all_projects) { create_list(:project, 4) }
|
||||
|
||||
it 'knows the total count of the collection' do
|
||||
expect(paginator.total_count).to eq(8)
|
||||
end
|
||||
|
||||
it 'fills the first page with elements of the first collection' do
|
||||
expect(paginator.paginate(1)).to eq(all_projects.take(3))
|
||||
end
|
||||
|
||||
it 'fils the second page with a mixture of of the first & second collection' do
|
||||
first_collection_element = all_projects.last
|
||||
second_collection_elements = all_groups.take(2)
|
||||
|
||||
expected_collection = [first_collection_element] + second_collection_elements
|
||||
|
||||
expect(paginator.paginate(2)).to eq(expected_collection)
|
||||
end
|
||||
|
||||
it 'fils the last page with elements from the second collection' do
|
||||
expected_collection = all_groups[-2..-1]
|
||||
|
||||
expect(paginator.paginate(3)).to eq(expected_collection)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -213,7 +213,7 @@ describe Gitlab::PathRegex do
|
|||
it 'accepts group routes' do
|
||||
expect(subject).to match('activity/')
|
||||
expect(subject).to match('group_members/')
|
||||
expect(subject).to match('subgroups/')
|
||||
expect(subject).to match('labels/')
|
||||
end
|
||||
|
||||
it 'is not case sensitive' do
|
||||
|
@ -246,7 +246,7 @@ describe Gitlab::PathRegex do
|
|||
it 'accepts group routes' do
|
||||
expect(subject).to match('activity/')
|
||||
expect(subject).to match('group_members/')
|
||||
expect(subject).to match('subgroups/')
|
||||
expect(subject).to match('labels/')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -268,7 +268,7 @@ describe Gitlab::PathRegex do
|
|||
it 'accepts group routes' do
|
||||
expect(subject).to match('activity/more/')
|
||||
expect(subject).to match('group_members/more/')
|
||||
expect(subject).to match('subgroups/more/')
|
||||
expect(subject).to match('labels/more/')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -292,7 +292,7 @@ describe Gitlab::PathRegex do
|
|||
it 'rejects group routes' do
|
||||
expect(subject).not_to match('root/activity/')
|
||||
expect(subject).not_to match('root/group_members/')
|
||||
expect(subject).not_to match('root/subgroups/')
|
||||
expect(subject).not_to match('root/labels/')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -314,7 +314,7 @@ describe Gitlab::PathRegex do
|
|||
it 'rejects group routes' do
|
||||
expect(subject).not_to match('root/activity/more/')
|
||||
expect(subject).not_to match('root/group_members/more/')
|
||||
expect(subject).not_to match('root/subgroups/more/')
|
||||
expect(subject).not_to match('root/labels/more/')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -349,7 +349,7 @@ describe Gitlab::PathRegex do
|
|||
it 'accepts group routes' do
|
||||
expect(subject).to match('activity/')
|
||||
expect(subject).to match('group_members/')
|
||||
expect(subject).to match('subgroups/')
|
||||
expect(subject).to match('labels/')
|
||||
end
|
||||
|
||||
it 'is not case sensitive' do
|
||||
|
@ -382,7 +382,7 @@ describe Gitlab::PathRegex do
|
|||
it 'accepts group routes' do
|
||||
expect(subject).to match('root/activity/')
|
||||
expect(subject).to match('root/group_members/')
|
||||
expect(subject).to match('root/subgroups/')
|
||||
expect(subject).to match('root/labels/')
|
||||
end
|
||||
|
||||
it 'is not case sensitive' do
|
||||
|
|
|
@ -29,5 +29,12 @@ describe Gitlab::SQL::Union do
|
|||
|
||||
expect(union.to_sql).to include('UNION ALL')
|
||||
end
|
||||
|
||||
it 'returns `NULL` if all relations are empty' do
|
||||
empty_relation = User.none
|
||||
union = described_class.new([empty_relation, empty_relation])
|
||||
|
||||
expect(union.to_sql).to eq('NULL')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
33
spec/lib/gitlab/utils/merge_hash_spec.rb
Normal file
33
spec/lib/gitlab/utils/merge_hash_spec.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
require 'spec_helper'
|
||||
describe Gitlab::Utils::MergeHash do
|
||||
describe '.crush' do
|
||||
it 'can flatten a hash to each element' do
|
||||
input = { hello: "world", this: { crushes: ["an entire", "hash"] } }
|
||||
expected_result = [:hello, "world", :this, :crushes, "an entire", "hash"]
|
||||
|
||||
expect(described_class.crush(input)).to eq(expected_result)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.elements' do
|
||||
it 'deep merges an array of elements' do
|
||||
input = [{ hello: ["world"] },
|
||||
{ hello: "Everyone" },
|
||||
{ hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] } },
|
||||
"Goodbye", "Hallo"]
|
||||
expected_output = [
|
||||
{
|
||||
hello:
|
||||
[
|
||||
"world",
|
||||
"Everyone",
|
||||
{ greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] }
|
||||
]
|
||||
},
|
||||
"Goodbye"
|
||||
]
|
||||
|
||||
expect(described_class.merge(input)).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
end
|
166
spec/models/concerns/group_descendant_spec.rb
Normal file
166
spec/models/concerns/group_descendant_spec.rb
Normal file
|
@ -0,0 +1,166 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe GroupDescendant, :nested_groups do
|
||||
let(:parent) { create(:group) }
|
||||
let(:subgroup) { create(:group, parent: parent) }
|
||||
let(:subsub_group) { create(:group, parent: subgroup) }
|
||||
|
||||
def all_preloaded_groups(*groups)
|
||||
groups + [parent, subgroup, subsub_group]
|
||||
end
|
||||
|
||||
context 'for a group' do
|
||||
describe '#hierarchy' do
|
||||
it 'only queries once for the ancestors' do
|
||||
# make sure the subsub_group does not have anything cached
|
||||
test_group = create(:group, parent: subsub_group).reload
|
||||
|
||||
query_count = ActiveRecord::QueryRecorder.new { test_group.hierarchy }.count
|
||||
|
||||
expect(query_count).to eq(1)
|
||||
end
|
||||
|
||||
it 'only queries once for the ancestors when a top is given' do
|
||||
test_group = create(:group, parent: subsub_group).reload
|
||||
|
||||
recorder = ActiveRecord::QueryRecorder.new { test_group.hierarchy(subgroup) }
|
||||
expect(recorder.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'builds a hierarchy for a group' do
|
||||
expected_hierarchy = { parent => { subgroup => subsub_group } }
|
||||
|
||||
expect(subsub_group.hierarchy).to eq(expected_hierarchy)
|
||||
end
|
||||
|
||||
it 'builds a hierarchy upto a specified parent' do
|
||||
expected_hierarchy = { subgroup => subsub_group }
|
||||
|
||||
expect(subsub_group.hierarchy(parent)).to eq(expected_hierarchy)
|
||||
end
|
||||
|
||||
it 'raises an error if specifying a base that is not part of the tree' do
|
||||
expect { subsub_group.hierarchy(build_stubbed(:group)) }
|
||||
.to raise_error('specified top is not part of the tree')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.build_hierarchy' do
|
||||
it 'combines hierarchies until the top' do
|
||||
other_subgroup = create(:group, parent: parent)
|
||||
other_subsub_group = create(:group, parent: subgroup)
|
||||
|
||||
groups = all_preloaded_groups(other_subgroup, subsub_group, other_subsub_group)
|
||||
|
||||
expected_hierarchy = { parent => [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }] }
|
||||
|
||||
expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
|
||||
end
|
||||
|
||||
it 'combines upto a given parent' do
|
||||
other_subgroup = create(:group, parent: parent)
|
||||
other_subsub_group = create(:group, parent: subgroup)
|
||||
|
||||
groups = [other_subgroup, subsub_group, other_subsub_group]
|
||||
groups << subgroup # Add the parent as if it was preloaded
|
||||
|
||||
expected_hierarchy = [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }]
|
||||
expect(described_class.build_hierarchy(groups, parent)).to eq(expected_hierarchy)
|
||||
end
|
||||
|
||||
it 'handles building a tree out of order' do
|
||||
other_subgroup = create(:group, parent: parent)
|
||||
other_subgroup2 = create(:group, parent: parent)
|
||||
other_subsub_group = create(:group, parent: other_subgroup)
|
||||
|
||||
groups = all_preloaded_groups(subsub_group, other_subgroup2, other_subsub_group, other_subgroup)
|
||||
expected_hierarchy = { parent => [{ subgroup => subsub_group }, other_subgroup2, { other_subgroup => other_subsub_group }] }
|
||||
|
||||
expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
|
||||
end
|
||||
|
||||
it 'raises an error if not all elements were preloaded' do
|
||||
expect { described_class.build_hierarchy([subsub_group]) }
|
||||
.to raise_error('parent was not preloaded')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for a project' do
|
||||
let(:project) { create(:project, namespace: subsub_group) }
|
||||
|
||||
describe '#hierarchy' do
|
||||
it 'builds a hierarchy for a project' do
|
||||
expected_hierarchy = { parent => { subgroup => { subsub_group => project } } }
|
||||
|
||||
expect(project.hierarchy).to eq(expected_hierarchy)
|
||||
end
|
||||
|
||||
it 'builds a hierarchy upto a specified parent' do
|
||||
expected_hierarchy = { subsub_group => project }
|
||||
|
||||
expect(project.hierarchy(subgroup)).to eq(expected_hierarchy)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.build_hierarchy' do
|
||||
it 'combines hierarchies until the top' do
|
||||
other_project = create(:project, namespace: parent)
|
||||
other_subgroup_project = create(:project, namespace: subgroup)
|
||||
|
||||
elements = all_preloaded_groups(other_project, subsub_group, other_subgroup_project)
|
||||
|
||||
expected_hierarchy = { parent => [other_project, { subgroup => [subsub_group, other_subgroup_project] }] }
|
||||
|
||||
expect(described_class.build_hierarchy(elements)).to eq(expected_hierarchy)
|
||||
end
|
||||
|
||||
it 'combines upto a given parent' do
|
||||
other_project = create(:project, namespace: parent)
|
||||
other_subgroup_project = create(:project, namespace: subgroup)
|
||||
|
||||
elements = [other_project, subsub_group, other_subgroup_project]
|
||||
elements << subgroup # Added as if it was preloaded
|
||||
|
||||
expected_hierarchy = [other_project, { subgroup => [subsub_group, other_subgroup_project] }]
|
||||
|
||||
expect(described_class.build_hierarchy(elements, parent)).to eq(expected_hierarchy)
|
||||
end
|
||||
|
||||
it 'merges to elements in the same hierarchy' do
|
||||
expected_hierarchy = { parent => subgroup }
|
||||
|
||||
expect(described_class.build_hierarchy([parent, subgroup])).to eq(expected_hierarchy)
|
||||
end
|
||||
|
||||
it 'merges complex hierarchies' do
|
||||
project = create(:project, namespace: parent)
|
||||
sub_project = create(:project, namespace: subgroup)
|
||||
subsubsub_group = create(:group, parent: subsub_group)
|
||||
subsub_project = create(:project, namespace: subsub_group)
|
||||
subsubsub_project = create(:project, namespace: subsubsub_group)
|
||||
other_subgroup = create(:group, parent: parent)
|
||||
other_subproject = create(:project, namespace: other_subgroup)
|
||||
|
||||
elements = [project, subsubsub_project, sub_project, other_subproject, subsub_project]
|
||||
# Add parent groups as if they were preloaded
|
||||
elements += [other_subgroup, subsubsub_group, subsub_group, subgroup]
|
||||
|
||||
expected_hierarchy = [
|
||||
project,
|
||||
{
|
||||
subgroup => [
|
||||
{ subsub_group => [{ subsubsub_group => subsubsub_project }, subsub_project] },
|
||||
sub_project
|
||||
]
|
||||
},
|
||||
{ other_subgroup => other_subproject }
|
||||
]
|
||||
|
||||
actual_hierarchy = described_class.build_hierarchy(elements, parent)
|
||||
|
||||
expect(actual_hierarchy).to eq(expected_hierarchy)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
49
spec/models/concerns/loaded_in_group_list_spec.rb
Normal file
49
spec/models/concerns/loaded_in_group_list_spec.rb
Normal file
|
@ -0,0 +1,49 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe LoadedInGroupList do
|
||||
let(:parent) { create(:group) }
|
||||
subject(:found_group) { Group.with_selects_for_list.find_by(id: parent.id) }
|
||||
|
||||
describe '.with_selects_for_list' do
|
||||
it 'includes the preloaded counts for groups' do
|
||||
create(:group, parent: parent)
|
||||
create(:project, namespace: parent)
|
||||
parent.add_developer(create(:user))
|
||||
|
||||
found_group = Group.with_selects_for_list.find_by(id: parent.id)
|
||||
|
||||
expect(found_group.preloaded_project_count).to eq(1)
|
||||
expect(found_group.preloaded_subgroup_count).to eq(1)
|
||||
expect(found_group.preloaded_member_count).to eq(1)
|
||||
end
|
||||
|
||||
context 'with archived projects' do
|
||||
it 'counts including archived projects when `true` is passed' do
|
||||
create(:project, namespace: parent, archived: true)
|
||||
create(:project, namespace: parent)
|
||||
|
||||
found_group = Group.with_selects_for_list(archived: 'true').find_by(id: parent.id)
|
||||
|
||||
expect(found_group.preloaded_project_count).to eq(2)
|
||||
end
|
||||
|
||||
it 'counts only archived projects when `only` is passed' do
|
||||
create_list(:project, 2, namespace: parent, archived: true)
|
||||
create(:project, namespace: parent)
|
||||
|
||||
found_group = Group.with_selects_for_list(archived: 'only').find_by(id: parent.id)
|
||||
|
||||
expect(found_group.preloaded_project_count).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#children_count' do
|
||||
it 'counts groups and projects' do
|
||||
create(:group, parent: parent)
|
||||
create(:project, namespace: parent)
|
||||
|
||||
expect(found_group.children_count).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -153,6 +153,20 @@ describe Namespace do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#ancestors_upto', :nested_groups do
|
||||
let(:parent) { create(:group) }
|
||||
let(:child) { create(:group, parent: parent) }
|
||||
let(:child2) { create(:group, parent: child) }
|
||||
|
||||
it 'returns all ancestors when no namespace is given' do
|
||||
expect(child2.ancestors_upto).to contain_exactly(child, parent)
|
||||
end
|
||||
|
||||
it 'includes ancestors upto but excluding the given ancestor' do
|
||||
expect(child2.ancestors_upto(parent)).to contain_exactly(child)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#move_dir' do
|
||||
let(:namespace) { create(:namespace) }
|
||||
let!(:project) { create(:project_empty_repo, namespace: namespace) }
|
||||
|
|
|
@ -1761,6 +1761,21 @@ describe Project do
|
|||
it { expect(project.gitea_import?).to be true }
|
||||
end
|
||||
|
||||
describe '#ancestors_upto', :nested_groups do
|
||||
let(:parent) { create(:group) }
|
||||
let(:child) { create(:group, parent: parent) }
|
||||
let(:child2) { create(:group, parent: child) }
|
||||
let(:project) { create(:project, namespace: child2) }
|
||||
|
||||
it 'returns all ancestors when no namespace is given' do
|
||||
expect(project.ancestors_upto).to contain_exactly(child2, child, parent)
|
||||
end
|
||||
|
||||
it 'includes ancestors upto but excluding the given ancestor' do
|
||||
expect(project.ancestors_upto(parent)).to contain_exactly(child2, child)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#lfs_enabled?' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
|
@ -2178,6 +2193,12 @@ describe Project do
|
|||
it { expect(project.parent).to eq(project.namespace) }
|
||||
end
|
||||
|
||||
describe '#parent_id' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
it { expect(project.parent_id).to eq(project.namespace_id) }
|
||||
end
|
||||
|
||||
describe '#parent_changed?' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
|
|
101
spec/serializers/group_child_entity_spec.rb
Normal file
101
spec/serializers/group_child_entity_spec.rb
Normal file
|
@ -0,0 +1,101 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe GroupChildEntity do
|
||||
include Gitlab::Routing.url_helpers
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:request) { double('request') }
|
||||
let(:entity) { described_class.new(object, request: request) }
|
||||
subject(:json) { entity.as_json }
|
||||
|
||||
before do
|
||||
allow(request).to receive(:current_user).and_return(user)
|
||||
end
|
||||
|
||||
shared_examples 'group child json' do
|
||||
it 'renders json' do
|
||||
is_expected.not_to be_nil
|
||||
end
|
||||
|
||||
%w[id
|
||||
full_name
|
||||
avatar_url
|
||||
name
|
||||
description
|
||||
visibility
|
||||
type
|
||||
can_edit
|
||||
visibility
|
||||
permission
|
||||
relative_path].each do |attribute|
|
||||
it "includes #{attribute}" do
|
||||
expect(json[attribute.to_sym]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'for a project' do
|
||||
let(:object) do
|
||||
create(:project, :with_avatar,
|
||||
description: 'Awesomeness')
|
||||
end
|
||||
|
||||
before do
|
||||
object.add_master(user)
|
||||
end
|
||||
|
||||
it 'has the correct type' do
|
||||
expect(json[:type]).to eq('project')
|
||||
end
|
||||
|
||||
it 'includes the star count' do
|
||||
expect(json[:star_count]).to be_present
|
||||
end
|
||||
|
||||
it 'has the correct edit path' do
|
||||
expect(json[:edit_path]).to eq(edit_project_path(object))
|
||||
end
|
||||
|
||||
it_behaves_like 'group child json'
|
||||
end
|
||||
|
||||
describe 'for a group', :nested_groups do
|
||||
let(:object) do
|
||||
create(:group, :nested, :with_avatar,
|
||||
description: 'Awesomeness')
|
||||
end
|
||||
|
||||
before do
|
||||
object.add_owner(user)
|
||||
end
|
||||
|
||||
it 'has the correct type' do
|
||||
expect(json[:type]).to eq('group')
|
||||
end
|
||||
|
||||
it 'counts projects and subgroups as children' do
|
||||
create(:project, namespace: object)
|
||||
create(:group, parent: object)
|
||||
|
||||
expect(json[:children_count]).to eq(2)
|
||||
end
|
||||
|
||||
%w[children_count leave_path parent_id number_projects_with_delimiter number_users_with_delimiter project_count subgroup_count].each do |attribute|
|
||||
it "includes #{attribute}" do
|
||||
expect(json[attribute.to_sym]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows an owner to leave when there is another one' do
|
||||
object.add_owner(create(:user))
|
||||
|
||||
expect(json[:can_leave]).to be_truthy
|
||||
end
|
||||
|
||||
it 'has the correct edit path' do
|
||||
expect(json[:edit_path]).to eq(edit_group_path(object))
|
||||
end
|
||||
|
||||
it_behaves_like 'group child json'
|
||||
end
|
||||
end
|
110
spec/serializers/group_child_serializer_spec.rb
Normal file
110
spec/serializers/group_child_serializer_spec.rb
Normal file
|
@ -0,0 +1,110 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe GroupChildSerializer do
|
||||
let(:request) { double('request') }
|
||||
let(:user) { create(:user) }
|
||||
subject(:serializer) { described_class.new(current_user: user) }
|
||||
|
||||
describe '#represent' do
|
||||
context 'for groups' do
|
||||
it 'can render a single group' do
|
||||
expect(serializer.represent(build(:group))).to be_kind_of(Hash)
|
||||
end
|
||||
|
||||
it 'can render a collection of groups' do
|
||||
expect(serializer.represent(build_list(:group, 2))).to be_kind_of(Array)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a hierarchy', :nested_groups do
|
||||
let(:parent) { create(:group) }
|
||||
|
||||
subject(:serializer) do
|
||||
described_class.new(current_user: user).expand_hierarchy(parent)
|
||||
end
|
||||
|
||||
it 'expands the subgroups' do
|
||||
subgroup = create(:group, parent: parent)
|
||||
subsub_group = create(:group, parent: subgroup)
|
||||
|
||||
json = serializer.represent([subgroup, subsub_group]).first
|
||||
subsub_group_json = json[:children].first
|
||||
|
||||
expect(json[:id]).to eq(subgroup.id)
|
||||
expect(subsub_group_json).not_to be_nil
|
||||
expect(subsub_group_json[:id]).to eq(subsub_group.id)
|
||||
end
|
||||
|
||||
it 'can render a nested tree' do
|
||||
subgroup1 = create(:group, parent: parent)
|
||||
subsub_group1 = create(:group, parent: subgroup1)
|
||||
subgroup2 = create(:group, parent: parent)
|
||||
|
||||
json = serializer.represent([subgroup1, subsub_group1, subgroup1, subgroup2])
|
||||
subgroup1_json = json.first
|
||||
subsub_group1_json = subgroup1_json[:children].first
|
||||
|
||||
expect(json.size).to eq(2)
|
||||
expect(subgroup1_json[:id]).to eq(subgroup1.id)
|
||||
expect(subsub_group1_json[:id]).to eq(subsub_group1.id)
|
||||
end
|
||||
|
||||
context 'without a specified parent' do
|
||||
subject(:serializer) do
|
||||
described_class.new(current_user: user).expand_hierarchy
|
||||
end
|
||||
|
||||
it 'can render a tree' do
|
||||
subgroup = create(:group, parent: parent)
|
||||
|
||||
json = serializer.represent([parent, subgroup])
|
||||
parent_json = json.first
|
||||
|
||||
expect(parent_json[:id]).to eq(parent.id)
|
||||
expect(parent_json[:children].first[:id]).to eq(subgroup.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for projects' do
|
||||
it 'can render a single project' do
|
||||
expect(serializer.represent(build(:project))).to be_kind_of(Hash)
|
||||
end
|
||||
|
||||
it 'can render a collection of projects' do
|
||||
expect(serializer.represent(build_list(:project, 2))).to be_kind_of(Array)
|
||||
end
|
||||
|
||||
context 'with a hierarchy', :nested_groups do
|
||||
let(:parent) { create(:group) }
|
||||
|
||||
subject(:serializer) do
|
||||
described_class.new(current_user: user).expand_hierarchy(parent)
|
||||
end
|
||||
|
||||
it 'can render a nested tree' do
|
||||
subgroup1 = create(:group, parent: parent)
|
||||
project1 = create(:project, namespace: subgroup1)
|
||||
subgroup2 = create(:group, parent: parent)
|
||||
project2 = create(:project, namespace: subgroup2)
|
||||
|
||||
json = serializer.represent([project1, project2, subgroup1, subgroup2])
|
||||
project1_json, project2_json = json.map { |group_json| group_json[:children].first }
|
||||
|
||||
expect(json.size).to eq(2)
|
||||
expect(project1_json[:id]).to eq(project1.id)
|
||||
expect(project2_json[:id]).to eq(project2.id)
|
||||
end
|
||||
|
||||
it 'returns an array when an array of a single instance was given' do
|
||||
project = create(:project, namespace: parent)
|
||||
|
||||
json = serializer.represent([project])
|
||||
|
||||
expect(json).to be_kind_of(Array)
|
||||
expect(json.size).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue