Skip to content
Snippets Groups Projects
Commit de553961 authored by Kushal Pandya's avatar Kushal Pandya Committed by Bob Van Landuyt
Browse files

Groups tree enhancements for Groups Dashboard and Group Homepage

parent 67815272
No related branches found
No related tags found
No related merge requests found
Showing
with 1032 additions and 523 deletions
Loading
Loading
@@ -6,10 +6,11 @@ import _ from 'underscore';
*/
 
export default class FilterableList {
constructor(form, filter, holder) {
constructor(form, filter, holder, filterInputField = 'filter_groups') {
this.filterForm = form;
this.listFilterElement = filter;
this.listHolderElement = holder;
this.filterInputField = filterInputField;
this.isBusy = false;
}
 
Loading
Loading
@@ -32,10 +33,10 @@ export default class FilterableList {
onFilterInput() {
const $form = $(this.filterForm);
const queryData = {};
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
 
if (filterGroupsParam) {
queryData.filter_groups = filterGroupsParam;
queryData[this.filterInputField] = filterGroupsParam;
}
 
this.filterResults(queryData);
Loading
Loading
<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>
import { n__ } from '../../locale';
import { MAX_CHILDREN_COUNT } from '../constants';
export default {
props: {
groups: {
type: Object,
required: true,
},
baseGroup: {
parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
groups: {
type: Array,
required: false,
default: () => ([]),
},
},
computed: {
hasMoreChildren() {
return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT;
},
moreChildrenStats() {
return n__('One more item', '%d more items', this.parentGroup.childrenCount - this.parentGroup.children.length);
},
},
};
</script>
Loading
Loading
@@ -20,8 +32,20 @@ export default {
v-for="(group, index) in groups"
:key="index"
:group="group"
:base-group="baseGroup"
:collection="groups"
:parent-group="parentGroup"
/>
<li
v-if="hasMoreChildren"
class="group-row">
<a
:href="parentGroup.relativePath"
class="group-row-contents has-more-items">
<i
class="fa fa-external-link"
aria-hidden="true"
/>
{{moreChildrenStats}}
</a>
</li>
</ul>
</template>
Loading
Loading
@@ -2,49 +2,28 @@
import identicon from '../../vue_shared/components/identicon.vue';
import eventHub from '../event_hub';
 
import itemCaret from './item_caret.vue';
import itemTypeIcon from './item_type_icon.vue';
import itemStats from './item_stats.vue';
import itemActions from './item_actions.vue';
export default {
components: {
identicon,
itemCaret,
itemTypeIcon,
itemStats,
itemActions,
},
props: {
group: {
type: Object,
required: true,
},
baseGroup: {
parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
collection: {
group: {
type: Object,
required: false,
default: () => ({}),
},
},
methods: {
onClickRowGroup(e) {
e.stopPropagation();
// Skip for buttons
if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) {
if (this.group.hasSubgroups) {
eventHub.$emit('toggleSubGroups', this.group);
} else {
window.location.href = this.group.groupPath;
}
}
},
onLeaveGroup(e) {
e.preventDefault();
// eslint-disable-next-line no-alert
if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) {
this.leaveGroup();
}
},
leaveGroup() {
eventHub.$emit('leaveGroup', this.group, this.collection);
required: true,
},
},
computed: {
Loading
Loading
@@ -53,51 +32,33 @@ export default {
},
rowClass() {
return {
'group-row': true,
'is-open': this.group.isOpen,
'has-subgroups': this.group.hasSubgroups,
'no-description': !this.group.description,
'has-children': this.hasChildren,
'has-description': this.group.description,
'being-removed': this.group.isBeingRemoved,
};
},
visibilityIcon() {
return {
fa: true,
'fa-globe': this.group.visibility === 'public',
'fa-shield': this.group.visibility === 'internal',
'fa-lock': this.group.visibility === 'private',
};
hasChildren() {
return this.group.childrenCount > 0;
},
fullPath() {
let fullPath = '';
if (this.group.isOrphan) {
// check if current group is baseGroup
if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) {
// Remove baseGroup prefix from our current group.fullName. e.g:
// baseGroup.fullName: `level1`
// group.fullName: `level1 / level2 / level3`
// Result: `level2 / level3`
const gfn = this.group.fullName;
const bfn = this.baseGroup.fullName;
const length = bfn.length;
const start = gfn.indexOf(bfn);
const extraPrefixChars = 3;
fullPath = gfn.substr(start + length + extraPrefixChars);
hasAvatar() {
return this.group.avatarUrl !== null;
},
isGroup() {
return this.group.type === 'group';
},
},
methods: {
onClickRowGroup(e) {
const NO_EXPAND_CLS = 'no-expand';
if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
if (this.hasChildren) {
eventHub.$emit('toggleChildren', this.group);
} else {
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;
},
},
};
Loading
Loading
@@ -108,98 +69,36 @@ export default {
@click.stop="onClickRowGroup"
:id="groupDomId"
:class="rowClass"
class="group-row"
>
<div
class="group-row-contents">
<div
class="controls">
<a
v-if="group.canEdit"
class="edit-group btn"
:href="group.editPath">
<i
class="fa fa-cogs"
aria-hidden="true"
>
</i>
</a>
<a
@click="onLeaveGroup"
:href="group.leavePath"
class="leave-group btn"
title="Leave this group">
<i
class="fa fa-sign-out"
aria-hidden="true"
>
</i>
</a>
</div>
<div
class="stats">
<span
class="number-projects">
<i
class="fa fa-bookmark"
aria-hidden="true"
>
</i>
{{group.numberProjects}}
</span>
<span
class="number-users">
<i
class="fa fa-users"
aria-hidden="true"
>
</i>
{{group.numberUsers}}
</span>
<span
class="group-visibility">
<i
:class="visibilityIcon"
aria-hidden="true"
>
</i>
</span>
</div>
<item-actions
v-if="isGroup"
:group="group"
:parent-group="parentGroup"
/>
<item-stats
:item="group"
/>
<div
class="folder-toggle-wrap">
<span
class="folder-caret"
v-if="group.hasSubgroups">
<i
v-if="group.isOpen"
class="fa fa-caret-down"
aria-hidden="true"
>
</i>
<i
v-if="!group.isOpen"
class="fa fa-caret-right"
aria-hidden="true"
>
</i>
</span>
<span class="folder-icon">
<i
v-if="group.isOpen"
class="fa fa-folder-open"
aria-hidden="true"
>
</i>
<i
v-if="!group.isOpen"
class="fa fa-folder"
aria-hidden="true">
</i>
</span>
<item-caret
:is-group-open="group.isOpen"
/>
<item-type-icon
:item-type="group.type"
:is-group-open="group.isOpen"
/>
</div>
<div
class="avatar-container s40 hidden-xs">
class="avatar-container s40 hidden-xs"
:class="{ 'content-loading': group.isChildrenLoading }"
>
<a
:href="group.groupPath">
:href="group.relativePath"
class="no-expand"
>
<img
v-if="hasAvatar"
class="avatar s40"
Loading
Loading
@@ -215,19 +114,22 @@ export default {
<div
class="title">
<a
:href="group.groupPath">{{fullPath}}</a>
<template v-if="group.permissions.humanGroupAccess">
as
<span class="access-type">{{group.permissions.humanGroupAccess}}</span>
</template>
:href="group.relativePath"
class="no-expand">{{group.fullName}}</a>
<span
v-if="group.permission"
class="access-type"
>
{{s__('GroupsTreeRole|as')}} {{group.permission}}
</span>
</div>
<div
class="description">{{group.description}}</div>
</div>
<group-folder
v-if="group.isOpen && hasGroups"
:groups="group.subGroups"
:baseGroup="group"
v-if="group.isOpen && hasChildren"
:parent-group="group"
:groups="group.children"
/>
</li>
</template>
Loading
Loading
@@ -4,18 +4,26 @@ import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils';
 
export default {
components: {
tablePagination,
},
props: {
groups: {
type: Object,
type: Array,
required: true,
},
pageInfo: {
type: Object,
required: true,
},
},
components: {
tablePagination,
searchEmpty: {
type: Boolean,
required: true,
},
searchEmptyMessage: {
type: String,
required: true,
},
},
methods: {
change(page) {
Loading
Loading
@@ -29,10 +37,17 @@ export default {
 
<template>
<div class="groups-list-tree-container">
<div
v-if="searchEmpty"
class="has-no-search-results">
{{searchEmptyMessage}}
</div>
<group-folder
v-if="!searchEmpty"
:groups="groups"
/>
<table-pagination
v-if="!searchEmpty"
:change="change"
:pageInfo="pageInfo"
/>
Loading
Loading
<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',
};
Loading
Loading
@@ -3,12 +3,13 @@ import eventHub from './event_hub';
import { getParameterByName } from '../lib/utils/common_utils';
 
export default class GroupFilterableList extends FilterableList {
constructor({ form, filter, holder, filterEndpoint, pagePath }) {
super(form, filter, holder);
constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
super(form, filter, holder, filterInputField);
this.form = form;
this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath;
this.$dropdown = $('.js-group-filter-dropdown-wrap');
this.filterInputField = filterInputField;
this.$dropdown = $(dropdownSel);
}
 
getFilterEndpoint() {
Loading
Loading
@@ -35,11 +36,11 @@ export default class GroupFilterableList extends FilterableList {
e.preventDefault();
 
const $form = $(this.form);
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
const queryData = {};
 
if (filterGroupsParam) {
queryData.filter_groups = filterGroupsParam;
queryData[this.filterInputField] = filterGroupsParam;
}
 
this.filterResults(queryData);
Loading
Loading
@@ -47,7 +48,7 @@ export default class GroupFilterableList extends FilterableList {
}
 
setDefaultFilterOption() {
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a').first().text());
this.$dropdown.find('.dropdown-label').text(defaultOption);
}
 
Loading
Loading
@@ -65,13 +66,15 @@ export default class GroupFilterableList extends FilterableList {
 
// Active selected option
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
this.$dropdown.find('.dropdown-menu li a').removeClass('is-active');
$(e.target).addClass('is-active');
 
// Clear current value on search form
this.form.querySelector('[name="filter_groups"]').value = '';
this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
}
 
onFilterSuccess(data, xhr, queryData) {
super.onFilterSuccess(data, xhr, queryData);
const currentPath = this.getPagePath(queryData);
 
const paginationData = {
'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
Loading
Loading
@@ -82,7 +85,11 @@ export default class GroupFilterableList extends FilterableList {
'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
};
 
eventHub.$emit('updateGroups', data);
window.history.replaceState({
page: currentPath,
}, document.title, currentPath);
eventHub.$emit('updateGroups', data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
eventHub.$emit('updatePagination', paginationData);
}
}
/* global Flash */
 
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import GroupFilterableList from './groups_filterable_list';
import GroupsComponent from './components/groups.vue';
import GroupFolder from './components/group_folder.vue';
import GroupItem from './components/group_item.vue';
import GroupsStore from './stores/groups_store';
import GroupsService from './services/groups_service';
import eventHub from './event_hub';
import { getParameterByName } from '../lib/utils/common_utils';
import NewGroupChild from './new_group_child';
import GroupsStore from './store/groups_store';
import GroupsService from './service/groups_service';
import groupsApp from './components/app.vue';
import groupFolderComponent from './components/group_folder.vue';
import groupItemComponent from './components/group_item.vue';
Vue.use(Translate);
 
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('dashboard-group-app');
const el = document.getElementById('js-groups-tree');
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
 
// Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL
Loading
Loading
@@ -19,176 +24,61 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
 
Vue.component('groups-component', GroupsComponent);
Vue.component('group-folder', GroupFolder);
Vue.component('group-item', GroupItem);
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
if (newGroupChildWrapper) {
// eslint-disable-next-line no-new
new NewGroupChild(newGroupChildWrapper);
}
 
// eslint-disable-next-line no-new
new Vue({
el,
components: {
groupsApp,
},
data() {
this.store = new GroupsStore();
this.service = new GroupsService(el.dataset.endpoint);
const dataset = this.$options.el.dataset;
const hideProjects = dataset.hideProjects === 'true';
const store = new GroupsStore(hideProjects);
const service = new GroupsService(dataset.endpoint);
 
return {
store: this.store,
isLoading: true,
state: this.store.state,
store,
service,
hideProjects,
loading: true,
};
},
computed: {
isEmpty() {
return Object.keys(this.state.groups).length === 0;
},
},
methods: {
fetchGroups(parentGroup) {
let parentId = null;
let getGroups = null;
let page = null;
let sort = null;
let pageParam = null;
let sortParam = null;
let filterGroups = null;
let filterGroupsParam = null;
if (parentGroup) {
parentId = parentGroup.id;
} else {
this.isLoading = true;
}
pageParam = getParameterByName('page');
if (pageParam) {
page = pageParam;
}
filterGroupsParam = getParameterByName('filter_groups');
if (filterGroupsParam) {
filterGroups = filterGroupsParam;
}
sortParam = getParameterByName('sort');
if (sortParam) {
sort = sortParam;
}
getGroups = this.service.getGroups(parentId, page, filterGroups, sort);
getGroups
.then(response => response.json())
.then((response) => {
this.isLoading = false;
this.updateGroups(response, parentGroup);
})
.catch(this.handleErrorResponse);
return getGroups;
},
fetchPage(page, filterGroups, sort) {
this.isLoading = true;
return this.service
.getGroups(null, page, filterGroups, sort)
.then((response) => {
this.isLoading = false;
$.scrollTo(0);
const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
window.history.replaceState({
page: currentPath,
}, document.title, currentPath);
return response.json().then((data) => {
this.updateGroups(data);
this.updatePagination(response.headers);
});
})
.catch(this.handleErrorResponse);
},
toggleSubGroups(parentGroup = null) {
if (!parentGroup.isOpen) {
this.store.resetGroups(parentGroup);
this.fetchGroups(parentGroup);
}
this.store.toggleSubGroups(parentGroup);
},
leaveGroup(group, collection) {
this.service.leaveGroup(group.leavePath)
.then(resp => resp.json())
.then((response) => {
$.scrollTo(0);
this.store.removeGroup(group, collection);
// eslint-disable-next-line no-new
new Flash(response.notice, 'notice');
})
.catch((error) => {
let message = 'An error occurred. Please try again.';
if (error.status === 403) {
message = 'Failed to leave the group. Please make sure you are not the only owner';
}
// eslint-disable-next-line no-new
new Flash(message);
});
},
updateGroups(groups, parentGroup) {
this.store.setGroups(groups, parentGroup);
},
updatePagination(headers) {
this.store.storePagination(headers);
},
handleErrorResponse() {
this.isLoading = false;
$.scrollTo(0);
// eslint-disable-next-line no-new
new Flash('An error occurred. Please try again.');
},
},
created() {
eventHub.$on('fetchPage', this.fetchPage);
eventHub.$on('toggleSubGroups', this.toggleSubGroups);
eventHub.$on('leaveGroup', this.leaveGroup);
eventHub.$on('updateGroups', this.updateGroups);
eventHub.$on('updatePagination', this.updatePagination);
},
beforeMount() {
const dataset = this.$options.el.dataset;
let groupFilterList = null;
const form = document.querySelector('form#group-filter-form');
const filter = document.querySelector('.js-groups-list-filter');
const holder = document.querySelector('.js-groups-list-holder');
const form = document.querySelector(dataset.formSel);
const filter = document.querySelector(dataset.filterSel);
const holder = document.querySelector(dataset.holderSel);
 
const opts = {
form,
filter,
holder,
filterEndpoint: el.dataset.endpoint,
pagePath: el.dataset.path,
filterEndpoint: dataset.endpoint,
pagePath: dataset.path,
dropdownSel: dataset.dropdownSel,
filterInputField: 'filter',
};
 
groupFilterList = new GroupFilterableList(opts);
groupFilterList.initSearch();
},
mounted() {
this.fetchGroups()
.then((response) => {
this.updatePagination(response.headers);
this.isLoading = false;
})
.catch(this.handleErrorResponse);
},
beforeDestroy() {
eventHub.$off('fetchPage', this.fetchPage);
eventHub.$off('toggleSubGroups', this.toggleSubGroups);
eventHub.$off('leaveGroup', this.leaveGroup);
eventHub.$off('updateGroups', this.updateGroups);
eventHub.$off('updatePagination', this.updatePagination);
render(createElement) {
return createElement('groups-app', {
props: {
store: this.store,
service: this.service,
hideProjects: this.hideProjects,
},
});
},
});
});
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);
}
}
}
Loading
Loading
@@ -20,7 +20,7 @@ export default class GroupsService {
}
 
if (filterGroups) {
data.filter_groups = filterGroups;
data.filter = filterGroups;
}
 
if (sort) {
Loading
Loading
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;
}
}
Loading
Loading
@@ -281,6 +281,57 @@ ul.indent-list {
 
 
// Specific styles for tree list
@keyframes spin-avatar {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.groups-list-tree-container {
.has-no-search-results {
text-align: center;
padding: $gl-padding;
font-style: italic;
color: $well-light-text-color;
}
> .group-list-tree > .group-row.has-children:first-child {
border-top: none;
}
}
.group-list-tree .avatar-container.content-loading {
position: relative;
> a,
> a .avatar {
height: 100%;
border-radius: 50%;
}
> a {
padding: 2px;
}
> a .avatar {
border: 2px solid $white-normal;
&.identicon {
line-height: 30px;
}
}
&::after {
content: "";
position: absolute;
height: 100%;
width: 100%;
background-color: transparent;
border: 2px outset $kdb-border;
border-radius: 50%;
animation: spin-avatar 3s infinite linear;
}
}
.group-list-tree {
.folder-toggle-wrap {
float: left;
Loading
Loading
@@ -293,7 +344,7 @@ ul.indent-list {
}
 
.folder-caret,
.folder-icon {
.item-type-icon {
display: inline-block;
}
 
Loading
Loading
@@ -301,11 +352,11 @@ ul.indent-list {
width: 15px;
}
 
.folder-icon {
.item-type-icon {
width: 20px;
}
 
> .group-row:not(.has-subgroups) {
> .group-row:not(.has-children) {
.folder-caret .fa {
opacity: 0;
}
Loading
Loading
@@ -351,12 +402,23 @@ ul.indent-list {
top: 30px;
bottom: 0;
}
&.being-removed {
opacity: 0.5;
}
}
}
 
.group-row {
padding: 0;
border: none;
&.has-children {
border-top: none;
}
&:first-child {
border-top: 1px solid $white-normal;
}
 
&:last-of-type {
.group-row-contents:not(:hover) {
Loading
Loading
@@ -379,6 +441,25 @@ ul.indent-list {
.avatar-container > a {
width: 100%;
}
&.has-more-items {
display: block;
padding: 20px 10px;
}
}
}
ul.group-list-tree {
li.group-row {
&.has-description {
.title {
line-height: inherit;
}
}
.title {
line-height: $list-text-height;
}
}
}
 
Loading
Loading
Loading
Loading
@@ -26,14 +26,117 @@
}
}
 
.groups-header {
@media (min-width: $screen-sm-min) {
.nav-links {
width: 35%;
.group-nav-container .nav-controls {
display: flex;
align-items: flex-start;
padding: $gl-padding-top 0;
border-bottom: 1px solid $border-color;
.group-filter-form {
flex: 1;
}
.dropdown-menu-align-right {
margin-top: 0;
}
.new-project-subgroup {
.dropdown-primary {
min-width: 115px;
}
.dropdown-toggle {
.dropdown-btn-icon {
pointer-events: none;
color: inherit;
margin-left: 0;
}
}
 
.nav-controls {
width: 65%;
.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;
}
}
}
}
Loading
Loading
Loading
Loading
@@ -42,6 +42,17 @@ module SortingHelper
options
end
 
def groups_sort_options_hash
options = {
sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created,
sort_value_recently_updated => sort_title_recently_updated,
sort_value_oldest_updated => sort_title_oldest_updated
}
options
end
def member_sort_options_hash
{
sort_value_access_level_asc => sort_title_access_level_asc,
Loading
Loading
.top-area
%ul.nav-links
= nav_link(page: dashboard_groups_path) do
= link_to dashboard_groups_path, title: 'Your groups' do
= link_to dashboard_groups_path, title: _("Your groups") do
Your groups
= nav_link(page: explore_groups_path) do
= link_to explore_groups_path, title: 'Explore public groups' do
= link_to explore_groups_path, title: _("Explore public groups") do
Explore public groups
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
- if current_user.can_create_group?
= link_to "New group", new_group_path, class: "btn btn-new"
= link_to _("New group"), new_group_path, class: "btn btn-new"
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment