Commit 79e88912 authored by Douwe Maan's avatar Douwe Maan

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
parents 3fa410c8 893402d4
...@@ -73,6 +73,7 @@ import initProjectVisibilitySelector from './project_visibility'; ...@@ -73,6 +73,7 @@ import initProjectVisibilitySelector from './project_visibility';
import GpgBadges from './gpg_badges'; import GpgBadges from './gpg_badges';
import UserFeatureHelper from './helpers/user_feature_helper'; import UserFeatureHelper from './helpers/user_feature_helper';
import initChangesDropdown from './init_changes_dropdown'; import initChangesDropdown from './init_changes_dropdown';
import NewGroupChild from './groups/new_group_child';
import AbuseReports from './abuse_reports'; import AbuseReports from './abuse_reports';
import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils'; import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
import AjaxLoadingSpinner from './ajax_loading_spinner'; import AjaxLoadingSpinner from './ajax_loading_spinner';
...@@ -392,10 +393,15 @@ import memberExpirationDate from './member_expiration_date'; ...@@ -392,10 +393,15 @@ import memberExpirationDate from './member_expiration_date';
new gl.Activities(); new gl.Activities();
break; break;
case 'groups:show': case 'groups:show':
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new NotificationsForm(); new NotificationsForm();
new NotificationsDropdown(); new NotificationsDropdown();
new ProjectsList(); new ProjectsList();
if (newGroupChildWrapper) {
new NewGroupChild(newGroupChildWrapper);
}
break; break;
case 'groups:group_members:index': case 'groups:group_members:index':
memberExpirationDate(); memberExpirationDate();
......
...@@ -6,10 +6,11 @@ import _ from 'underscore'; ...@@ -6,10 +6,11 @@ import _ from 'underscore';
*/ */
export default class FilterableList { export default class FilterableList {
constructor(form, filter, holder) { constructor(form, filter, holder, filterInputField = 'filter_groups') {
this.filterForm = form; this.filterForm = form;
this.listFilterElement = filter; this.listFilterElement = filter;
this.listHolderElement = holder; this.listHolderElement = holder;
this.filterInputField = filterInputField;
this.isBusy = false; this.isBusy = false;
} }
...@@ -32,10 +33,10 @@ export default class FilterableList { ...@@ -32,10 +33,10 @@ export default class FilterableList {
onFilterInput() { onFilterInput() {
const $form = $(this.filterForm); const $form = $(this.filterForm);
const queryData = {}; const queryData = {};
const filterGroupsParam = $form.find('[name="filter_groups"]').val(); const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) { if (filterGroupsParam) {
queryData.filter_groups = filterGroupsParam; queryData[this.filterInputField] = filterGroupsParam;
} }
this.filterResults(queryData); this.filterResults(queryData);
......
<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>
<script> <script>
import { n__ } from '../../locale';
import { MAX_CHILDREN_COUNT } from '../constants';
export default { export default {
props: { props: {
groups: { parentGroup: {
type: Object,
required: true,
},
baseGroup: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), 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> </script>
...@@ -20,8 +32,20 @@ export default { ...@@ -20,8 +32,20 @@ export default {
v-for="(group, index) in groups" v-for="(group, index) in groups"
:key="index" :key="index"
:group="group" :group="group"
:base-group="baseGroup" :parent-group="parentGroup"
:collection="groups" />
<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> </ul>
</template> </template>
...@@ -2,49 +2,28 @@ ...@@ -2,49 +2,28 @@
import identicon from '../../vue_shared/components/identicon.vue'; import identicon from '../../vue_shared/components/identicon.vue';
import eventHub from '../event_hub'; 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 { export default {
components: { components: {
identicon, identicon,
itemCaret,
itemTypeIcon,
itemStats,
itemActions,
}, },
props: { props: {
group: { parentGroup: {
type: Object,
required: true,
},
baseGroup: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
collection: { group: {
type: Object, type: Object,
required: false, required: true,
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: { computed: {
...@@ -53,51 +32,33 @@ export default { ...@@ -53,51 +32,33 @@ export default {
}, },
rowClass() { rowClass() {
return { return {
'group-row': true,
'is-open': this.group.isOpen, 'is-open': this.group.isOpen,
'has-subgroups': this.group.hasSubgroups, 'has-children': this.hasChildren,
'no-description': !this.group.description, 'has-description': this.group.description,
'being-removed': this.group.isBeingRemoved,
}; };
}, },
visibilityIcon() { hasChildren() {
return { return this.group.childrenCount > 0;
fa: true,
'fa-globe': this.group.visibility === 'public',
'fa-shield': this.group.visibility === 'internal',
'fa-lock': this.group.visibility === 'private',
};
}, },
fullPath() { hasAvatar() {
let fullPath = ''; return this.group.avatarUrl !== null;
},
if (this.group.isOrphan) { isGroup() {
// check if current group is baseGroup return this.group.type === 'group';
if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) { },
// Remove baseGroup prefix from our current group.fullName. e.g: },
// baseGroup.fullName: `level1` methods: {
// group.fullName: `level1 / level2 / level3` onClickRowGroup(e) {
// Result: `level2 / level3` const NO_EXPAND_CLS = 'no-expand';
const gfn = this.group.fullName; if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
const bfn = this.baseGroup.fullName; e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
const length = bfn.length; if (this.hasChildren) {
const start = gfn.indexOf(bfn); eventHub.$emit('toggleChildren', this.group);
const extraPrefixChars = 3;
fullPath = gfn.substr(start + length + extraPrefixChars);
} else { } else {
fullPath = this.group.fullName; gl.utils.visitUrl(this.group.relativePath);
} }
} else {
fullPath = this.group.name;
} }
return fullPath;
},
hasGroups() {
return Object.keys(this.group.subGroups).length > 0;
},
hasAvatar() {
return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1;
}, },
}, },
}; };
...@@ -108,98 +69,36 @@ export default { ...@@ -108,98 +69,36 @@ export default {
@click.stop="onClickRowGroup" @click.stop="onClickRowGroup"
:id="groupDomId" :id="groupDomId"
:class="rowClass" :class="rowClass"
class="group-row"
> >
<div <div
class="group-row-contents"> class="group-row-contents">
<div <item-actions
class="controls"> v-if="isGroup"
<a :group="group"
v-if="group.canEdit" :parent-group="parentGroup"
class="edit-group btn" />
:href="group.editPath"> <item-stats
<i :item="group"
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>
<div <div
class="folder-toggle-wrap"> class="folder-toggle-wrap">
<span <item-caret
class="folder-caret" :is-group-open="group.isOpen"
v-if="group.hasSubgroups"> />
<i <item-type-icon
v-if="group.isOpen" :item-type="group.type"
class="fa fa-caret-down" :is-group-open="group.isOpen"
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>
</div> </div>
<div <div
class="avatar-container s40 hidden-xs"> class="avatar-container s40 hidden-xs"
:class="{ 'content-loading': group.isChildrenLoading }"
>
<a <a
:href="group.groupPath"> :href="group.relativePath"
class="no-expand"
>
<img <img
v-if="hasAvatar" v-if="hasAvatar"
class="avatar s40" class="avatar s40"
...@@ -215,19 +114,22 @@ export default { ...@@ -215,19 +114,22 @@ export default {
<div <div
class="title"> class="title">
<a <a
:href="group.groupPath">{{fullPath}}</a> :href="group.relativePath"
<template v-if="group.permissions.humanGroupAccess"> class="no-expand">{{group.fullName}}</a>
as <span
<span class="access-type">{{group.permissions.humanGroupAccess}}</span> v-if="group.permission"
</template> class="access-type"
>
{{s__('GroupsTreeRole|as')}} {{group.permission}}
</span>
</div> </div>
<div <div
class="description">{{group.description}}</div> class="description">{{group.description}}</div>
</div> </div>
<group-folder <group-folder
v-if="group.isOpen && hasGroups" v-if="group.isOpen && hasChildren"
:groups="group.subGroups" :parent-group="group"
:baseGroup="group" :groups="group.children"
/> />
</li> </li>
</template> </template>
...@@ -4,24 +4,33 @@ import eventHub from '../event_hub'; ...@@ -4,24 +4,33 @@ import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils'; import { getParameterByName } from '../../lib/utils/common_utils';
export default { export default {
components: {
tablePagination,
},
props: { props: {
groups: { groups: {
type: Object, type: Array,
required: true, required: true,
}, },
pageInfo: { pageInfo: {
type: Object, type: Object,
required: true, required: true,
}, },
searchEmpty: {
type: Boolean,
required: true,
},
searchEmptyMessage: {
type: String,
required: true,
}, },
components: {
tablePagination,
}, },
methods: { methods: {
change(page) { change(page) {
const filterGroupsParam = getParameterByName('filter_groups'); const filterGroupsParam = getParameterByName('filter_groups');
const sortParam = getParameterByName('sort'); 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 { ...@@ -29,10 +38,17 @@ export default {
<template> <template>
<div class="groups-list-tree-container"> <div class="groups-list-tree-container">
<div
v-if="searchEmpty"
class="has-no-search-results">
{{searchEmptyMessage}}
</div>
<group-folder <group-folder
v-if="!searchEmpty"
:groups="groups" :groups="groups"
/> />
<table-pagination <table-pagination
v-if="!searchEmpty"
:change="change" :change="change"
:pageInfo="pageInfo" :pageInfo="pageInfo"
/> />
......
<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>
<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>
<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>
<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>
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'; ...@@ -3,12 +3,13 @@ import eventHub from './event_hub';
import { getParameterByName } from '../lib/utils/common_utils'; import { getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList { export default class GroupFilterableList extends FilterableList {
constructor({ form, filter, holder, filterEndpoint, pagePath }) { constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
super(form, filter, holder); super(form, filter, holder, filterInputField);
this.form = form; this.form = form;
this.filterEndpoint = filterEndpoint; this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath; this.pagePath = pagePath;
this.$dropdown = $('.js-group-filter-dropdown-wrap'); this.filterInputField = filterInputField;
this.$dropdown = $(dropdownSel);
} }
getFilterEndpoint() { getFilterEndpoint() {
...@@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList { ...@@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList {
bindEvents() { bindEvents() {
super.bindEvents(); super.bindEvents();
this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
this.onFilterOptionClikWrapper = this.onOptionClick.bind(this); this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper); this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
} }
onFormSubmit(e) { onFilterInput() {
e.preventDefault();
const $form = $(this.form);
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
const queryData = {}; const queryData = {};
const $form = $(this.form);
const archivedParam = getParameterByName('archived', window.location.href);
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) { if (filterGroupsParam) {
queryData.filter_groups = filterGroupsParam; queryData[this.filterInputField] = filterGroupsParam;
}
if (archivedParam) {
queryData.archived = archivedParam;
} }
this.filterResults(queryData); this.filterResults(queryData);
if (this.setDefaultFilterOption) {
this.setDefaultFilterOption(); this.setDefaultFilterOption();
} }
}
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); this.$dropdown.find('.dropdown-label').text(defaultOption);
} }
...@@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList { ...@@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList {
e.preventDefault(); e.preventDefault();
const queryData = {}; 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) { if (sortParam) {
queryData.sort = sortParam; queryData.sort = sortParam;
} }
if (archivedParam) {
queryData.archived = archivedParam;
}
this.filterResults(queryData); this.filterResults(queryData);
// Active selected option // Active selected option
if (isOptionFilterBySort) {
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text)); 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 // Clear current value on search form
this.form.querySelector('[name="filter_groups"]').value = ''; this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
} }
onFilterSuccess(data, xhr, queryData) { onFilterSuccess(data, xhr, queryData) {
super.onFilterSuccess(data, xhr, queryData); const currentPath = this.getPagePath(queryData);
const paginationData = { const paginationData = {
'X-Per-Page': xhr.getResponseHeader('X-Per-Page'), 'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
...@@ -82,7 +106,11 @@ export default class GroupFilterableList extends FilterableList { ...@@ -82,7 +106,11 @@ export default class GroupFilterableList extends FilterableList {
'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'), '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); eventHub.$emit('updatePagination', paginationData);
} }
} }
import Vue from 'vue'; import Vue from 'vue';
import Flash from '../flash'; import Translate from '../vue_shared/translate';
import GroupFilterableList from './groups_filterable_list'; import GroupFilterableList from './groups_filterable_list';
import GroupsComponent from './components/groups.vue'; import GroupsStore from './store/groups_store';
import GroupFolder from './components/group_folder.vue'; import GroupsService from './service/groups_service';
import GroupItem from './components/group_item.vue';
import GroupsStore from './stores/groups_store'; import groupsApp from './components/app.vue';
import GroupsService from './services/groups_service'; import groupFolderComponent from './components/group_folder.vue';
import eventHub from './event_hub'; import groupItemComponent from './components/group_item.vue';
import { getParameterByName } from '../lib/utils/common_utils';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => { 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) // Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL // This is for when the user enters directly to the page via URL
...@@ -18,176 +19,56 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -18,176 +19,56 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
Vue.component('groups-component', GroupsComponent); Vue.component('group-folder', groupFolderComponent);
Vue.component('group-folder', GroupFolder); Vue.component('group-item', groupItemComponent);
Vue.component('group-item', GroupItem);
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
components: {
groupsApp,
},
data() { data() {
this.store = new GroupsStore(); const dataset = this.$options.el.dataset;
this.service = new GroupsService(el.dataset.endpoint); const hideProjects = dataset.hideProjects === 'true';
const store = new GroupsStore(hideProjects);
const service = new GroupsService(dataset.endpoint);
return { return {
store: this.store, store,
isLoading: true, service,
state: this.store.state, hideProjects,
loading: true, 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() { beforeMount() {
const dataset = this.$options.el.dataset;
let groupFilterList = null; let groupFilterList = null;
const form = document.querySelector('form#group-filter-form'); const form = document.querySelector(dataset.formSel);
const filter = document.querySelector('.js-groups-list-filter'); const filter = document.querySelector(dataset.filterSel);
const holder = document.querySelector('.js-groups-list-holder'); const holder = document.querySelector(dataset.holderSel);
const opts = { const opts = {
form, form,
filter, filter,
holder, holder,
filterEndpoint: el.dataset.endpoint, filterEndpoint: dataset.endpoint,
pagePath: el.dataset.path, pagePath: dataset.path,
dropdownSel: dataset.dropdownSel,
filterInputField: 'filter',
}; };
groupFilterList = new GroupFilterableList(opts); groupFilterList = new GroupFilterableList(opts);
groupFilterList.initSearch(); groupFilterList.initSearch();
}, },
mounted() { render(createElement) {
this.fetchGroups() return createElement('groups-app', {
.then((response) => { props: {
this.updatePagination(response.headers); store: this.store,
this.isLoading = false; service: this.service,
}) hideProjects: this.hideProjects,
.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);
}, },
}); });
}); });
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 { ...@@ -8,7 +8,7 @@ export default class GroupsService {
this.groups = Vue.resource(endpoint); this.groups = Vue.resource(endpoint);
} }
getGroups(parentId, page, filterGroups, sort) { getGroups(parentId, page, filterGroups, sort, archived) {
const data = {}; const data = {};
if (parentId) { if (parentId) {
...@@ -20,12 +20,16 @@ export default class GroupsService { ...@@ -20,12 +20,16 @@ export default class GroupsService {
} }
if (filterGroups) { if (filterGroups) {
data.filter_groups = filterGroups; data.filter = filterGroups;
} }
if (sort) { if (sort) {
data.sort = sort; data.sort = sort;
} }
if (archived) {
data.archived = archived;
}
} }
return this.groups.get(data); return this.groups.get(data);
......
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);
}
}
}
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 { ...@@ -281,6 +281,57 @@ ul.indent-list {
// Specific styles for tree 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 { .group-list-tree {
.folder-toggle-wrap { .folder-toggle-wrap {
float: left; float: left;
...@@ -293,7 +344,7 @@ ul.indent-list { ...@@ -293,7 +344,7 @@ ul.indent-list {
} }
.folder-caret, .folder-caret,
.folder-icon { .item-type-icon {
display: inline-block; display: inline-block;
} }
...@@ -301,11 +352,11 @@ ul.indent-list { ...@@ -301,11 +352,11 @@ ul.indent-list {
width: 15px; width: 15px;
} }
.folder-icon { .item-type-icon {
width: 20px; width: 20px;
} }
> .group-row:not(.has-subgroups) { > .group-row:not(.has-children) {
.folder-caret .fa { .folder-caret .fa {
opacity: 0; opacity: 0;
} }
...@@ -351,12 +402,23 @@ ul.indent-list { ...@@ -351,12 +402,23 @@ ul.indent-list {
top: 30px; top: 30px;
bottom: 0; bottom: 0;
} }
&.being-removed {
opacity: 0.5;
}
} }
} }
.group-row { .group-row {
padding: 0; padding: 0;
border: none;
&.has-children {
border-top: none;
}
&:first-child {
border-top: 1px solid $white-normal;
}
&:last-of-type { &:last-of-type {
.group-row-contents:not(:hover) { .group-row-contents:not(:hover) {
...@@ -379,6 +441,25 @@ ul.indent-list { ...@@ -379,6 +441,25 @@ ul.indent-list {
.avatar-container > a { .avatar-container > a {
width: 100%; 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 @@ ...@@ -26,14 +26,117 @@
} }
} }
.groups-header { .group-nav-container .nav-controls {
@media (min-width: $screen-sm-min) { display: flex;
.nav-links { align-items: flex-start;
width: 35%; padding: $gl-padding-top 0;
border-bottom: 1px solid $border-color;
.group-filter-form {
flex: 1;
} }
.nav-controls { .dropdown-menu-align-right {
width: 65%; margin-top: 0;
}
.new-project-subgroup {
.dropdown-primary {
min-width: 115px;
}
.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;
}
} }
} }
} }
......
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
class Dashboard::GroupsController < Dashboard::ApplicationController class Dashboard::GroupsController < Dashboard::ApplicationController
def index include GroupTree
@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? def index
@groups = @groups.includes(:route) groups = GroupsFinder.new(current_user, all_available: false).execute
@groups = @groups.sort(@sort) render_group_tree(groups)
@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
end end
end end
class Explore::GroupsController < Explore::ApplicationController class Explore::GroupsController < Explore::ApplicationController
def index include GroupTree
@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])
respond_to do |format| def index
format.html render_group_tree GroupsFinder.new(current_user).execute
format.json do
render json: {
html: view_to_html_string("explore/groups/_groups", locals: { groups: @groups })
}
end
end
end end
end end
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 ...@@ -46,15 +46,11 @@ class GroupsController < Groups::ApplicationController
end end
def show def show
setup_projects
respond_to do |format| respond_to do |format|
format.html format.html do
@has_children = GroupDescendantsFinder.new(current_user: current_user,
format.json do parent_group: @group,
render json: { params: params).has_children?
html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
}
end end
format.atom do format.atom do
...@@ -64,13 +60,6 @@ class GroupsController < Groups::ApplicationController ...@@ -64,13 +60,6 @@ class GroupsController < Groups::ApplicationController
end end
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 def activity
respond_to do |format| respond_to do |format|
format.html format.html
...@@ -107,20 +96,6 @@ class GroupsController < Groups::ApplicationController ...@@ -107,20 +96,6 @@ class GroupsController < Groups::ApplicationController
protected 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! def authorize_create_group!
allowed = if params[:parent_id].present? allowed = if params[:parent_id].present?
parent = Group.find_by(id: params[:parent_id]) parent = Group.find_by(id: params[:parent_id])
......
# 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 ...@@ -34,7 +34,6 @@ class GroupProjectsFinder < ProjectsFinder
else else
collection_without_user collection_without_user
end end
union(projects) union(projects)
end end
......
...@@ -42,6 +42,17 @@ module SortingHelper ...@@ -42,6 +42,17 @@ module SortingHelper
options options
end 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 def member_sort_options_hash
{ {
sort_value_access_level_asc => sort_title_access_level_asc, sort_value_access_level_asc => sort_title_access_level_asc,
......
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
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 ...@@ -6,6 +6,8 @@ class Group < Namespace
include Avatarable include Avatarable
include Referable include Referable
include SelectForProjectAuthorization include SelectForProjectAuthorization
include LoadedInGroupList
include GroupDescendant
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members alias_method :members, :group_members
......
...@@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base ...@@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base
.base_and_ancestors .base_and_ancestors
end 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 def self_and_ancestors
return self.class.where(id: id) unless parent_id return self.class.where(id: id) unless parent_id
......
...@@ -17,6 +17,7 @@ class Project < ActiveRecord::Base ...@@ -17,6 +17,7 @@ class Project < ActiveRecord::Base
include ProjectFeaturesCompatibility include ProjectFeaturesCompatibility
include SelectForProjectAuthorization include SelectForProjectAuthorization
include Routable include Routable
include GroupDescendant
extend Gitlab::ConfigHelper extend Gitlab::ConfigHelper
extend Gitlab::CurrentSettings extend Gitlab::CurrentSettings
...@@ -81,6 +82,8 @@ class Project < ActiveRecord::Base ...@@ -81,6 +82,8 @@ class Project < ActiveRecord::Base
belongs_to :creator, class_name: 'User' belongs_to :creator, class_name: 'User'
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id' belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
belongs_to :namespace 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_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
has_many :boards, before_add: :validate_board_limit has_many :boards, before_add: :validate_board_limit
...@@ -479,6 +482,13 @@ class Project < ActiveRecord::Base ...@@ -479,6 +482,13 @@ class Project < ActiveRecord::Base
end end
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? def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil? return namespace.lfs_enabled? if self[:lfs_enabled].nil?
...@@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base ...@@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base
map.public_path_for_source_path(path) map.public_path_for_source_path(path)
end end
def parent
namespace
end
def parent_changed? def parent_changed?
namespace_id_changed? namespace_id_changed?
end end
......
class BaseSerializer class BaseSerializer
def initialize(parameters = {}) attr_reader :params
@request = EntityRequest.new(parameters)
def initialize(params = {})
@params = params
@request = EntityRequest.new(params)
end end
def represent(resource, opts = {}, entity_class = nil) def represent(resource, opts = {}, entity_class = nil)
......
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
class EnvironmentSerializer < BaseSerializer class EnvironmentSerializer < BaseSerializer
include WithPagination
Item = Struct.new(:name, :size, :latest) Item = Struct.new(:name, :size, :latest)
entity EnvironmentEntity entity EnvironmentEntity
...@@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer ...@@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer
tap { @itemize = true } tap { @itemize = true }
end end
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
end
def itemized? def itemized?
@itemize @itemize
end end
def paginated?
@paginator.present?
end
def represent(resource, opts = {}) def represent(resource, opts = {})
if itemized? if itemized?
itemize(resource).map do |item| itemize(resource).map do |item|
...@@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer ...@@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer
latest: super(item.latest, opts) } latest: super(item.latest, opts) }
end end
else else
resource = @paginator.paginate(resource) if paginated?
super(resource, opts) super(resource, opts)
end end
end end
......
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
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
class GroupSerializer < BaseSerializer class GroupSerializer < BaseSerializer
entity GroupEntity include WithPagination
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
end
def paginated? entity GroupEntity
@paginator.present?
end
def represent(resource, opts = {})
if paginated?
super(@paginator.paginate(resource), opts)
else
super(resource, opts)
end
end
end end
class PipelineSerializer < BaseSerializer class PipelineSerializer < BaseSerializer
include WithPagination
InvalidResourceError = Class.new(StandardError) InvalidResourceError = Class.new(StandardError)
entity PipelineDetailsEntity 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 = {}) def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation) if resource.is_a?(ActiveRecord::Relation)
......
.top-area .top-area
%ul.nav-links %ul.nav-links
= nav_link(page: dashboard_groups_path) do = 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 Your groups
= nav_link(page: explore_groups_path) do = 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 Explore public groups
.nav-controls .nav-controls
= render 'shared/groups/search_form' = render 'shared/groups/search_form'
= render 'shared/groups/dropdown' = render 'shared/groups/dropdown'
- if current_user.can_create_group? - 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"
.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.
.js-groups-list-holder .js-groups-list-holder
#dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } } #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' } }
.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' }
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
= webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups' = webpack_bundle_tag 'groups'
- if @groups.empty? - if params[:filter].blank? && @groups.empty?
= render 'empty_state' = render 'shared/groups/empty_state'
- else - else
= render 'groups' = render 'groups'
.js-groups-list-holder .js-groups-list-holder
%ul.content-list #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' } }
- @groups.each do |group|
= render 'shared/groups/group', group: group
= paginate @groups, theme: 'gitlab'
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
- page_title "Groups" - page_title "Groups"
- header_title "Groups", dashboard_groups_path - header_title "Groups", dashboard_groups_path
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups'
- if current_user - if current_user
= render 'dashboard/groups_head' = render 'dashboard/groups_head'
- else - else
...@@ -17,7 +20,7 @@ ...@@ -17,7 +20,7 @@
%p Below you will find all the groups that are public. %p Below you will find all the groups that are public.
%p You can easily contribute to them by requesting to join these groups. %p You can easily contribute to them by requesting to join these groups.
- if @groups.present? - if params[:filter].blank? && @groups.empty?
= render 'groups'
- else
.nothing-here-block No public groups .nothing-here-block No public groups
- else
= render 'groups'
= 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' } }
%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
- @no_container = true - @no_container = true
- breadcrumb_title "Details" - breadcrumb_title "Details"
- can_create_subgroups = can?(current_user, :create_subgroup, @group)
= content_for :meta_tags do = content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
...@@ -7,13 +8,38 @@ ...@@ -7,13 +8,38 @@
= render 'groups/home_panel' = render 'groups/home_panel'
.groups-header{ class: container_class } .groups-header{ class: container_class }
.top-area .group-nav-container
= render 'groups/show_nav' .nav-controls.clearfix
.nav-controls = render "shared/groups/search_form"
= render 'shared/projects/search_form' = render "shared/groups/dropdown", show_archive_options: true
= render 'shared/projects/dropdown'
- if can? current_user, :create_projects, @group - 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_label = _("New project")
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
- 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.
.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' } %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-label %span.dropdown-label
- if @sort.present? = sort_options_hash[default_sort_by]
= sort_options_hash[@sort]
- else
= sort_title_recently_created
= icon('chevron-down') = icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
%li %li.dropdown-header
= link_to filter_groups_path(sort: sort_value_recently_created) do = _("Sort by")
= sort_title_recently_created - groups_sort_options_hash.each do |value, title|
= link_to filter_groups_path(sort: sort_value_oldest_created) do %li.js-filter-sort-order
= sort_title_oldest_created = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do
= link_to filter_groups_path(sort: sort_value_recently_updated) do = title
= sort_title_recently_updated - if show_archive_options
= link_to filter_groups_path(sort: sort_value_oldest_updated) do %li.divider
= sort_title_oldest_updated %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
.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 @@ ...@@ -11,7 +11,7 @@
= link_to edit_group_path(group), class: "btn" do = link_to edit_group_path(group), class: "btn" do
= icon('cogs') = 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') = icon('sign-out')
.stats .stats
......
...@@ -3,4 +3,4 @@ ...@@ -3,4 +3,4 @@
- groups.each_with_index do |group, i| - groups.each_with_index do |group, i|
= render "shared/groups/group", group: group = render "shared/groups/group", group: group
- else - else
.nothing-here-block No groups found .nothing-here-block= s_("GroupsEmptyState|No groups found")
= form_tag request.path, method: :get, class: 'group-filter-form', id: 'group-filter-form' do |f| = form_tag request.path, method: :get, class: 'group-filter-form append-right-10', 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" = 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"
- @sort ||= sort_value_latest_activity - @sort ||= sort_value_latest_activity
.dropdown .dropdown.js-project-filter-dropdown-wrap
- toggle_text = projects_sort_options_hash[@sort] - toggle_text = projects_sort_options_hash[@sort]
= dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' }) = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' })
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
......
---
title: Show collapsible project lists
merge_request: 14055
author:
type: changed
...@@ -29,6 +29,7 @@ module Gitlab ...@@ -29,6 +29,7 @@ module Gitlab
#{config.root}/app/models/project_services #{config.root}/app/models/project_services
#{config.root}/app/workers/concerns #{config.root}/app/workers/concerns
#{config.root}/app/services/concerns #{config.root}/app/services/concerns
#{config.root}/app/serializers/concerns
#{config.root}/app/finders/concerns]) #{config.root}/app/finders/concerns])
config.generators.templates.push("#{config.root}/generator_templates") config.generators.templates.push("#{config.root}/generator_templates")
......
...@@ -32,6 +32,8 @@ scope(path: 'groups/*group_id', ...@@ -32,6 +32,8 @@ scope(path: 'groups/*group_id',
end end
resources :variables, only: [:index, :show, :update, :create, :destroy] resources :variables, only: [:index, :show, :update, :create, :destroy]
resources :children, only: [:index]
end end
end end
...@@ -43,7 +45,6 @@ scope(path: 'groups/*id', ...@@ -43,7 +45,6 @@ scope(path: 'groups/*id',
get :merge_requests, as: :merge_requests_group get :merge_requests, as: :merge_requests_group
get :projects, as: :projects_group get :projects, as: :projects_group
get :activity, as: :activity_group get :activity, as: :activity_group
get :subgroups, as: :subgroups_group
get '/', action: :show, as: :group_canonical get '/', action: :show, as: :group_canonical
end end
......
...@@ -3,6 +3,7 @@ Feature: Explore Groups ...@@ -3,6 +3,7 @@ Feature: Explore Groups
Background: Background:
Given group "TestGroup" has private project "Enterprise" Given group "TestGroup" has private project "Enterprise"
@javascript
Scenario: I should see group with private and internal projects as user Scenario: I should see group with private and internal projects as user
Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has internal project "Internal"
When I sign in as a user When I sign in as a user
...@@ -10,6 +11,7 @@ Feature: Explore Groups ...@@ -10,6 +11,7 @@ Feature: Explore Groups
Then I should see project "Internal" items Then I should see project "Internal" items
And I should not see project "Enterprise" items And I should not see project "Enterprise" items
@javascript
Scenario: I should see group issues for internal project as user Scenario: I should see group issues for internal project as user
Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has internal project "Internal"
When I sign in as a user When I sign in as a user
...@@ -17,6 +19,7 @@ Feature: Explore Groups ...@@ -17,6 +19,7 @@ Feature: Explore Groups
Then I should see project "Internal" items Then I should see project "Internal" items
And I should not see project "Enterprise" items And I should not see project "Enterprise" items
@javascript
Scenario: I should see group merge requests for internal project as user Scenario: I should see group merge requests for internal project as user
Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has internal project "Internal"
When I sign in as a user When I sign in as a user
...@@ -24,6 +27,7 @@ Feature: Explore Groups ...@@ -24,6 +27,7 @@ Feature: Explore Groups
Then I should see project "Internal" items Then I should see project "Internal" items
And I should not see project "Enterprise" items And I should not see project "Enterprise" items
@javascript
Scenario: I should see group with private, internal and public projects as visitor Scenario: I should see group with private, internal and public projects as visitor
Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community" Given group "TestGroup" has public project "Community"
...@@ -32,6 +36,7 @@ Feature: Explore Groups ...@@ -32,6 +36,7 @@ Feature: Explore Groups
And I should not see project "Internal" items And I should not see project "Internal" items
And I should not see project "Enterprise" items And I should not see project "Enterprise" items
@javascript
Scenario: I should see group issues for public project as visitor Scenario: I should see group issues for public project as visitor
Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community" Given group "TestGroup" has public project "Community"
...@@ -40,6 +45,7 @@ Feature: Explore Groups ...@@ -40,6 +45,7 @@ Feature: Explore Groups
And I should not see project "Internal" items And I should not see project "Internal" items
And I should not see project "Enterprise" items And I should not see project "Enterprise" items
@javascript
Scenario: I should see group merge requests for public project as visitor Scenario: I should see group merge requests for public project as visitor
Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community" Given group "TestGroup" has public project "Community"
...@@ -48,6 +54,7 @@ Feature: Explore Groups ...@@ -48,6 +54,7 @@ Feature: Explore Groups
And I should not see project "Internal" items And I should not see project "Internal" items
And I should not see project "Enterprise" items And I should not see project "Enterprise" items
@javascript
Scenario: I should see group with private, internal and public projects as user Scenario: I should see group with private, internal and public projects as user
Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community" Given group "TestGroup" has public project "Community"
...@@ -57,6 +64,7 @@ Feature: Explore Groups ...@@ -57,6 +64,7 @@ Feature: Explore Groups
And I should see project "Internal" items And I should see project "Internal" items
And I should not see project "Enterprise" items And I should not see project "Enterprise" items
@javascript
Scenario: I should see group issues for internal and public projects as user Scenario: I should see group issues for internal and public projects as user
Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has internal project "Internal"
Given group "TestGroup" has public project "Community" Given group "TestGroup" has public project "Community"
...@@ -66,6 +74,7 @@ Feature: Explore Groups ...@@ -66,6 +74,7 @@ Feature: Explore Groups
And I should see project "Internal" items And I should see project "Internal" items
And I should not see project "Enterprise" items And I should not see project "Enterprise" items
@javascript
Scenario: I should see group merge requests for internal and public projects as user 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 internal project "Internal"
Given group "TestGroup" has public project "Community" Given group "TestGroup" has public project "Community"
...@@ -75,17 +84,20 @@ Feature: Explore Groups ...@@ -75,17 +84,20 @@ Feature: Explore Groups
And I should see project "Internal" items And I should see project "Internal" items
And I should not see project "Enterprise" items And I should not see project "Enterprise" items
@javascript
Scenario: I should see group with public project in public groups area Scenario: I should see group with public project in public groups area
Given group "TestGroup" has public project "Community" Given group "TestGroup" has public project "Community"
When I visit the public groups area When I visit the public groups area
Then I should see group "TestGroup" Then I should see group "TestGroup"
@javascript
Scenario: I should see group with public project in public groups area as user Scenario: I should see group with public project in public groups area as user
Given group "TestGroup" has public project "Community" Given group "TestGroup" has public project "Community"
When I sign in as a user When I sign in as a user
And I visit the public groups area And I visit the public groups area
Then I should see group "TestGroup" Then I should see group "TestGroup"
@javascript
Scenario: I should see group with internal project in public groups area as user Scenario: I should see group with internal project in public groups area as user
Given group "TestGroup" has internal project "Internal" Given group "TestGroup" has internal project "Internal"
When I sign in as a user When I sign in as a user
......
...@@ -17,12 +17,32 @@ module Gitlab ...@@ -17,12 +17,32 @@ module Gitlab
@model = ancestors_base.model @model = ancestors_base.model
end 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 # Returns a relation that includes the ancestors_base set of groups
# and all their ancestors (recursively). # 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? 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 end
# Returns a relation that includes the descendants_base set of groups # Returns a relation that includes the descendants_base set of groups
...@@ -78,17 +98,19 @@ module Gitlab ...@@ -78,17 +98,19 @@ module Gitlab
private private
def base_and_ancestors_cte def base_and_ancestors_cte(stop_id = nil)
cte = SQL::RecursiveCTE.new(:base_and_ancestors) cte = SQL::RecursiveCTE.new(:base_and_ancestors)
cte << ancestors_base.except(:order) cte << ancestors_base.except(:order)
# Recursively get all the ancestors of the base set. # Recursively get all the ancestors of the base set.
cte << model parent_query = model
.from([groups_table, cte.table]) .from([groups_table, cte.table])
.where(groups_table[:id].eq(cte.table[:parent_id])) .where(groups_table[:id].eq(cte.table[:parent_id]))
.except(:order) .except(:order)
parent_query = parent_query.where(cte.table[:parent_id].not_eq(stop_id)) if stop_id
cte << parent_query
cte cte
end end
......
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 ...@@ -128,7 +128,6 @@ module Gitlab
notification_setting notification_setting
pipeline_quota pipeline_quota
projects projects
subgroups
].freeze ].freeze
ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES
......
...@@ -26,7 +26,11 @@ module Gitlab ...@@ -26,7 +26,11 @@ module Gitlab
@relations.map { |rel| rel.reorder(nil).to_sql }.reject(&:blank?) @relations.map { |rel| rel.reorder(nil).to_sql }.reject(&:blank?)
end end
if fragments.any?
fragments.join("\n#{union_keyword}\n") fragments.join("\n#{union_keyword}\n")
else
'NULL'
end
end end
def union_keyword def union_keyword
......
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 "" ...@@ -8,8 +8,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: gitlab 1.0.0\n" "Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-10-06 18:33+0200\n" "POT-Creation-Date: 2017-10-10 17:50+0200\n"
"PO-Revision-Date: 2017-10-06 18:33+0200\n" "PO-Revision-Date: 2017-10-10 17:50+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n" "Language: \n"
...@@ -109,6 +109,9 @@ msgstr "" ...@@ -109,6 +109,9 @@ msgstr ""
msgid "All" msgid "All"
msgstr "" msgstr ""
msgid "An error occurred. Please try again."
msgstr ""
msgid "Appearance" msgid "Appearance"
msgstr "" msgstr ""
...@@ -375,6 +378,9 @@ msgstr "" ...@@ -375,6 +378,9 @@ msgstr ""
msgid "Clone repository" msgid "Clone repository"
msgstr "" msgstr ""
msgid "Cluster"
msgstr ""
msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account" msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account"
msgstr "" msgstr ""
...@@ -420,13 +426,10 @@ msgstr "" ...@@ -420,13 +426,10 @@ msgstr ""
msgid "ClusterIntegration|Google Container Engine project" msgid "ClusterIntegration|Google Container Engine project"
msgstr "" msgstr ""
msgid "ClusterIntegration|Google Container Engine"
msgstr ""
msgid "ClusterIntegration|Learn more about %{link_to_documentation}" msgid "ClusterIntegration|Learn more about %{link_to_documentation}"
msgstr "" msgstr ""
msgid "ClusterIntegration|See machine types" msgid "ClusterIntegration|Machine type"
msgstr "" msgstr ""
msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters" msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters"
...@@ -438,9 +441,15 @@ msgstr "" ...@@ -438,9 +441,15 @@ msgstr ""
msgid "ClusterIntegration|Number of nodes" msgid "ClusterIntegration|Number of nodes"
msgstr "" msgstr ""
msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:"
msgstr ""
msgid "ClusterIntegration|Project namespace (optional, unique)" msgid "ClusterIntegration|Project namespace (optional, unique)"
msgstr "" msgstr ""
msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration."
msgstr ""
msgid "ClusterIntegration|Remove cluster integration" msgid "ClusterIntegration|Remove cluster integration"
msgstr "" msgstr ""
...@@ -450,7 +459,10 @@ 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." msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project."
msgstr "" msgstr ""
msgid "ClusterIntegration|Save changes" msgid "ClusterIntegration|Save"
msgstr ""
msgid "ClusterIntegration|See machine types"
msgstr "" msgstr ""
msgid "ClusterIntegration|See your projects" msgid "ClusterIntegration|See your projects"
...@@ -462,18 +474,12 @@ msgstr "" ...@@ -462,18 +474,12 @@ msgstr ""
msgid "ClusterIntegration|Something went wrong on our end." msgid "ClusterIntegration|Something went wrong on our end."
msgstr "" msgstr ""
msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine." 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:"
msgstr "" msgstr ""
msgid "ClusterIntegration|Toggle Cluster" msgid "ClusterIntegration|Toggle Cluster"
msgstr "" 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." 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 "" msgstr ""
...@@ -691,6 +697,9 @@ msgstr "" ...@@ -691,6 +697,9 @@ msgstr ""
msgid "Discard changes" msgid "Discard changes"
msgstr "" msgstr ""
msgid "Dismiss Cycle Analytics introduction box"
msgstr ""
msgid "Don't show again" msgid "Don't show again"
msgstr "" msgstr ""
...@@ -760,6 +769,9 @@ msgstr "" ...@@ -760,6 +769,9 @@ msgstr ""
msgid "Explore projects" msgid "Explore projects"
msgstr "" msgstr ""
msgid "Explore public groups"
msgstr ""
msgid "Failed to change the owner" msgid "Failed to change the owner"
msgstr "" msgstr ""
...@@ -846,6 +858,51 @@ msgstr "" ...@@ -846,6 +858,51 @@ msgstr ""
msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}" msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}"
msgstr "" 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" msgid "Health Check"
msgstr "" msgstr ""
...@@ -876,6 +933,12 @@ msgstr "" ...@@ -876,6 +933,12 @@ msgstr ""
msgid "Install a Runner compatible with GitLab CI" msgid "Install a Runner compatible with GitLab CI"
msgstr "" 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" msgid "Interval Pattern"
msgstr "" msgstr ""
...@@ -935,6 +998,9 @@ msgstr "" ...@@ -935,6 +998,9 @@ msgstr ""
msgid "Learn more in the|pipeline schedules documentation" msgid "Learn more in the|pipeline schedules documentation"
msgstr "" msgstr ""
msgid "Leave"
msgstr ""
msgid "Leave group" msgid "Leave group"
msgstr "" msgstr ""
...@@ -946,6 +1012,15 @@ msgid_plural "Limited to showing %d events at most" ...@@ -946,6 +1012,15 @@ msgid_plural "Limited to showing %d events at most"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Lock"
msgstr ""
msgid "Locked"
msgstr ""
msgid "Login"
msgstr ""
msgid "Median" msgid "Median"
msgstr "" msgstr ""
...@@ -973,6 +1048,9 @@ msgstr "" ...@@ -973,6 +1048,9 @@ msgstr ""
msgid "More information is available|here" msgid "More information is available|here"
msgstr "" msgstr ""
msgid "New Cluster"
msgstr ""
msgid "New Issue" msgid "New Issue"
msgid_plural "New Issues" msgid_plural "New Issues"
msgstr[0] "" msgstr[0] ""
...@@ -990,18 +1068,27 @@ msgstr "" ...@@ -990,18 +1068,27 @@ msgstr ""
msgid "New file" msgid "New file"
msgstr "" msgstr ""
msgid "New group"
msgstr ""
msgid "New issue" msgid "New issue"
msgstr "" msgstr ""
msgid "New merge request" msgid "New merge request"
msgstr "" msgstr ""
msgid "New project"
msgstr ""
msgid "New schedule" msgid "New schedule"
msgstr "" msgstr ""
msgid "New snippet" msgid "New snippet"
msgstr "" msgstr ""
msgid "New subgroup"
msgstr ""
msgid "New tag" msgid "New tag"
msgstr "" msgstr ""
...@@ -1080,6 +1167,9 @@ msgstr "" ...@@ -1080,6 +1167,9 @@ msgstr ""
msgid "OfSearchInADropdown|Filter" msgid "OfSearchInADropdown|Filter"
msgstr "" msgstr ""
msgid "Only project members can comment."
msgstr ""
msgid "OpenedNDaysAgo|Opened" msgid "OpenedNDaysAgo|Opened"
msgstr "" msgstr ""
...@@ -1110,6 +1200,9 @@ msgstr "" ...@@ -1110,6 +1200,9 @@ msgstr ""
msgid "Password" msgid "Password"
msgstr "" msgstr ""
msgid "People without permission will never get a notification and won\\'t be able to comment."
msgstr ""
msgid "Pipeline" msgid "Pipeline"
msgstr "" msgstr ""
...@@ -1209,9 +1302,51 @@ msgstr "" ...@@ -1209,9 +1302,51 @@ msgstr ""
msgid "Preferences" msgid "Preferences"
msgstr "" 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" msgid "Profile"
msgstr "" 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." msgid "Project '%{project_name}' queued for deletion."
msgstr "" msgstr ""
...@@ -1266,6 +1401,9 @@ msgstr "" ...@@ -1266,6 +1401,9 @@ msgstr ""
msgid "ProjectNetworkGraph|Graph" msgid "ProjectNetworkGraph|Graph"
msgstr "" msgstr ""
msgid "Projects"
msgstr ""
msgid "ProjectsDropdown|Frequently visited" msgid "ProjectsDropdown|Frequently visited"
msgstr "" msgstr ""
...@@ -1287,6 +1425,12 @@ msgstr "" ...@@ -1287,6 +1425,12 @@ msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support" msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr "" 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" msgid "Push events"
msgstr "" msgstr ""
...@@ -1415,12 +1559,18 @@ msgstr "" ...@@ -1415,12 +1559,18 @@ msgstr ""
msgid "Something went wrong on our end." msgid "Something went wrong on our end."
msgstr "" 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." msgid "Something went wrong while fetching the projects."
msgstr "" msgstr ""
msgid "Something went wrong while fetching the registry list." msgid "Something went wrong while fetching the registry list."
msgstr "" msgstr ""
msgid "Sort by"
msgstr ""
msgid "SortOptions|Access level, ascending" msgid "SortOptions|Access level, ascending"
msgstr "" msgstr ""
...@@ -1529,6 +1679,9 @@ msgstr "" ...@@ -1529,6 +1679,9 @@ msgstr ""
msgid "Start the Runner!" msgid "Start the Runner!"
msgstr "" msgstr ""
msgid "Subgroups"
msgstr ""
msgid "Switch branch/tag" msgid "Switch branch/tag"
msgstr "" msgstr ""
...@@ -1600,12 +1753,24 @@ msgstr "" ...@@ -1600,12 +1753,24 @@ msgstr ""
msgid "There are problems accessing Git storage: " msgid "There are problems accessing Git storage: "
msgstr "" msgstr ""
msgid "This is a confidential issue."
msgstr ""
msgid "This is the author's first Merge Request to this project." msgid "This is the author's first Merge Request to this project."
msgstr "" 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." msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr "" msgstr ""
msgid "This merge request is locked."
msgstr ""
msgid "Time before an issue gets scheduled" msgid "Time before an issue gets scheduled"
msgstr "" msgstr ""
...@@ -1760,6 +1925,12 @@ msgstr "" ...@@ -1760,6 +1925,12 @@ msgstr ""
msgid "Total test time for all commits/merges" msgid "Total test time for all commits/merges"
msgstr "" msgstr ""
msgid "Unlock"
msgstr ""
msgid "Unlocked"
msgstr ""
msgid "Unstar" msgid "Unstar"
msgstr "" msgstr ""
...@@ -1922,9 +2093,15 @@ msgstr "" ...@@ -1922,9 +2093,15 @@ msgstr ""
msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
msgstr "" msgstr ""
msgid "You are on a read-only GitLab instance."
msgstr ""
msgid "You can only add files when you are on a branch" msgid "You can only add files when you are on a branch"
msgstr "" msgstr ""
msgid "You cannot write to this read-only GitLab instance."
msgstr ""
msgid "You have reached your project limit" msgid "You have reached your project limit"
msgstr "" msgstr ""
...@@ -1955,6 +2132,12 @@ 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" msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
msgstr "" msgstr ""
msgid "Your comment will not be visible to the public."
msgstr ""
msgid "Your groups"
msgstr ""
msgid "Your name" msgid "Your name"
msgstr "" msgstr ""
...@@ -1977,5 +2160,11 @@ msgid_plural "parents" ...@@ -1977,5 +2160,11 @@ msgid_plural "parents"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "password"
msgstr ""
msgid "personal access token" msgid "personal access token"
msgstr "" msgstr ""
msgid "username"
msgstr ""
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
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
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
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
require 'rails_helper' require 'spec_helper'
describe GroupsController do describe GroupsController do
let(:user) { create(:user) } let(:user) { create(:user) }
...@@ -150,42 +150,6 @@ describe GroupsController do ...@@ -150,42 +150,6 @@ describe GroupsController do
end end
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 describe 'GET #issues' do
let(:issue_1) { create(:issue, project: project) } let(:issue_1) { create(:issue, project: project) }
let(:issue_2) { create(:issue, project: project) } let(:issue_2) { create(:issue, project: project) }
...@@ -425,7 +389,6 @@ describe GroupsController do ...@@ -425,7 +389,6 @@ describe GroupsController do
end end
end end
end end
end
context 'for a POST request' do context 'for a POST request' do
context 'when requesting the canonical path with different casing' do context 'when requesting the canonical path with different casing' do
...@@ -483,4 +446,5 @@ describe GroupsController do ...@@ -483,4 +446,5 @@ describe GroupsController do
def group_moved_message(redirect_route, group) 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." "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
end
end end
...@@ -6,6 +6,13 @@ feature 'Dashboard Groups page', :js do ...@@ -6,6 +6,13 @@ feature 'Dashboard Groups page', :js do
let(:nested_group) { create(:group, :nested) } let(:nested_group) { create(:group, :nested) }
let(:another_group) { create(:group) } 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 it 'shows groups user is member of' do
group.add_owner(user) group.add_owner(user)
nested_group.add_owner(user) nested_group.add_owner(user)
...@@ -13,13 +20,27 @@ feature 'Dashboard Groups page', :js do ...@@ -13,13 +20,27 @@ feature 'Dashboard Groups page', :js do
sign_in(user) sign_in(user)
visit dashboard_groups_path visit dashboard_groups_path
wait_for_requests
expect(page).to have_content(group.full_name) expect(page).to have_content(group.name)
expect(page).to have_content(nested_group.full_name)
expect(page).not_to have_content(another_group.full_name) expect(page).not_to have_content(another_group.name)
end
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 end
describe 'when filtering groups' do describe 'when filtering groups', :nested_groups do
before do before do
group.add_owner(user) group.add_owner(user)
nested_group.add_owner(user) nested_group.add_owner(user)
...@@ -30,25 +51,26 @@ feature 'Dashboard Groups page', :js do ...@@ -30,25 +51,26 @@ feature 'Dashboard Groups page', :js do
visit dashboard_groups_path visit dashboard_groups_path
end end
it 'filters groups' do it 'expands when filtering groups' do
fill_in 'filter_groups', with: group.name fill_in 'filter', with: nested_group.name
wait_for_requests wait_for_requests
expect(page).to have_content(group.full_name) expect(page).not_to have_content(group.name)
expect(page).not_to have_content(nested_group.full_name) expect(page).to have_content(nested_group.parent.name)
expect(page).not_to have_content(another_group.full_name) expect(page).to have_content(nested_group.name)
expect(page).not_to have_content(another_group.name)
end end
it 'resets search when user cleans the input' do 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 wait_for_requests
fill_in 'filter_groups', with: '' fill_in 'filter', with: ''
wait_for_requests wait_for_requests
expect(page).to have_content(group.full_name) expect(page).to have_content(group.name)
expect(page).to have_content(nested_group.full_name) expect(page).to have_content(nested_group.parent.name)
expect(page).not_to have_content(another_group.full_name) expect(page).not_to have_content(another_group.name)
expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2 expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
end end
end end
...@@ -66,28 +88,29 @@ feature 'Dashboard Groups page', :js do ...@@ -66,28 +88,29 @@ feature 'Dashboard Groups page', :js do
end end
it 'shows subgroups inside of its parent group' do 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("#group-#{group.id}")
expect(page).to have_selector(".groups-list-tree-container #group-#{group.id} #group-#{subgroup.id}", count: 1) click_group_caret(group)
expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
end end
it 'can toggle parent group' do it 'can toggle parent group' do
# Expanded by default # Collapsed 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-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-right")
# Collapse # expand
find("#group-#{group.id}").trigger('click') 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-down")
expect(page).to have_selector("#group-#{group.id} .fa-caret-right", count: 1) expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}") expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
# Expand # collapse
find("#group-#{group.id}").trigger('click') click_group_caret(group)
expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1) expect(page).not_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-right")
expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}") expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
end end
end end
......
...@@ -13,6 +13,7 @@ describe 'Explore Groups page', :js do ...@@ -13,6 +13,7 @@ describe 'Explore Groups page', :js do
sign_in(user) sign_in(user)
visit explore_groups_path visit explore_groups_path
wait_for_requests
end end
it 'shows groups user is member of' do it 'shows groups user is member of' do
...@@ -22,7 +23,7 @@ describe 'Explore Groups page', :js do ...@@ -22,7 +23,7 @@ describe 'Explore Groups page', :js do
end end
it 'filters groups' do it 'filters groups' do
fill_in 'filter_groups', with: group.name fill_in 'filter', with: group.name
wait_for_requests wait_for_requests
expect(page).to have_content(group.full_name) expect(page).to have_content(group.full_name)
...@@ -31,10 +32,10 @@ describe 'Explore Groups page', :js do ...@@ -31,10 +32,10 @@ describe 'Explore Groups page', :js do
end end
it 'resets search when user cleans the input' do 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 wait_for_requests
fill_in 'filter_groups', with: "" fill_in 'filter', with: ""
wait_for_requests wait_for_requests
expect(page).to have_content(group.full_name) expect(page).to have_content(group.full_name)
...@@ -45,21 +46,21 @@ describe 'Explore Groups page', :js do ...@@ -45,21 +46,21 @@ describe 'Explore Groups page', :js do
it 'shows non-archived projects count' do it 'shows non-archived projects count' do
# Initially project is not archived # 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 # Archive project
empty_project.archive! empty_project.archive!
visit explore_groups_path visit explore_groups_path
# Check project count # 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 # Unarchive project
empty_project.unarchive! empty_project.unarchive!
visit explore_groups_path visit explore_groups_path
# Check project count # 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 end
describe 'landing component' do describe 'landing component' do
......
...@@ -24,4 +24,35 @@ feature 'Group show page' do ...@@ -24,4 +24,35 @@ feature 'Group show page' do
it_behaves_like "an autodiscoverable RSS feed without an RSS token" it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end 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 end
...@@ -90,8 +90,7 @@ feature 'Group' do ...@@ -90,8 +90,7 @@ feature 'Group' do
context 'as admin' do context 'as admin' do
before do before do
visit subgroups_group_path(group) visit new_group_path(group, parent_id: group.id)
click_link 'New Subgroup'
end end
it 'creates a nested group' do it 'creates a nested group' do
...@@ -111,8 +110,8 @@ feature 'Group' do ...@@ -111,8 +110,8 @@ feature 'Group' do
sign_out(:user) sign_out(:user)
sign_in(user) sign_in(user)
visit subgroups_group_path(group) visit new_group_path(group, parent_id: group.id)
click_link 'New Subgroup'
fill_in 'Group path', with: 'bar' fill_in 'Group path', with: 'bar'
click_button 'Create group' click_button 'Create group'
...@@ -120,16 +119,6 @@ feature 'Group' do ...@@ -120,16 +119,6 @@ feature 'Group' do
expect(page).to have_content("Group 'bar' was successfully created.") expect(page).to have_content("Group 'bar' was successfully created.")
end end
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 end
it 'checks permissions to avoid exposing groups by parent_id' do it 'checks permissions to avoid exposing groups by parent_id' do
...@@ -210,13 +199,15 @@ feature 'Group' do ...@@ -210,13 +199,15 @@ feature 'Group' do
describe 'group page with nested groups', :nested_groups, :js do describe 'group page with nested groups', :nested_groups, :js do
let!(:group) { create(:group) } let!(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) } let!(:nested_group) { create(:group, parent: group) }
let!(:project) { create(:project, namespace: group) }
let!(:path) { group_path(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 visit path
click_link 'Subgroups' wait_for_requests
expect(page).to have_content(nested_group.name) expect(page).to have_content(nested_group.name)
expect(page).to have_content(project.name)
end end
end end
......
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
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();
});
});
});
});
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();
});
});
});
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();
});
});
});
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();
});
});
});
});
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');
});
});
});
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();
});
});
});
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();
});
});
});
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();
});
});
});
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();
});
});
});
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();
});
});
});
});
const group1 = { export const mockEndpoint = '/dashboard/groups.json';
id: 12,
name: 'level1', export const ITEM_TYPE = {
path: 'level1', PROJECT: 'project',
description: 'foo', 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',
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,
};
export const mockRawChildren = [
{
id: 57,
name: 'bsp',
description: '',
visibility: 'public', visibility: 'public',
full_name: 'platform / hardware / bsp',
relative_path: '/platform/hardware/bsp',
can_edit: true,
type: 'group',
avatar_url: null, avatar_url: null,
web_url: 'http://localhost:3000/groups/level1', permission: 'Owner',
group_path: '/level1', edit_path: '/groups/platform/hardware/bsp/edit',
full_name: 'level1', children_count: 6,
full_path: 'level1', leave_path: '/groups/platform/hardware/bsp/group_members/leave',
parent_id: null, parent_id: 55,
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', number_users_with_delimiter: '1',
has_subgroups: true, project_count: 4,
permissions: { subgroup_count: 2,
human_group_access: 'Master', 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,
},
];
// This group has no direct parent, should be placed as subgroup of group1 export const mockGroups = [
const group14 = { {
id: 1128, id: 75,
name: 'level4', name: 'test-group',
path: 'level4', description: '',
description: 'foo',
visibility: 'public', visibility: 'public',
full_name: 'test-group',
relative_path: '/test-group',
can_edit: true,
type: 'group',
avatar_url: null, avatar_url: null,
web_url: 'http://localhost:3000/groups/level1/level2/level3/level4', permission: 'Owner',
group_path: '/level1/level2/level3/level4', edit_path: '/groups/test-group/edit',
full_name: 'level1 / level2 / level3 / level4', children_count: 2,
full_path: 'level1/level2/level3/level4', leave_path: '/groups/test-group/group_members/leave',
parent_id: 1127, parent_id: null,
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', number_users_with_delimiter: '1',
has_subgroups: true, project_count: 2,
permissions: { subgroup_count: 0,
human_group_access: 'Master', can_leave: false,
}, },
}; {
id: 67,
const group2 = { name: 'open-source',
id: 1119, description: '',
name: 'devops', visibility: 'private',
path: 'devops', full_name: 'open-source',
description: 'foo', 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', visibility: 'public',
full_name: 'platform',
relative_path: '/platform',
can_edit: true,
type: 'group',
avatar_url: null, avatar_url: null,
web_url: 'http://localhost:3000/groups/devops', permission: 'Owner',
group_path: '/devops', edit_path: '/groups/platform/edit',
full_name: 'devops', children_count: 1,
full_path: 'devops', leave_path: '/groups/platform/group_members/leave',
parent_id: null, 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', number_users_with_delimiter: '1',
has_subgroups: true, project_count: 0,
permissions: { subgroup_count: 1,
human_group_access: 'Master', can_leave: false,
}, },
}; {
id: 5,
const group21 = { name: 'H5bp',
id: 1120, description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.',
name: 'chef', visibility: 'public',
path: 'chef', full_name: 'H5bp',
description: 'foo', 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', visibility: 'public',
full_name: 'Gitlab Org',
relative_path: '/gitlab-org',
can_edit: true,
type: 'group',
avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png', avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
web_url: 'http://localhost:3000/groups/devops/chef', permission: 'Owner',
group_path: '/devops/chef', edit_path: '/groups/gitlab-org/edit',
full_name: 'devops / chef', children_count: 4,
full_path: 'devops/chef', leave_path: '/groups/gitlab-org/group_members/leave',
parent_id: 1119, parent_id: null,
created_at: '2017-05-11T19:51:04.060Z', number_users_with_delimiter: '5',
updated_at: '2017-05-11T19:51:04.060Z', project_count: 4,
number_projects_with_delimiter: '1', subgroup_count: 0,
number_users_with_delimiter: '1', can_leave: false,
has_subgroups: true,
permissions: {
human_group_access: 'Master',
}, },
}; ];
const groupsData = { export const mockSearchedGroups = [
groups: [group1, group14, group2, group21], {
pagination: { id: 55,
Date: 'Mon, 22 May 2017 22:31:52 GMT', name: 'hardware',
'X-Prev-Page': '1', description: '',
'X-Content-Type-Options': 'nosniff', visibility: 'public',
'X-Total': '31', full_name: 'platform / hardware',
'Transfer-Encoding': 'chunked', relative_path: '/platform/hardware',
'X-Runtime': '0.611144', can_edit: true,
'X-Xss-Protection': '1; mode=block', type: 'group',
'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09', avatar_url: null,
'X-Ua-Compatible': 'IE=edge', permission: 'Owner',
'X-Per-Page': '20', edit_path: '/groups/platform/hardware/edit',
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"', children_count: 3,
'X-Next-Page': '', leave_path: '/groups/platform/hardware/group_members/leave',
Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"', parent_id: 54,
'X-Frame-Options': 'DENY', number_users_with_delimiter: '1',
'Content-Type': 'application/json; charset=utf-8', project_count: 1,
'Cache-Control': 'max-age=0, private, must-revalidate', subgroup_count: 2,
'X-Total-Pages': '2', can_leave: false,
'X-Page': '2', 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,
}; };
export { groupsData, group1 }; export const mockPageInfo = {
perPage: 10,
page: 10,
total: 10,
totalPages: 10,
nextPage: 10,
prevPage: 10,
};
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);
});
});
});
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 ...@@ -18,6 +18,12 @@ describe Gitlab::GroupHierarchy, :postgresql do
expect(relation).to include(parent, child1) expect(relation).to include(parent, child1)
end 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 it 'uses ancestors_base #initialize argument' do
relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors
...@@ -55,6 +61,28 @@ describe Gitlab::GroupHierarchy, :postgresql do ...@@ -55,6 +61,28 @@ describe Gitlab::GroupHierarchy, :postgresql do
end end
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 describe '#all_groups' do
let(:relation) do let(:relation) do
described_class.new(Group.where(id: child1.id)).all_groups described_class.new(Group.where(id: child1.id)).all_groups
......
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 ...@@ -213,7 +213,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do it 'accepts group routes' do
expect(subject).to match('activity/') expect(subject).to match('activity/')
expect(subject).to match('group_members/') expect(subject).to match('group_members/')
expect(subject).to match('subgroups/') expect(subject).to match('labels/')
end end
it 'is not case sensitive' do it 'is not case sensitive' do
...@@ -246,7 +246,7 @@ describe Gitlab::PathRegex do ...@@ -246,7 +246,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do it 'accepts group routes' do
expect(subject).to match('activity/') expect(subject).to match('activity/')
expect(subject).to match('group_members/') expect(subject).to match('group_members/')
expect(subject).to match('subgroups/') expect(subject).to match('labels/')
end end
end end
...@@ -268,7 +268,7 @@ describe Gitlab::PathRegex do ...@@ -268,7 +268,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do it 'accepts group routes' do
expect(subject).to match('activity/more/') expect(subject).to match('activity/more/')
expect(subject).to match('group_members/more/') expect(subject).to match('group_members/more/')
expect(subject).to match('subgroups/more/') expect(subject).to match('labels/more/')
end end
end end
end end
...@@ -292,7 +292,7 @@ describe Gitlab::PathRegex do ...@@ -292,7 +292,7 @@ describe Gitlab::PathRegex do
it 'rejects group routes' do it 'rejects group routes' do
expect(subject).not_to match('root/activity/') expect(subject).not_to match('root/activity/')
expect(subject).not_to match('root/group_members/') expect(subject).not_to match('root/group_members/')
expect(subject).not_to match('root/subgroups/') expect(subject).not_to match('root/labels/')
end end
end end
...@@ -314,7 +314,7 @@ describe Gitlab::PathRegex do ...@@ -314,7 +314,7 @@ describe Gitlab::PathRegex do
it 'rejects group routes' do it 'rejects group routes' do
expect(subject).not_to match('root/activity/more/') expect(subject).not_to match('root/activity/more/')
expect(subject).not_to match('root/group_members/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 end
end end
...@@ -349,7 +349,7 @@ describe Gitlab::PathRegex do ...@@ -349,7 +349,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do it 'accepts group routes' do
expect(subject).to match('activity/') expect(subject).to match('activity/')
expect(subject).to match('group_members/') expect(subject).to match('group_members/')
expect(subject).to match('subgroups/') expect(subject).to match('labels/')
end end
it 'is not case sensitive' do it 'is not case sensitive' do
...@@ -382,7 +382,7 @@ describe Gitlab::PathRegex do ...@@ -382,7 +382,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do it 'accepts group routes' do
expect(subject).to match('root/activity/') expect(subject).to match('root/activity/')
expect(subject).to match('root/group_members/') expect(subject).to match('root/group_members/')
expect(subject).to match('root/subgroups/') expect(subject).to match('root/labels/')
end end
it 'is not case sensitive' do it 'is not case sensitive' do
......
...@@ -29,5 +29,12 @@ describe Gitlab::SQL::Union do ...@@ -29,5 +29,12 @@ describe Gitlab::SQL::Union do
expect(union.to_sql).to include('UNION ALL') expect(union.to_sql).to include('UNION ALL')
end 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
end end
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
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
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 ...@@ -153,6 +153,20 @@ describe Namespace do
end end
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 describe '#move_dir' do
let(:namespace) { create(:namespace) } let(:namespace) { create(:namespace) }
let!(:project) { create(:project_empty_repo, namespace: namespace) } let!(:project) { create(:project_empty_repo, namespace: namespace) }
......
...@@ -1761,6 +1761,21 @@ describe Project do ...@@ -1761,6 +1761,21 @@ describe Project do
it { expect(project.gitea_import?).to be true } it { expect(project.gitea_import?).to be true }
end 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 describe '#lfs_enabled?' do
let(:project) { create(:project) } let(:project) { create(:project) }
...@@ -2178,6 +2193,12 @@ describe Project do ...@@ -2178,6 +2193,12 @@ describe Project do
it { expect(project.parent).to eq(project.namespace) } it { expect(project.parent).to eq(project.namespace) }
end end
describe '#parent_id' do
let(:project) { create(:project) }
it { expect(project.parent_id).to eq(project.namespace_id) }
end
describe '#parent_changed?' do describe '#parent_changed?' do
let(:project) { create(:project) } let(:project) { create(:project) }
......
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
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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment