Merge branch '25426-group-dashboard-ui' into 'master'

Resolve "Group dashboard UI"

Closes #25426

See merge request !11098
This commit is contained in:
Douwe Maan 2017-06-08 18:12:04 +00:00
commit 4b1c49171d
30 changed files with 1441 additions and 73 deletions

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,3 @@
import Vue from 'vue';
export default new Vue();

View File

@ -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);
}
}

View File

@ -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);
},
});
});

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -284,9 +284,7 @@ export default {
<table-pagination
v-if="shouldRenderPagination"
:pagenum="pagenum"
:change="change"
:count="state.count.all"
:pageInfo="state.pageInfo"
/>
</div>

View File

@ -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;
}
}

View File

@ -52,9 +52,14 @@ module MembershipActions
"You left the \"#{membershipable.human_name}\" #{source_type}."
end
redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize]
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
redirect_to redirect_path, notice: notice
format.json { render json: { notice: notice } }
end
end
protected

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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' }

View File

@ -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'

View File

@ -1,10 +1,10 @@
.dropdown.inline
.dropdown.inline.js-group-filter-dropdown-wrap
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.light
- if @sort.present?
= sort_options_hash[@sort]
- else
= sort_title_recently_created
%span.dropdown-label
- if @sort.present?
= sort_options_hash[@sort]
- else
= sort_title_recently_created
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right
%li

View File

@ -0,0 +1,4 @@
---
title: Update Dashboard Groups UI with better support for subgroups
merge_request:
author:

View File

@ -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',

View File

@ -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

View File

@ -6,40 +6,124 @@ describe 'Dashboard Groups page', js: true, feature: true do
let!(:nested_group) { create(:group, :nested) }
let!(:another_group) { create(:group) }
before do
it 'shows groups user is member of' do
group.add_owner(user)
nested_group.add_owner(user)
login_as(user)
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
expect(page).to have_content(group.full_name)
expect(page).not_to have_content(nested_group.full_name)
expect(page).not_to have_content(another_group.full_name)
end
it 'resets search when user cleans the input' do
fill_in 'filter_groups', with: group.name
wait_for_requests
fill_in 'filter_groups', with: ""
wait_for_requests
expect(page).to have_content(group.full_name)
expect(page).to have_content(nested_group.full_name)
expect(page).not_to have_content(another_group.full_name)
expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
end
describe 'when filtering groups' do
before do
group.add_owner(user)
nested_group.add_owner(user)
login_as(user)
visit dashboard_groups_path
end
it 'filters groups' do
fill_in 'filter_groups', with: group.name
wait_for_requests
expect(page).to have_content(group.full_name)
expect(page).not_to have_content(nested_group.full_name)
expect(page).not_to have_content(another_group.full_name)
end
it 'resets search when user cleans the input' do
fill_in 'filter_groups', with: group.name
wait_for_requests
fill_in 'filter_groups', with: ""
wait_for_requests
expect(page).to have_content(group.full_name)
expect(page).to have_content(nested_group.full_name)
expect(page).not_to have_content(another_group.full_name)
expect(page.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

View File

@ -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();
});
});
});

View File

@ -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');
});
});
});

View File

@ -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 };

View File

@ -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', () => {

View File

@ -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',