diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 99de128b04b..51cc8c085b2 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -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(); diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index aaaeb9bddb1..139206cc185 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -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); + } } diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue new file mode 100644 index 00000000000..7cc6c4b0359 --- /dev/null +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -0,0 +1,27 @@ + + + diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue new file mode 100644 index 00000000000..32815b9f73e --- /dev/null +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -0,0 +1,220 @@ + + + diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue new file mode 100644 index 00000000000..36a04d4202f --- /dev/null +++ b/app/assets/javascripts/groups/components/groups.vue @@ -0,0 +1,39 @@ + + + diff --git a/app/assets/javascripts/groups/event_hub.js b/app/assets/javascripts/groups/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/groups/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js new file mode 100644 index 00000000000..439a931ddad --- /dev/null +++ b/app/assets/javascripts/groups/groups_filterable_list.js @@ -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); + } +} diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js new file mode 100644 index 00000000000..ff601db2aa6 --- /dev/null +++ b/app/assets/javascripts/groups/index.js @@ -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); + }, + }); +}); diff --git a/app/assets/javascripts/groups/services/groups_service.js b/app/assets/javascripts/groups/services/groups_service.js new file mode 100644 index 00000000000..97e02fcb76d --- /dev/null +++ b/app/assets/javascripts/groups/services/groups_service.js @@ -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); + } +} diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js new file mode 100644 index 00000000000..67ee7d140ce --- /dev/null +++ b/app/assets/javascripts/groups/stores/groups_store.js @@ -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; + } +} diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index a537267643e..2aca86189fd 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -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); diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index 9f247af1dec..23b967b4b32 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -284,9 +284,7 @@ export default { diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 49163653548..38727e15c6f 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -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; + } +} diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index cefb9b4e766..8d07780f6c2 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -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 diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb index d03265e9f20..742157d113d 100644 --- a/app/controllers/dashboard/groups_controller.rb +++ b/app/controllers/dashboard/groups_controller.rb @@ -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 diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index a6014088e92..c003b01e226 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -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) diff --git a/app/serializers/group_entity.rb b/app/serializers/group_entity.rb new file mode 100644 index 00000000000..4f506f7e745 --- /dev/null +++ b/app/serializers/group_entity.rb @@ -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 diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb new file mode 100644 index 00000000000..26e8566828b --- /dev/null +++ b/app/serializers/group_serializer.rb @@ -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 diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml index 6c3bf1a2b3b..168e6272d8e 100644 --- a/app/views/dashboard/groups/_groups.html.haml +++ b/app/views/dashboard/groups/_groups.html.haml @@ -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' } diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 73ab2c95ff9..f9b45a539a1 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -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' diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 37589b634fa..760370a6984 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -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 diff --git a/changelogs/unreleased/25426-group-dashboard-ui.yml b/changelogs/unreleased/25426-group-dashboard-ui.yml new file mode 100644 index 00000000000..cc2bf62d07b --- /dev/null +++ b/changelogs/unreleased/25426-group-dashboard-ui.yml @@ -0,0 +1,4 @@ +--- +title: Update Dashboard Groups UI with better support for subgroups +merge_request: +author: diff --git a/config/webpack.config.js b/config/webpack.config.js index cbcf5ce996d..7501acb7633 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -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', diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index 60db0192dfd..ed4ad7b600e 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -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 diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb index b0e2953dda2..7eb254f8451 100644 --- a/spec/features/dashboard/groups_list_spec.rb +++ b/spec/features/dashboard/groups_list_spec.rb @@ -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 diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js new file mode 100644 index 00000000000..25e10552d95 --- /dev/null +++ b/spec/javascripts/groups/group_item_spec.js @@ -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(); + }); + }); +}); diff --git a/spec/javascripts/groups/groups_spec.js b/spec/javascripts/groups/groups_spec.js new file mode 100644 index 00000000000..2a77f7259da --- /dev/null +++ b/spec/javascripts/groups/groups_spec.js @@ -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'); + }); + }); +}); diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js new file mode 100644 index 00000000000..1c0ec7a97d0 --- /dev/null +++ b/spec/javascripts/groups/mock_data.js @@ -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: '; rel="prev", ; rel="first", ; 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 }; diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index e3938a77680..52cf217c25f 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -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', () => { diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index 13827a26571..2c34402576b 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -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',