Commit de553961 authored by Kushal Pandya's avatar Kushal Pandya Committed by Bob Van Landuyt

Groups tree enhancements for Groups Dashboard and Group Homepage

parent 67815272
...@@ -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, updatePagination }) {
return this.service.getGroups(parentId, page, filterGroupsBy, sortBy)
.then((res) => {
if (updatePagination) {
this.updatePagination(res.headers);
}
return res;
})
.then(res => res.json())
.catch(() => {
this.isLoading = false;
$.scrollTo(0);
Flash(COMMON_STR.FAILURE);
});
},
fetchAllGroups() {
const page = getParameterByName('page') || null;
const sortBy = getParameterByName('sort') || null;
const filterGroupsBy = getParameterByName('filter') || null;
this.isLoading = true;
// eslint-disable-next-line promise/catch-or-return
this.fetchGroups({
page,
filterGroupsBy,
sortBy,
updatePagination: true,
}).then((res) => {
this.isLoading = false;
this.updateGroups(res, Boolean(filterGroupsBy));
});
},
fetchPage(page, filterGroupsBy, sortBy) {
this.isLoading = true;
// eslint-disable-next-line promise/catch-or-return
this.fetchGroups({
page,
filterGroupsBy,
sortBy,
updatePagination: true,
}).then((res) => {
this.isLoading = false;
$.scrollTo(0);
const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
window.history.replaceState({
page: currentPath,
}, document.title, currentPath);
this.updateGroups(res);
});
},
toggleChildren(group) {
const parentGroup = group;
if (!parentGroup.isOpen) {
if (parentGroup.children.length === 0) {
parentGroup.isChildrenLoading = true;
// eslint-disable-next-line promise/catch-or-return
this.fetchGroups({
parentId: parentGroup.id,
}).then((res) => {
this.store.setGroupChildren(parentGroup, res);
}).catch(() => {
parentGroup.isChildrenLoading = false;
});
} else {
parentGroup.isOpen = true;
}
} else {
parentGroup.isOpen = false;
}
},
leaveGroup(group, parentGroup) {
const targetGroup = group;
targetGroup.isBeingRemoved = true;
this.service.leaveGroup(targetGroup.leavePath)
.then(res => res.json())
.then((res) => {
$.scrollTo(0);
this.store.removeGroup(targetGroup, parentGroup);
Flash(res.notice, 'notice');
})
.catch((err) => {
let message = COMMON_STR.FAILURE;
if (err.status === 403) {
message = COMMON_STR.LEAVE_FORBIDDEN;
}
Flash(message);
targetGroup.isBeingRemoved = false;
});
},
updatePagination(headers) {
this.store.setPaginationInfo(headers);
},
updateGroups(groups, fromSearch) {
this.isSearchEmpty = groups ? groups.length === 0 : false;
if (fromSearch) {
this.store.setSearchedGroups(groups);
} else {
this.store.setGroups(groups);
}
},
},
created() {
this.searchEmptyMessage = this.hideProjects ?
COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
eventHub.$on('fetchPage', this.fetchPage);
eventHub.$on('toggleChildren', this.toggleChildren);
eventHub.$on('leaveGroup', this.leaveGroup);
eventHub.$on('updatePagination', this.updatePagination);
eventHub.$on('updateGroups', this.updateGroups);
},
mounted() {
this.fetchAllGroups();
},
beforeDestroy() {
eventHub.$off('fetchPage', this.fetchPage);
eventHub.$off('toggleChildren', this.toggleChildren);
eventHub.$off('leaveGroup', this.leaveGroup);
eventHub.$off('updatePagination', this.updatePagination);
eventHub.$off('updateGroups', this.updateGroups);
},
};
</script>
<template>
<div>
<loading-icon
class="loading-animation prepend-top-20"
size="2"
v-if="isLoading"
:label="s__('GroupsTree|Loading groups')"
/>
<groups-component
v-if="!isLoading"
:groups="groups"
:search-empty="isSearchEmpty"
:search-empty-message="searchEmptyMessage"
:page-info="pageInfo"
/>
</div>
</template>
<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,18 +4,26 @@ import eventHub from '../event_hub'; ...@@ -4,18 +4,26 @@ 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: {
components: { type: Boolean,
tablePagination, required: true,
},
searchEmptyMessage: {
type: String,
required: true,
},
}, },
methods: { methods: {
change(page) { change(page) {
...@@ -29,10 +37,17 @@ export default { ...@@ -29,10 +37,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?')"
: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() {
...@@ -35,11 +36,11 @@ export default class GroupFilterableList extends FilterableList { ...@@ -35,11 +36,11 @@ export default class GroupFilterableList extends FilterableList {
e.preventDefault(); e.preventDefault();
const $form = $(this.form); const $form = $(this.form);
const filterGroupsParam = $form.find('[name="filter_groups"]').val(); const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
const queryData = {}; const queryData = {};
if (filterGroupsParam) { if (filterGroupsParam) {
queryData.filter_groups = filterGroupsParam; queryData[this.filterInputField] = filterGroupsParam;
} }
this.filterResults(queryData); this.filterResults(queryData);
...@@ -47,7 +48,7 @@ export default class GroupFilterableList extends FilterableList { ...@@ -47,7 +48,7 @@ export default class GroupFilterableList extends FilterableList {
} }
setDefaultFilterOption() { setDefaultFilterOption() {
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text()); const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a').first().text());
this.$dropdown.find('.dropdown-label').text(defaultOption); this.$dropdown.find('.dropdown-label').text(defaultOption);
} }
...@@ -65,13 +66,15 @@ export default class GroupFilterableList extends FilterableList { ...@@ -65,13 +66,15 @@ export default class GroupFilterableList extends FilterableList {
// Active selected option // Active selected option
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 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 +85,11 @@ export default class GroupFilterableList extends FilterableList { ...@@ -82,7 +85,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);
} }
} }
/* global Flash */ /* global Flash */
import Vue from 'vue'; import Vue from 'vue';
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 NewGroupChild from './new_group_child';
import GroupFolder from './components/group_folder.vue'; import GroupsStore from './store/groups_store';
import GroupItem from './components/group_item.vue'; import GroupsService from './service/groups_service';
import GroupsStore from './stores/groups_store';
import GroupsService from './services/groups_service'; import groupsApp from './components/app.vue';
import eventHub from './event_hub'; import groupFolderComponent from './components/group_folder.vue';
import { getParameterByName } from '../lib/utils/common_utils'; import groupItemComponent from './components/group_item.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('dashboard-group-app'); const el = document.getElementById('js-groups-tree');
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
// Don't do anything if element doesn't exist (No groups) // 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
...@@ -19,176 +24,61 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -19,176 +24,61 @@ 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);
if (newGroupChildWrapper) {
// eslint-disable-next-line no-new
new NewGroupChild(newGroupChildWrapper);
}
// 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);
}
}
}
...@@ -20,7 +20,7 @@ export default class GroupsService { ...@@ -20,7 +20,7 @@ export default class GroupsService {
} }
if (filterGroups) { if (filterGroups) {
data.filter_groups = filterGroups; data.filter = filterGroups;
} }
if (sort) { if (sort) {
......
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;
}
.dropdown-menu-align-right {
margin-top: 0;
}
.new-project-subgroup {
.dropdown-primary {
min-width: 115px;
}
.dropdown-toggle {
.dropdown-btn-icon {
pointer-events: none;
color: inherit;
margin-left: 0;
}
} }
.nav-controls { .dropdown-menu {
width: 65%; min-width: 280px;
margin-top: 2px;
}
li:not(.divider) {
padding: 0;
&.droplab-item-selected {
.icon-container {
.list-item-checkmark {
visibility: visible;
}
}
}
.menu-item {
padding: 8px 4px;
&:hover {
background-color: $gray-darker;
color: $theme-gray-900;
}
}
.icon-container {
float: left;
padding-left: 6px;
.list-item-checkmark {
visibility: hidden;
}
}
.description {
font-size: 14px;
strong {
display: block;
font-weight: $gl-font-weight-bold;
}
}
}
}
@media (max-width: $screen-sm-max) {
&,
.dropdown,
.dropdown .dropdown-toggle,
.btn-new {
display: block;
}
.group-filter-form,
.dropdown {
margin-bottom: 10px;
margin-right: 0;
}
.group-filter-form,
.dropdown .dropdown-toggle,
.btn-new {
width: 100%;
}
.dropdown .dropdown-toggle .fa-chevron-down {
position: absolute;
top: 11px;
right: 8px;
}
.new-project-subgroup {
display: flex;
align-items: flex-start;
.dropdown-primary {
flex: 1;
}
.dropdown-menu {
width: 100%;
max-width: inherit;
min-width: inherit;
}
} }
} }
} }
......
...@@ -42,6 +42,17 @@ module SortingHelper ...@@ -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,
......
.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'
- if children.any? = webpack_bundle_tag 'common_vue'
render children here = webpack_bundle_tag 'groups'
- else
.nothing-here-block No children found .js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
...@@ -7,12 +7,35 @@ ...@@ -7,12 +7,35 @@
= render 'groups/home_panel' = render 'groups/home_panel'
.groups-header{ class: container_class } .groups-header{ class: container_class }
.top-area .group-nav-container
.nav-controls .nav-controls.clearfix
= render 'shared/projects/search_form' = render "shared/groups/search_form"
= render 'shared/projects/dropdown' = render "shared/groups/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")
.btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
%input.btn.btn-success.dropdown-primary.js-new-group-child{ type: "button", value: new_project_label, data: { action: "new-project" } }
%button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown" } }
= icon("caret-down", class: "dropdown-btn-icon")
%ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
%li.droplab-item-selected{ role: "button", data: { value: "new-project", text: new_project_label } }
.menu-item
.icon-container
= icon("check", class: "list-item-checkmark")
.description
%strong= new_project_label
%span= s_("GroupsTree|Create project under this group.")
%li.divider.droplap-item-ignore
%li{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
.menu-item
.icon-container
= icon("check", class: "list-item-checkmark")
.description
%strong= new_subgroup_label
%span= s_("GroupsTree|Create a subgroup under this group.")
= render "children", children: @children - if params[:filter].blank? && @children.empty?
= render "shared/groups/empty_state"
- else
= render "children", children: @children, group: @group
.dropdown.inline.js-group-filter-dropdown-wrap - if @sort.present?
- default_sort_by = @sort
- else
- if params[:sort]
- default_sort_by = params[:sort]
- else
- default_sort_by = sort_value_recently_created
.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %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
= 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
= link_to filter_groups_path(sort: sort_value_oldest_updated) do
= sort_title_oldest_updated
.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
......
...@@ -16,6 +16,7 @@ feature 'Dashboard Groups page', :js do ...@@ -16,6 +16,7 @@ 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.full_name)
expect(page).to have_content(nested_group.full_name) expect(page).to have_content(nested_group.full_name)
...@@ -33,7 +34,7 @@ feature 'Dashboard Groups page', :js do ...@@ -33,7 +34,7 @@ feature 'Dashboard 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)
...@@ -42,10 +43,10 @@ feature 'Dashboard Groups page', :js do ...@@ -42,10 +43,10 @@ feature 'Dashboard 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)
......
...@@ -15,6 +15,7 @@ describe 'Explore Groups page', :js do ...@@ -15,6 +15,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
...@@ -24,7 +25,7 @@ describe 'Explore Groups page', :js do ...@@ -24,7 +25,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)
...@@ -33,10 +34,10 @@ describe 'Explore Groups page', :js do ...@@ -33,10 +34,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)
...@@ -47,21 +48,21 @@ describe 'Explore Groups page', :js do ...@@ -47,21 +48,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
......
This diff is collapsed.
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));
});
});
});
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();
});
});
});
});
This diff is collapsed.
import Vue from 'vue';
import VueResource from 'vue-resource';
import GroupsService from '~/groups/service/groups_service';
import { mockEndpoint, mockParentGroupItem } from '../mock_data';
Vue.use(VueResource);
describe('GroupsService', () => {
let service;
beforeEach(() => {
service = new GroupsService(mockEndpoint);
});
describe('getGroups', () => {
it('should return promise for `GET` request on provided endpoint', () => {
spyOn(service.groups, 'get').and.stub();
const queryParams = {
page: 2,
filter: 'git',
sort: 'created_asc',
};
service.getGroups(55, 2, 'git', 'created_asc');
expect(service.groups.get).toHaveBeenCalledWith({ parent_id: 55 });
service.getGroups(null, 2, 'git', 'created_asc');
expect(service.groups.get).toHaveBeenCalledWith(queryParams);
});
});
describe('leaveGroup', () => {
it('should return promise for `DELETE` request on provided endpoint', () => {
spyOn(Vue.http, 'delete').and.stub();
service.leaveGroup(mockParentGroupItem.leavePath);
expect(Vue.http.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
});
});
});
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);
});
});
});
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