Merge branch '25426-group-dashboard-ui' into 'master'
Resolve "Group dashboard UI" Closes #25426 See merge request !11098
This commit is contained in:
commit
4b1c49171d
|
@ -160,9 +160,6 @@ import initSettingsPanels from './settings_panels';
|
|||
case 'admin:projects:index':
|
||||
new ProjectsList();
|
||||
break;
|
||||
case 'dashboard:groups:index':
|
||||
new GroupsList();
|
||||
break;
|
||||
case 'explore:groups:index':
|
||||
new GroupsList();
|
||||
|
||||
|
|
|
@ -8,39 +8,87 @@ export default class FilterableList {
|
|||
this.filterForm = form;
|
||||
this.listFilterElement = filter;
|
||||
this.listHolderElement = holder;
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
getFilterEndpoint() {
|
||||
return `${this.filterForm.getAttribute('action')}?${$(this.filterForm).serialize()}`;
|
||||
}
|
||||
|
||||
getPagePath() {
|
||||
return this.getFilterEndpoint();
|
||||
}
|
||||
|
||||
initSearch() {
|
||||
this.debounceFilter = _.debounce(this.filterResults.bind(this), 500);
|
||||
// Wrap to prevent passing event arguments to .filterResults;
|
||||
this.debounceFilter = _.debounce(this.onFilterInput.bind(this), 500);
|
||||
|
||||
this.listFilterElement.removeEventListener('input', this.debounceFilter);
|
||||
this.unbindEvents();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
onFilterInput() {
|
||||
const $form = $(this.filterForm);
|
||||
const queryData = {};
|
||||
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
|
||||
|
||||
if (filterGroupsParam) {
|
||||
queryData.filter_groups = filterGroupsParam;
|
||||
}
|
||||
|
||||
this.filterResults(queryData);
|
||||
|
||||
if (this.setDefaultFilterOption) {
|
||||
this.setDefaultFilterOption();
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.listFilterElement.addEventListener('input', this.debounceFilter);
|
||||
}
|
||||
|
||||
filterResults() {
|
||||
const form = this.filterForm;
|
||||
const filterUrl = `${form.getAttribute('action')}?${$(form).serialize()}`;
|
||||
unbindEvents() {
|
||||
this.listFilterElement.removeEventListener('input', this.debounceFilter);
|
||||
}
|
||||
|
||||
filterResults(queryData) {
|
||||
if (this.isBusy) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$(this.listHolderElement).fadeTo(250, 0.5);
|
||||
|
||||
return $.ajax({
|
||||
url: form.getAttribute('action'),
|
||||
data: $(form).serialize(),
|
||||
url: this.getFilterEndpoint(),
|
||||
data: queryData,
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
context: this,
|
||||
complete() {
|
||||
$(this.listHolderElement).fadeTo(250, 1);
|
||||
complete: this.onFilterComplete,
|
||||
beforeSend: () => {
|
||||
this.isBusy = true;
|
||||
},
|
||||
success(data) {
|
||||
this.listHolderElement.innerHTML = data.html;
|
||||
|
||||
// Change url so if user reload a page - search results are saved
|
||||
return window.history.replaceState({
|
||||
page: filterUrl,
|
||||
|
||||
}, document.title, filterUrl);
|
||||
success: (response, textStatus, xhr) => {
|
||||
this.onFilterSuccess(response, xhr, queryData);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onFilterSuccess(response, xhr, queryData) {
|
||||
if (response.html) {
|
||||
this.listHolderElement.innerHTML = response.html;
|
||||
}
|
||||
|
||||
// Change url so if user reload a page - search results are saved
|
||||
const currentPath = this.getPagePath(queryData);
|
||||
|
||||
return window.history.replaceState({
|
||||
page: currentPath,
|
||||
}, document.title, currentPath);
|
||||
}
|
||||
|
||||
onFilterComplete() {
|
||||
this.isBusy = false;
|
||||
$(this.listHolderElement).fadeTo(250, 1);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
<script>
|
||||
export default {
|
||||
props: {
|
||||
groups: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
baseGroup: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="content-list group-list-tree">
|
||||
<group-item
|
||||
v-for="(group, index) in groups"
|
||||
:key="index"
|
||||
:group="group"
|
||||
:base-group="baseGroup"
|
||||
:collection="groups"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
|
@ -0,0 +1,220 @@
|
|||
<script>
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
baseGroup: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
collection: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClickRowGroup(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
// Skip for buttons
|
||||
if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) {
|
||||
if (this.group.hasSubgroups) {
|
||||
eventHub.$emit('toggleSubGroups', this.group);
|
||||
} else {
|
||||
window.location.href = this.group.webUrl;
|
||||
}
|
||||
}
|
||||
},
|
||||
onLeaveGroup(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// eslint-disable-next-line no-alert
|
||||
if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) {
|
||||
this.leaveGroup();
|
||||
}
|
||||
},
|
||||
leaveGroup() {
|
||||
eventHub.$emit('leaveGroup', this.group, this.collection);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
groupDomId() {
|
||||
return `group-${this.group.id}`;
|
||||
},
|
||||
rowClass() {
|
||||
return {
|
||||
'group-row': true,
|
||||
'is-open': this.group.isOpen,
|
||||
'has-subgroups': this.group.hasSubgroups,
|
||||
'no-description': !this.group.description,
|
||||
};
|
||||
},
|
||||
visibilityIcon() {
|
||||
return {
|
||||
fa: true,
|
||||
'fa-globe': this.group.visibility === 'public',
|
||||
'fa-shield': this.group.visibility === 'internal',
|
||||
'fa-lock': this.group.visibility === 'private',
|
||||
};
|
||||
},
|
||||
fullPath() {
|
||||
let fullPath = '';
|
||||
|
||||
if (this.group.isOrphan) {
|
||||
// check if current group is baseGroup
|
||||
if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) {
|
||||
// Remove baseGroup prefix from our current group.fullName. e.g:
|
||||
// baseGroup.fullName: `level1`
|
||||
// group.fullName: `level1 / level2 / level3`
|
||||
// Result: `level2 / level3`
|
||||
const gfn = this.group.fullName;
|
||||
const bfn = this.baseGroup.fullName;
|
||||
const length = bfn.length;
|
||||
const start = gfn.indexOf(bfn);
|
||||
const extraPrefixChars = 3;
|
||||
|
||||
fullPath = gfn.substr(start + length + extraPrefixChars);
|
||||
} else {
|
||||
fullPath = this.group.fullName;
|
||||
}
|
||||
} else {
|
||||
fullPath = this.group.name;
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
},
|
||||
hasGroups() {
|
||||
return Object.keys(this.group.subGroups).length > 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
@click.stop="onClickRowGroup"
|
||||
:id="groupDomId"
|
||||
:class="rowClass"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
class="avatar-container s40 hidden-xs">
|
||||
<a
|
||||
:href="group.webUrl">
|
||||
<img
|
||||
class="avatar s40"
|
||||
:src="group.avatarUrl"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="title">
|
||||
<a
|
||||
:href="group.webUrl">{{fullPath}}</a>
|
||||
<template v-if="group.permissions.humanGroupAccess">
|
||||
as
|
||||
<span class="access-type">{{group.permissions.humanGroupAccess}}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="description">{{group.description}}</div>
|
||||
</div>
|
||||
<group-folder
|
||||
v-if="group.isOpen && hasGroups"
|
||||
:groups="group.subGroups"
|
||||
:baseGroup="group"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
|
@ -0,0 +1,39 @@
|
|||
<script>
|
||||
import tablePagination from '~/vue_shared/components/table_pagination.vue';
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
groups: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
pageInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
tablePagination,
|
||||
},
|
||||
methods: {
|
||||
change(page) {
|
||||
const filterGroupsParam = gl.utils.getParameterByName('filter_groups');
|
||||
const sortParam = gl.utils.getParameterByName('sort');
|
||||
eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="groups-list-tree-container">
|
||||
<group-folder
|
||||
:groups="groups"
|
||||
/>
|
||||
<table-pagination
|
||||
:change="change"
|
||||
:pageInfo="pageInfo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
|
@ -0,0 +1,87 @@
|
|||
import FilterableList from '~/filterable_list';
|
||||
import eventHub from './event_hub';
|
||||
|
||||
export default class GroupFilterableList extends FilterableList {
|
||||
constructor({ form, filter, holder, filterEndpoint, pagePath }) {
|
||||
super(form, filter, holder);
|
||||
this.form = form;
|
||||
this.filterEndpoint = filterEndpoint;
|
||||
this.pagePath = pagePath;
|
||||
this.$dropdown = $('.js-group-filter-dropdown-wrap');
|
||||
}
|
||||
|
||||
getFilterEndpoint() {
|
||||
return this.filterEndpoint;
|
||||
}
|
||||
|
||||
getPagePath(queryData) {
|
||||
const params = queryData ? $.param(queryData) : '';
|
||||
const queryString = params ? `?${params}` : '';
|
||||
return `${this.pagePath}${queryString}`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
super.bindEvents();
|
||||
|
||||
this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
|
||||
this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
|
||||
|
||||
this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
|
||||
this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
|
||||
}
|
||||
|
||||
onFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const $form = $(this.form);
|
||||
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
|
||||
const queryData = {};
|
||||
|
||||
if (filterGroupsParam) {
|
||||
queryData.filter_groups = filterGroupsParam;
|
||||
}
|
||||
|
||||
this.filterResults(queryData);
|
||||
this.setDefaultFilterOption();
|
||||
}
|
||||
|
||||
setDefaultFilterOption() {
|
||||
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
|
||||
this.$dropdown.find('.dropdown-label').text(defaultOption);
|
||||
}
|
||||
|
||||
onOptionClick(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const queryData = {};
|
||||
const sortParam = gl.utils.getParameterByName('sort', e.currentTarget.href);
|
||||
|
||||
if (sortParam) {
|
||||
queryData.sort = sortParam;
|
||||
}
|
||||
|
||||
this.filterResults(queryData);
|
||||
|
||||
// Active selected option
|
||||
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
|
||||
|
||||
// Clear current value on search form
|
||||
this.form.querySelector('[name="filter_groups"]').value = '';
|
||||
}
|
||||
|
||||
onFilterSuccess(data, xhr, queryData) {
|
||||
super.onFilterSuccess(data, xhr, queryData);
|
||||
|
||||
const paginationData = {
|
||||
'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
|
||||
'X-Page': xhr.getResponseHeader('X-Page'),
|
||||
'X-Total': xhr.getResponseHeader('X-Total'),
|
||||
'X-Total-Pages': xhr.getResponseHeader('X-Total-Pages'),
|
||||
'X-Next-Page': xhr.getResponseHeader('X-Next-Page'),
|
||||
'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
|
||||
};
|
||||
|
||||
eventHub.$emit('updateGroups', data);
|
||||
eventHub.$emit('updatePagination', paginationData);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,190 @@
|
|||
/* global Flash */
|
||||
|
||||
import Vue from 'vue';
|
||||
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';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const el = document.getElementById('dashboard-group-app');
|
||||
|
||||
// Don't do anything if element doesn't exist (No groups)
|
||||
// This is for when the user enters directly to the page via URL
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
Vue.component('groups-component', GroupsComponent);
|
||||
Vue.component('group-folder', GroupFolder);
|
||||
Vue.component('group-item', GroupItem);
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new Vue({
|
||||
el,
|
||||
data() {
|
||||
this.store = new GroupsStore();
|
||||
this.service = new GroupsService(el.dataset.endpoint);
|
||||
|
||||
return {
|
||||
store: this.store,
|
||||
isLoading: true,
|
||||
state: this.store.state,
|
||||
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 = gl.utils.getParameterByName('page');
|
||||
if (pageParam) {
|
||||
page = pageParam;
|
||||
}
|
||||
|
||||
filterGroupsParam = gl.utils.getParameterByName('filter_groups');
|
||||
if (filterGroupsParam) {
|
||||
filterGroups = filterGroupsParam;
|
||||
}
|
||||
|
||||
sortParam = gl.utils.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);
|
||||
|
||||
this.updateGroups(response.json());
|
||||
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((response) => {
|
||||
$.scrollTo(0);
|
||||
|
||||
this.store.removeGroup(group, collection);
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash(response.json().notice, 'notice');
|
||||
})
|
||||
.catch((response) => {
|
||||
let message = 'An error occurred. Please try again.';
|
||||
|
||||
if (response.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() {
|
||||
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 opts = {
|
||||
form,
|
||||
filter,
|
||||
holder,
|
||||
filterEndpoint: el.dataset.endpoint,
|
||||
pagePath: el.dataset.path,
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
import Vue from 'vue';
|
||||
import VueResource from 'vue-resource';
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
export default class GroupsService {
|
||||
constructor(endpoint) {
|
||||
this.groups = Vue.resource(endpoint);
|
||||
}
|
||||
|
||||
getGroups(parentId, page, filterGroups, sort) {
|
||||
const data = {};
|
||||
|
||||
if (parentId) {
|
||||
data.parent_id = parentId;
|
||||
} else {
|
||||
// Do not send the following param for sub groups
|
||||
if (page) {
|
||||
data.page = page;
|
||||
}
|
||||
|
||||
if (filterGroups) {
|
||||
data.filter_groups = filterGroups;
|
||||
}
|
||||
|
||||
if (sort) {
|
||||
data.sort = sort;
|
||||
}
|
||||
}
|
||||
|
||||
return this.groups.get(data);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
leaveGroup(endpoint) {
|
||||
return Vue.http.delete(endpoint);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
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 = gl.utils.normalizeHeaders(pagination);
|
||||
paginationInfo = gl.utils.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[group.id] = group;
|
||||
mappedGroups[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[currentGroup.parentId];
|
||||
if (findParentGroup) {
|
||||
mappedGroups[currentGroup.parentId].subGroups[currentGroup.id] = currentGroup;
|
||||
mappedGroups[currentGroup.parentId].isOpen = true; // Expand group if it has subgroups
|
||||
} else if (parentGroup && parentGroup.id === currentGroup.parentId) {
|
||||
tree[currentGroup.id] = currentGroup;
|
||||
} else {
|
||||
// Means the groups hast no direct parent.
|
||||
// Save for later processing, we will add them to its corresponding base group
|
||||
orphans.push(currentGroup);
|
||||
}
|
||||
} else {
|
||||
// If the group is at the root level, add it to first level elements array.
|
||||
tree[currentGroup.id] = currentGroup;
|
||||
}
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
// Hopefully this array will be empty for most cases
|
||||
if (orphans.length) {
|
||||
orphans.map((orphan) => {
|
||||
let found = false;
|
||||
const currentOrphan = orphan;
|
||||
|
||||
Object.keys(tree).map((key) => {
|
||||
const group = tree[key];
|
||||
if (currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0) {
|
||||
group.subGroups[currentOrphan.id] = currentOrphan;
|
||||
group.isOpen = true;
|
||||
currentOrphan.isOrphan = true;
|
||||
found = true;
|
||||
}
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
currentOrphan.isOrphan = true;
|
||||
tree[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,
|
||||
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, group.id);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
toggleSubGroups(toggleGroup) {
|
||||
const group = toggleGroup;
|
||||
group.isOpen = !group.isOpen;
|
||||
return group;
|
||||
}
|
||||
}
|
|
@ -167,8 +167,8 @@
|
|||
if the name does not exist this function will return `null`
|
||||
otherwise it will return the value of the param key provided
|
||||
*/
|
||||
w.gl.utils.getParameterByName = (name) => {
|
||||
const url = window.location.href;
|
||||
w.gl.utils.getParameterByName = (name, parseUrl) => {
|
||||
const url = parseUrl || window.location.href;
|
||||
name = name.replace(/[[\]]/g, '\\$&');
|
||||
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
|
||||
const results = regex.exec(url);
|
||||
|
|
|
@ -284,9 +284,7 @@ export default {
|
|||
|
||||
<table-pagination
|
||||
v-if="shouldRenderPagination"
|
||||
:pagenum="pagenum"
|
||||
:change="change"
|
||||
:count="state.count.all"
|
||||
:pageInfo="state.pageInfo"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -264,3 +264,103 @@ ul.controls {
|
|||
ul.indent-list {
|
||||
padding: 10px 0 0 30px;
|
||||
}
|
||||
|
||||
|
||||
// Specific styles for tree list
|
||||
.group-list-tree {
|
||||
.folder-toggle-wrap {
|
||||
float: left;
|
||||
line-height: $list-text-height;
|
||||
font-size: 0;
|
||||
|
||||
span {
|
||||
font-size: $gl-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.folder-caret,
|
||||
.folder-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.folder-caret {
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
> .group-row:not(.has-subgroups) {
|
||||
.folder-caret .fa {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content-list li:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.group-list-tree {
|
||||
margin-bottom: 0;
|
||||
margin-left: 30px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
bottom: 0;
|
||||
left: -16px;
|
||||
border-left: 2px solid $border-white-normal;
|
||||
}
|
||||
|
||||
.group-row {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 0;
|
||||
border-top: 2px solid $border-white-normal;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: -16px;
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
background: $white-light;
|
||||
height: auto;
|
||||
top: 30px;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-row {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.group-row-contents {
|
||||
padding: 10px 10px 8px;
|
||||
border-top: solid 1px transparent;
|
||||
border-bottom: solid 1px $white-normal;
|
||||
|
||||
&:hover {
|
||||
border-color: $row-hover-border;
|
||||
background-color: $row-hover;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.js-groups-list-holder {
|
||||
.groups-list-loading {
|
||||
font-size: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,11 +52,16 @@ module MembershipActions
|
|||
"You left the \"#{membershipable.human_name}\" #{source_type}."
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize]
|
||||
|
||||
redirect_to redirect_path, notice: notice
|
||||
end
|
||||
|
||||
format.json { render json: { notice: notice } }
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def membershipable
|
||||
|
|
|
@ -1,16 +1,30 @@
|
|||
class Dashboard::GroupsController < Dashboard::ApplicationController
|
||||
def index
|
||||
@group_members = current_user.group_members.includes(source: :route).joins(:group)
|
||||
@group_members = @group_members.merge(Group.search(params[:filter_groups])) if params[:filter_groups].present?
|
||||
@group_members = @group_members.merge(Group.sort(@sort = params[:sort]))
|
||||
@group_members = @group_members.page(params[:page])
|
||||
@groups =
|
||||
if params[:parent_id] && Group.supports_nested_groups?
|
||||
parent = Group.find_by(id: params[:parent_id])
|
||||
|
||||
if can?(current_user, :read_group, parent)
|
||||
GroupsFinder.new(current_user, parent: parent).execute
|
||||
else
|
||||
Group.none
|
||||
end
|
||||
else
|
||||
current_user.groups
|
||||
end
|
||||
|
||||
@groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
|
||||
@groups = @groups.includes(:route)
|
||||
@groups = @groups.sort(@sort = params[:sort])
|
||||
@groups = @groups.page(params[:page])
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json do
|
||||
render json: {
|
||||
html: view_to_html_string("dashboard/groups/_groups", locals: { group_members: @group_members })
|
||||
}
|
||||
render json: GroupSerializer
|
||||
.new(current_user: @current_user)
|
||||
.with_pagination(request, response)
|
||||
.represent(@groups)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@ module GroupsHelper
|
|||
group = Group.find_by_full_path(group)
|
||||
end
|
||||
|
||||
group.try(:avatar_url) || image_path('no_group_avatar.png')
|
||||
group.try(:avatar_url) || ActionController::Base.helpers.image_path('no_group_avatar.png')
|
||||
end
|
||||
|
||||
def group_title(group, name = nil, url = nil)
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
class GroupEntity < Grape::Entity
|
||||
include ActionView::Helpers::NumberHelper
|
||||
include RequestAwareEntity
|
||||
include MembersHelper
|
||||
include GroupsHelper
|
||||
|
||||
expose :id, :name, :path, :description, :visibility
|
||||
expose :web_url
|
||||
expose :full_name, :full_path
|
||||
expose :parent_id
|
||||
expose :created_at, :updated_at
|
||||
|
||||
expose :permissions do
|
||||
expose :human_group_access do |group, options|
|
||||
group.group_members.find_by(user_id: request.current_user)&.human_access
|
||||
end
|
||||
end
|
||||
|
||||
expose :edit_path do |group|
|
||||
edit_group_path(group)
|
||||
end
|
||||
|
||||
expose :leave_path do |group|
|
||||
leave_group_group_members_path(group)
|
||||
end
|
||||
|
||||
expose :can_edit do |group|
|
||||
can?(request.current_user, :admin_group, group)
|
||||
end
|
||||
|
||||
expose :has_subgroups do |group|
|
||||
GroupsFinder.new(request.current_user, parent: group).execute.any?
|
||||
end
|
||||
|
||||
expose :number_projects_with_delimiter do |group|
|
||||
number_with_delimiter(GroupProjectsFinder.new(group: group, current_user: request.current_user).execute.count)
|
||||
end
|
||||
|
||||
expose :number_users_with_delimiter do |group|
|
||||
number_with_delimiter(group.users.count)
|
||||
end
|
||||
|
||||
expose :avatar_url do |group|
|
||||
group_icon(group)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,19 @@
|
|||
class GroupSerializer < BaseSerializer
|
||||
entity GroupEntity
|
||||
|
||||
def with_pagination(request, response)
|
||||
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
|
||||
end
|
||||
|
||||
def paginated?
|
||||
@paginator.present?
|
||||
end
|
||||
|
||||
def represent(resource, opts = {})
|
||||
if paginated?
|
||||
super(@paginator.paginate(resource), opts)
|
||||
else
|
||||
super(resource, opts)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,6 +1,9 @@
|
|||
.js-groups-list-holder
|
||||
%ul.content-list
|
||||
- @group_members.each do |group_member|
|
||||
= render 'shared/groups/group', group: group_member.group, group_member: group_member
|
||||
|
||||
= paginate @group_members, theme: 'gitlab'
|
||||
#dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } }
|
||||
.groups-list-loading
|
||||
= icon('spinner spin', 'v-show' => 'isLoading')
|
||||
%template{ 'v-if' => '!isLoading && isEmpty' }
|
||||
%div{ 'v-cloak' => true }
|
||||
= render 'empty_state'
|
||||
%template{ 'v-else-if' => '!isLoading && !isEmpty' }
|
||||
%groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' }
|
||||
|
|
|
@ -2,7 +2,10 @@
|
|||
- header_title "Groups", dashboard_groups_path
|
||||
= render 'dashboard/groups_head'
|
||||
|
||||
- if @group_members.empty?
|
||||
= webpack_bundle_tag 'common_vue'
|
||||
= webpack_bundle_tag 'groups'
|
||||
|
||||
- if @groups.empty?
|
||||
= render 'empty_state'
|
||||
- else
|
||||
= render 'groups'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
.dropdown.inline
|
||||
.dropdown.inline.js-group-filter-dropdown-wrap
|
||||
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
|
||||
%span.light
|
||||
%span.dropdown-label
|
||||
- if @sort.present?
|
||||
= sort_options_hash[@sort]
|
||||
- else
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Update Dashboard Groups UI with better support for subgroups
|
||||
merge_request:
|
||||
author:
|
|
@ -40,6 +40,7 @@ var config = {
|
|||
filtered_search: './filtered_search/filtered_search_bundle.js',
|
||||
graphs: './graphs/graphs_bundle.js',
|
||||
group: './group.js',
|
||||
groups: './groups/index.js',
|
||||
groups_list: './groups_list.js',
|
||||
issue_show: './issue_show/index.js',
|
||||
integrations: './integrations',
|
||||
|
@ -155,6 +156,7 @@ var config = {
|
|||
'environments',
|
||||
'environments_folder',
|
||||
'filtered_search',
|
||||
'groups',
|
||||
'issue_show',
|
||||
'merge_conflicts',
|
||||
'notebook_viewer',
|
||||
|
|
|
@ -124,6 +124,13 @@ describe Groups::GroupMembersController do
|
|||
expect(response).to redirect_to(dashboard_groups_path)
|
||||
expect(group.users).not_to include user
|
||||
end
|
||||
|
||||
it 'supports json request' do
|
||||
delete :leave, group_id: group, format: :json
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(json_response['notice']).to eq "You left the \"#{group.name}\" group."
|
||||
end
|
||||
end
|
||||
|
||||
context 'and is an owner' do
|
||||
|
|
|
@ -6,6 +6,19 @@ describe 'Dashboard Groups page', js: true, feature: true do
|
|||
let!(:nested_group) { create(:group, :nested) }
|
||||
let!(:another_group) { create(:group) }
|
||||
|
||||
it 'shows groups user is member of' do
|
||||
group.add_owner(user)
|
||||
nested_group.add_owner(user)
|
||||
|
||||
login_as(user)
|
||||
visit dashboard_groups_path
|
||||
|
||||
expect(page).to have_content(group.full_name)
|
||||
expect(page).to have_content(nested_group.full_name)
|
||||
expect(page).not_to have_content(another_group.full_name)
|
||||
end
|
||||
|
||||
describe 'when filtering groups' do
|
||||
before do
|
||||
group.add_owner(user)
|
||||
nested_group.add_owner(user)
|
||||
|
@ -15,12 +28,6 @@ describe 'Dashboard Groups page', js: true, feature: true do
|
|||
visit dashboard_groups_path
|
||||
end
|
||||
|
||||
it 'shows groups user is member of' do
|
||||
expect(page).to have_content(group.full_name)
|
||||
expect(page).to have_content(nested_group.full_name)
|
||||
expect(page).not_to have_content(another_group.full_name)
|
||||
end
|
||||
|
||||
it 'filters groups' do
|
||||
fill_in 'filter_groups', with: group.name
|
||||
wait_for_requests
|
||||
|
@ -42,4 +49,81 @@ describe 'Dashboard Groups page', js: true, feature: true do
|
|||
expect(page).not_to have_content(another_group.full_name)
|
||||
expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
|
||||
end
|
||||
end
|
||||
|
||||
describe 'group with subgroups' do
|
||||
let!(:subgroup) { create(:group, :public, parent: group) }
|
||||
|
||||
before do
|
||||
group.add_owner(user)
|
||||
subgroup.add_owner(user)
|
||||
|
||||
login_as(user)
|
||||
|
||||
visit dashboard_groups_path
|
||||
end
|
||||
|
||||
it 'shows subgroups inside of its parent group' do
|
||||
expect(page).to have_selector('.groups-list-tree-container .group-list-tree', count: 2)
|
||||
expect(page).to have_selector(".groups-list-tree-container #group-#{group.id} #group-#{subgroup.id}", count: 1)
|
||||
end
|
||||
|
||||
it 'can toggle parent group' do
|
||||
# Expanded by default
|
||||
expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
|
||||
expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
|
||||
|
||||
# Collapse
|
||||
find("#group-#{group.id}").trigger('click')
|
||||
|
||||
expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down")
|
||||
expect(page).to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
|
||||
expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
|
||||
|
||||
# Expand
|
||||
find("#group-#{group.id}").trigger('click')
|
||||
|
||||
expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
|
||||
expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right")
|
||||
expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when using pagination' do
|
||||
let(:group2) { create(:group) }
|
||||
|
||||
before do
|
||||
group.add_owner(user)
|
||||
group2.add_owner(user)
|
||||
|
||||
allow(Kaminari.config).to receive(:default_per_page).and_return(1)
|
||||
|
||||
login_as(user)
|
||||
visit dashboard_groups_path
|
||||
end
|
||||
|
||||
it 'shows pagination' do
|
||||
expect(page).to have_selector('.gl-pagination')
|
||||
expect(page).to have_selector('.gl-pagination .page', count: 2)
|
||||
end
|
||||
|
||||
it 'loads results for next page' do
|
||||
# Check first page
|
||||
expect(page).to have_content(group2.full_name)
|
||||
expect(page).to have_selector("#group-#{group2.id}")
|
||||
expect(page).not_to have_content(group.full_name)
|
||||
expect(page).not_to have_selector("#group-#{group.id}")
|
||||
|
||||
# Go to next page
|
||||
find(".gl-pagination .page:not(.active) a").trigger('click')
|
||||
|
||||
wait_for_requests
|
||||
|
||||
# Check second page
|
||||
expect(page).to have_content(group.full_name)
|
||||
expect(page).to have_selector("#group-#{group.id}")
|
||||
expect(page).not_to have_content(group2.full_name)
|
||||
expect(page).not_to have_selector("#group-#{group2.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
import Vue from 'vue';
|
||||
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 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[1119].subGroups[1120].name);
|
||||
});
|
||||
|
||||
it('should remove prefix of parent group', () => {
|
||||
expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,110 @@
|
|||
const group1 = {
|
||||
id: '12',
|
||||
name: 'level1',
|
||||
path: 'level1',
|
||||
description: 'foo',
|
||||
visibility: 'public',
|
||||
avatar_url: null,
|
||||
web_url: 'http://localhost:3000/groups/level1',
|
||||
full_name: 'level1',
|
||||
full_path: 'level1',
|
||||
parent_id: null,
|
||||
created_at: '2017-05-15T19:01:23.670Z',
|
||||
updated_at: '2017-05-15T19:01:23.670Z',
|
||||
number_projects_with_delimiter: '1',
|
||||
number_users_with_delimiter: '1',
|
||||
has_subgroups: true,
|
||||
permissions: {
|
||||
human_group_access: 'Master',
|
||||
},
|
||||
};
|
||||
|
||||
// This group has no direct parent, should be placed as subgroup of group1
|
||||
const group14 = {
|
||||
id: 1128,
|
||||
name: 'level4',
|
||||
path: 'level4',
|
||||
description: 'foo',
|
||||
visibility: 'public',
|
||||
avatar_url: null,
|
||||
web_url: 'http://localhost:3000/groups/level1/level2/level3/level4',
|
||||
full_name: 'level1 / level2 / level3 / level4',
|
||||
full_path: 'level1/level2/level3/level4',
|
||||
parent_id: 1127,
|
||||
created_at: '2017-05-15T19:02:01.645Z',
|
||||
updated_at: '2017-05-15T19:02:01.645Z',
|
||||
number_projects_with_delimiter: '1',
|
||||
number_users_with_delimiter: '1',
|
||||
has_subgroups: true,
|
||||
permissions: {
|
||||
human_group_access: 'Master',
|
||||
},
|
||||
};
|
||||
|
||||
const group2 = {
|
||||
id: 1119,
|
||||
name: 'devops',
|
||||
path: 'devops',
|
||||
description: 'foo',
|
||||
visibility: 'public',
|
||||
avatar_url: null,
|
||||
web_url: 'http://localhost:3000/groups/devops',
|
||||
full_name: 'devops',
|
||||
full_path: 'devops',
|
||||
parent_id: null,
|
||||
created_at: '2017-05-11T19:35:09.635Z',
|
||||
updated_at: '2017-05-11T19:35:09.635Z',
|
||||
number_projects_with_delimiter: '1',
|
||||
number_users_with_delimiter: '1',
|
||||
has_subgroups: true,
|
||||
permissions: {
|
||||
human_group_access: 'Master',
|
||||
},
|
||||
};
|
||||
|
||||
const group21 = {
|
||||
id: 1120,
|
||||
name: 'chef',
|
||||
path: 'chef',
|
||||
description: 'foo',
|
||||
visibility: 'public',
|
||||
avatar_url: null,
|
||||
web_url: 'http://localhost:3000/groups/devops/chef',
|
||||
full_name: 'devops / chef',
|
||||
full_path: 'devops/chef',
|
||||
parent_id: 1119,
|
||||
created_at: '2017-05-11T19:51:04.060Z',
|
||||
updated_at: '2017-05-11T19:51:04.060Z',
|
||||
number_projects_with_delimiter: '1',
|
||||
number_users_with_delimiter: '1',
|
||||
has_subgroups: true,
|
||||
permissions: {
|
||||
human_group_access: 'Master',
|
||||
},
|
||||
};
|
||||
|
||||
const groupsData = {
|
||||
groups: [group1, group14, group2, group21],
|
||||
pagination: {
|
||||
Date: 'Mon, 22 May 2017 22:31:52 GMT',
|
||||
'X-Prev-Page': '1',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Total': '31',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'X-Runtime': '0.611144',
|
||||
'X-Xss-Protection': '1; mode=block',
|
||||
'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09',
|
||||
'X-Ua-Compatible': 'IE=edge',
|
||||
'X-Per-Page': '20',
|
||||
Link: '<http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="prev", <http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="first", <http://localhost:3000/dashboard/groups.json?page=2&per_page=20>; rel="last"',
|
||||
'X-Next-Page': '',
|
||||
Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'max-age=0, private, must-revalidate',
|
||||
'X-Total-Pages': '2',
|
||||
'X-Page': '2',
|
||||
},
|
||||
};
|
||||
|
||||
export { groupsData, group1 };
|
|
@ -150,6 +150,14 @@ import '~/lib/utils/common_utils';
|
|||
const value = gl.utils.getParameterByName('fakeParameter');
|
||||
expect(value).toBe(null);
|
||||
});
|
||||
|
||||
it('should return valid paramentes if URL is provided', () => {
|
||||
let value = gl.utils.getParameterByName('foo', 'http://cocteau.twins/?foo=bar');
|
||||
expect(value).toBe('bar');
|
||||
|
||||
value = gl.utils.getParameterByName('manan', 'http://cocteau.twins/?foo=bar&manan=canchu');
|
||||
expect(value).toBe('canchu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gl.utils.normalizedHeaders', () => {
|
||||
|
|
|
@ -51,7 +51,6 @@ if (process.env.BABEL_ENV === 'coverage') {
|
|||
'./environments/environments_bundle.js',
|
||||
'./filtered_search/filtered_search_bundle.js',
|
||||
'./graphs/graphs_bundle.js',
|
||||
'./issuable/issuable_bundle.js',
|
||||
'./issuable/time_tracking/time_tracking_bundle.js',
|
||||
'./main.js',
|
||||
'./merge_conflicts/merge_conflicts_bundle.js',
|
||||
|
|
Loading…
Reference in New Issue