From 1e668a3cfebfcf576a8c5da834bad094fd9039f6 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 21 Jul 2017 15:49:37 +0200 Subject: Rework subgroup endpoint --- .../javascripts/groups/components/group_folder.vue | 8 +- .../javascripts/groups/components/group_item.vue | 173 +++++++++++++-------- .../javascripts/groups/components/groups.vue | 1 + .../groups/components/project_folder.vue | 67 ++++++++ .../javascripts/groups/components/project_item.vue | 109 +++++++++++++ app/assets/javascripts/groups/constants.js | 19 +++ .../javascripts/groups/groups_filterable_list.js | 4 +- app/assets/javascripts/groups/index.js | 13 +- .../javascripts/groups/stores/groups_store.js | 11 +- app/assets/stylesheets/framework/lists.scss | 97 ++++++++---- app/controllers/concerns/groups_tree.rb | 22 +++ app/controllers/dashboard/groups_controller.rb | 20 +-- app/controllers/groups_controller.rb | 17 +- app/finders/groups_finder.rb | 21 ++- app/serializers/group_entity.rb | 16 +- app/serializers/project_entity.rb | 21 ++- app/views/dashboard/groups/_empty_state.html.haml | 7 - app/views/dashboard/groups/_groups.html.haml | 9 -- app/views/dashboard/groups/index.html.haml | 7 +- app/views/groups/subgroups.html.haml | 6 +- app/views/projects/forks/index.html.haml | 2 +- app/views/shared/groups/_empty_state.html.haml | 7 + app/views/shared/groups/_groups_tree.html.haml | 12 ++ .../unreleased/bvl-show-projects-in-group-tree.yml | 5 + .../dashboard/groups_controller_spec.rb | 27 ++++ spec/controllers/groups_controller_spec.rb | 4 + spec/features/dashboard/groups_list_spec.rb | 48 +++--- spec/features/groups/show_spec.rb | 76 ++++++++- spec/finders/groups_finder_spec.rb | 36 ++++- spec/javascripts/groups/group_item_spec.js | 39 ++++- spec/javascripts/groups/groups_spec.js | 18 +-- spec/javascripts/groups/mock_data.js | 27 +++- spec/javascripts/groups/project_folder_spec.js | 77 +++++++++ spec/javascripts/groups/project_item_spec.js | 71 +++++++++ spec/serializers/deploy_key_entity_spec.rb | 31 ++-- spec/support/group_tree_shared_examples.rb | 74 +++++++++ 36 files changed, 988 insertions(+), 214 deletions(-) create mode 100644 app/assets/javascripts/groups/components/project_folder.vue create mode 100644 app/assets/javascripts/groups/components/project_item.vue create mode 100644 app/assets/javascripts/groups/constants.js create mode 100644 app/controllers/concerns/groups_tree.rb delete mode 100644 app/views/dashboard/groups/_empty_state.html.haml delete mode 100644 app/views/dashboard/groups/_groups.html.haml create mode 100644 app/views/shared/groups/_empty_state.html.haml create mode 100644 app/views/shared/groups/_groups_tree.html.haml create mode 100644 changelogs/unreleased/bvl-show-projects-in-group-tree.yml create mode 100644 spec/controllers/dashboard/groups_controller_spec.rb create mode 100644 spec/javascripts/groups/project_folder_spec.js create mode 100644 spec/javascripts/groups/project_item_spec.js create mode 100644 spec/support/group_tree_shared_examples.rb diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index 7cc6c4b0359..cf0fc2a42fd 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -10,12 +10,18 @@ export default { required: false, default: () => ({}), }, + hasSiblingProjects: { + type: Boolean, + required: true, + }, }, }; diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 36a04d4202f..f2ac2ed83a1 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -30,6 +30,7 @@ export default {
+import { MAX_PROJECT_COUNT } from '../constants'; +import projectItem from './project_item.vue'; +import identicon from '../../vue_shared/components/identicon.vue'; + +export default { + components: { + projectItem, + identicon, + }, + props: { + groupPath: { + type: String, + required: true, + }, + projects: { + type: Array, + required: true, + }, + hasSiblingGroups: { + type: Boolean, + required: true, + }, + projectCount: { + type: Number, + required: true, + }, + }, + computed: { + hasMoreItems() { + return this.projectCount > MAX_PROJECT_COUNT; + }, + countOfMoreProjects() { + return this.projectCount - MAX_PROJECT_COUNT; + }, + moreProjectsLinkText() { + // eslint-disable-next-line no-underscore-dangle + return `${this.countOfMoreProjects} more ${this.n__('project', 'projects', this.countOfMoreProjects)}`; + }, + }, +}; + + + diff --git a/app/assets/javascripts/groups/components/project_item.vue b/app/assets/javascripts/groups/components/project_item.vue new file mode 100644 index 00000000000..337e1faeae1 --- /dev/null +++ b/app/assets/javascripts/groups/components/project_item.vue @@ -0,0 +1,109 @@ + + + diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js new file mode 100644 index 00000000000..c4c5eab011e --- /dev/null +++ b/app/assets/javascripts/groups/constants.js @@ -0,0 +1,19 @@ +export const MAX_PROJECT_COUNT = 10; + +export const GROUP_VISIBILITY_TYPE = { + public: 'Public - The group and any public projects can be viewed without any authentication.', + internal: 'Internal - The group and any internal projects can be viewed by any logged in user.', + private: 'Private - The group and its projects can only be viewed by members.', +}; + +export const PROJECT_VISIBILITY_TYPE = { + public: 'Public - The project can be accessed without any authentication.', + internal: 'Internal - The project can be accessed by any logged in user.', + private: 'Private - Project access must be granted explicitly to each user.', +}; + +export const VISIBILITY_TYPE_ICON = { + public: 'fa-globe', + internal: 'fa-shield', + private: 'fa-lock', +}; diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js index 439a931ddad..041f4fc54f3 100644 --- a/app/assets/javascripts/groups/groups_filterable_list.js +++ b/app/assets/javascripts/groups/groups_filterable_list.js @@ -2,12 +2,12 @@ import FilterableList from '~/filterable_list'; import eventHub from './event_hub'; export default class GroupFilterableList extends FilterableList { - constructor({ form, filter, holder, filterEndpoint, pagePath }) { + constructor({ form, filter, holder, filterDropdownSel, filterEndpoint, pagePath }) { super(form, filter, holder); this.form = form; this.filterEndpoint = filterEndpoint; this.pagePath = pagePath; - this.$dropdown = $('.js-group-filter-dropdown-wrap'); + this.$dropdown = $(filterDropdownSel); } getFilterEndpoint() { diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index 00e1bd94c9c..2577d014364 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -1,6 +1,7 @@ /* global Flash */ import Vue from 'vue'; +import Translate from '../vue_shared/translate'; import GroupFilterableList from './groups_filterable_list'; import GroupsComponent from './components/groups.vue'; import GroupFolder from './components/group_folder.vue'; @@ -9,6 +10,8 @@ import GroupsStore from './stores/groups_store'; import GroupsService from './services/groups_service'; import eventHub from './event_hub'; +Vue.use(Translate); + document.addEventListener('DOMContentLoaded', () => { const el = document.getElementById('dashboard-group-app'); @@ -40,6 +43,12 @@ document.addEventListener('DOMContentLoaded', () => { isEmpty() { return Object.keys(this.state.groups).length === 0; }, + isGroupsListEmpty() { + return this.isEmpty && !this.isLoading; + }, + isGroupsListLoaded() { + return Object.keys(this.state.groups).length > 0 && !this.isLoading; + }, }, methods: { fetchGroups(parentGroup) { @@ -159,14 +168,16 @@ document.addEventListener('DOMContentLoaded', () => { }, 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 holder = document.querySelector('.groups-list-holder'); const opts = { form, filter, holder, + filterDropdownSel: '.js-group-filter-dropdown-wrap', filterEndpoint: el.dataset.endpoint, pagePath: el.dataset.path, }; diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js index 6eab6083e8f..e790c420ee2 100644 --- a/app/assets/javascripts/groups/stores/groups_store.js +++ b/app/assets/javascripts/groups/stores/groups_store.js @@ -59,7 +59,7 @@ export default class GroupsStore { const findParentGroup = mappedGroups[`id${currentGroup.parentId}`]; if (findParentGroup) { mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup; - mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups + mappedGroups[`id${currentGroup.parentId}`].isOpen = false; // Keep group always collapsed } else if (parentGroup && parentGroup.id === currentGroup.parentId) { tree[`id${currentGroup.id}`] = currentGroup; } else { @@ -93,7 +93,7 @@ export default class GroupsStore { currentOrphan.id !== group.id ) { group.subGroups[currentOrphan.id] = currentOrphan; - group.isOpen = true; + group.isOpen = false; currentOrphan.isOrphan = true; found = true; @@ -120,7 +120,7 @@ export default class GroupsStore { } decorateGroups(rawGroups) { - this.groups = rawGroups.map(this.decorateGroup); + this.groups = rawGroups.map(this.decorateGroup.bind(this)); return this.groups; } @@ -132,7 +132,7 @@ export default class GroupsStore { fullPath: rawGroup.full_path, avatarUrl: rawGroup.avatar_url, name: rawGroup.name, - hasSubgroups: rawGroup.has_subgroups, + hasSubgroups: rawGroup.subgroup_count > 0, canEdit: rawGroup.can_edit, description: rawGroup.description, webUrl: rawGroup.web_url, @@ -149,6 +149,9 @@ export default class GroupsStore { humanGroupAccess: rawGroup.permissions.human_group_access, }, subGroups: {}, + projectCount: rawGroup.project_count, + subGroupCount: rawGroup.subgroup_count, + projects: rawGroup.projects, }; } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 0fb19344510..8e87704b96d 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -107,7 +107,7 @@ ul.content-list { color: $list-text-color; &.no-description { - .title { + > .group-row-contents .metadata .title { line-height: $list-text-height; } } @@ -116,6 +116,22 @@ ul.content-list { font-weight: $gl-font-weight-bold; } + > .group-row-contents .metadata { + .title { + line-height: inherit; + font-weight: 600; + + .access-type { + color: $gl-text-color-secondary; + } + } + + .description { + @include str-truncated; + margin-bottom: 0; + } + } + a { color: $gl-text-color; } @@ -124,13 +140,6 @@ ul.content-list { color: $blue-600; } - .description { - p { - @include str-truncated; - margin-bottom: 0; - } - } - .controls { @include new-style-dropdown; @@ -322,7 +331,7 @@ ul.indent-list { width: 0; position: absolute; top: 5px; - bottom: 0; + bottom: 30px; left: -16px; border-left: 2px solid $border-white-normal; } @@ -331,14 +340,7 @@ ul.indent-list { 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 { @@ -353,34 +355,67 @@ ul.indent-list { .group-row { padding: 0; border: none; - - &:last-of-type { - .group-row-contents:not(:hover) { - border-bottom: 1px solid transparent; - } - } } .group-row-contents { padding: 10px 10px 8px; - border-top: solid 1px transparent; - border-bottom: solid 1px $white-normal; + + .avatar-container > a { + width: 100%; + } + } + + .group-list-tree .group-row, + .content-list li.has-more-items-link { + &::before { + content: ""; + display: block; + width: 10px; + height: 0; + border-top: 2px solid $border-white-normal; + position: absolute; + left: -16px; + } + } + + .group-row-contents, + .content-list li.has-more-items-link { + border-top: solid 1px $white-normal; + border-bottom: solid 1px transparent; &:hover { border-color: $row-hover-border; background-color: $row-hover; cursor: pointer; } + } - .avatar-container > a { - width: 100%; + .content-list li.has-more-items-link { + padding: 19px; + font-weight: normal; + color: $gl-text-color-secondary; + + &::before { + bottom: 30px; } } -} -.js-groups-list-holder { - .groups-list-loading { - font-size: 34px; - text-align: center; + .project-list.has-sibling-groups { + &::before { + top: -30px; + bottom: 30px; + } } + + .group-list-tree.has-sibling-projects { + > .group-row:last-of-type::before { + height: 0; + } + } +} + +.projects-list-holder .projects-list-loading, +.groups-list-holder .groups-list-loading { + font-size: 34px; + text-align: center; } diff --git a/app/controllers/concerns/groups_tree.rb b/app/controllers/concerns/groups_tree.rb new file mode 100644 index 00000000000..0420f773b26 --- /dev/null +++ b/app/controllers/concerns/groups_tree.rb @@ -0,0 +1,22 @@ +module GroupsTree + def find_groups(parent, all_available: true) + groups = + if parent && Group.supports_nested_groups? + if can?(current_user, :read_group, parent) + GroupsFinder.new(current_user, + parent: parent, + all_available: all_available, + all_children_for_parent: params[:filter_groups].present?).execute + else + Group.none + end + else + GroupsFinder.new(current_user, all_available: all_available).execute + end + + groups = groups.search(params[:filter_groups]) if params[:filter_groups].present? + groups = groups.includes(:route) + groups = groups.sort(@sort = params[:sort]) + groups.page(params[:page]) + end +end diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb index 742157d113d..0df086e1fb2 100644 --- a/app/controllers/dashboard/groups_controller.rb +++ b/app/controllers/dashboard/groups_controller.rb @@ -1,22 +1,10 @@ class Dashboard::GroupsController < Dashboard::ApplicationController + include GroupsTree def index - @groups = - if params[:parent_id] && Group.supports_nested_groups? - parent = Group.find_by(id: params[:parent_id]) + parent = nil + parent = Group.find_by(id: params[:parent_id]) if 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]) + @groups = find_groups(parent, all_available: false) respond_to do |format| format.html diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 994e736d66e..414170dc504 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -2,6 +2,7 @@ class GroupsController < Groups::ApplicationController include IssuesAction include MergeRequestsAction include ParamsBackwardCompatibility + include GroupsTree respond_to :html @@ -73,8 +74,20 @@ class GroupsController < Groups::ApplicationController def subgroups return not_found unless Group.supports_nested_groups? - @nested_groups = GroupsFinder.new(current_user, parent: group).execute - @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present? + parent = group + parent = Group.find_by(id: params[:parent_id]) if params[:parent_id] + + @nested_groups = find_groups(parent) + + respond_to do |format| + format.html + format.json do + render json: GroupSerializer + .new(current_user: current_user) + .with_pagination(request, response) + .represent(@nested_groups) + end + end end def activity diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 88d71b0a87b..411d6e64a84 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -34,6 +34,11 @@ class GroupsFinder < UnionFinder def all_groups return [owned_groups] if params[:owned] return [Group.all] if current_user&.full_private_access? + groups = [] + + if current_user + groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups + end groups = [] groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups if current_user @@ -51,9 +56,21 @@ class GroupsFinder < UnionFinder end def by_parent(groups) - return groups unless params[:parent] + return groups unless parent + + if params[:all_children_for_parent] + groups.where(parent: hierarchy_for_parent) + else + groups.where(parent: parent) + end + end + + def hierarchy_for_parent + Gitlab::GroupHierarchy.new(parent.children).all_groups + end - groups.where(parent: params[:parent]) + def parent + params[:parent] end def owned_groups diff --git a/app/serializers/group_entity.rb b/app/serializers/group_entity.rb index 7c872a3e986..7df4d828e30 100644 --- a/app/serializers/group_entity.rb +++ b/app/serializers/group_entity.rb @@ -10,6 +10,16 @@ class GroupEntity < Grape::Entity expose :parent_id expose :created_at, :updated_at + def project_count + @project_count ||= GroupProjectsFinder.new(group: object, current_user: request.current_user).execute.count + end + + expose :projects, using: ProjectEntity do |group| + GroupProjectsFinder.new(group: group, current_user: request.current_user).execute.limit(10) + end + + expose :project_count + expose :group_path do |group| group_path(group) end @@ -32,12 +42,12 @@ class GroupEntity < Grape::Entity can?(request.current_user, :admin_group, group) end - expose :has_subgroups do |group| - GroupsFinder.new(request.current_user, parent: group).execute.any? + expose :subgroup_count do |group| + GroupsFinder.new(request.current_user, parent: object).execute.count end expose :number_projects_with_delimiter do |group| - number_with_delimiter(GroupProjectsFinder.new(group: group, current_user: request.current_user).execute.count) + number_with_delimiter(project_count) end expose :number_users_with_delimiter do |group| diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb index b3e5fd21e97..71d6835cb31 100644 --- a/app/serializers/project_entity.rb +++ b/app/serializers/project_entity.rb @@ -1,14 +1,25 @@ class ProjectEntity < Grape::Entity + include ActionView::Helpers::NumberHelper include RequestAwareEntity - expose :id - expose :name + expose :id, :name, :description, :visibility, :full_name, :full_path, :web_url, + :created_at, :updated_at, :star_count, :can_edit - expose :full_path do |project| + def can_edit + return false unless request.respond_to?(:current_user) + + can?(request.current_user, :edit_project, object) + end + + expose :project_path do |project| project_path(project) end - expose :full_name do |project| - project.full_name + expose :edit_path do |project| + edit_project_path(project) + end + + expose :avatar_url do |project| + project.try(:avatar_url) end end diff --git a/app/views/dashboard/groups/_empty_state.html.haml b/app/views/dashboard/groups/_empty_state.html.haml deleted file mode 100644 index f5222fe631e..00000000000 --- a/app/views/dashboard/groups/_empty_state.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -.groups-empty-state - = custom_icon("icon_empty_groups") - - .text-content - %h4 A group is a collection of several projects. - %p If you organize your projects under a group, it works like a folder. - %p You can manage your group member’s permissions and access to each project in the group. diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml deleted file mode 100644 index 168e6272d8e..00000000000 --- a/app/views/dashboard/groups/_groups.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -.js-groups-list-holder - #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 1cea8182733..ff058da8af5 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -3,10 +3,7 @@ - header_title "Groups", dashboard_groups_path = render 'dashboard/groups_head' -= webpack_bundle_tag 'common_vue' -= webpack_bundle_tag 'groups' - - if @groups.empty? - = render 'empty_state' + = render 'shared/groups/empty_state' - else - = render 'groups' + = render 'shared/groups/groups_tree', groups_endpoint: dashboard_groups_path(format: :json), groups_path: dashboard_groups_path diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml index 8f0724c0677..f05cb2a3e73 100644 --- a/app/views/groups/subgroups.html.haml +++ b/app/views/groups/subgroups.html.haml @@ -7,15 +7,15 @@ .top-area = render 'groups/show_nav' .nav-controls - = form_tag request.path, method: :get do |f| - = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false + = render 'shared/groups/search_form' + = render 'shared/groups/dropdown' - if can?(current_user, :create_subgroup, @group) = link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do New Subgroup - if @nested_groups.present? %ul.content-list - = render partial: 'shared/groups/group', collection: @nested_groups, locals: { full_name: false } + = render 'shared/groups/groups_tree', groups_endpoint: subgroups_group_path(@group, format: :json), groups_path: subgroups_group_path(@group) - else .nothing-here-block There are no subgroups to show. diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index 111cbcda266..093b47f0c38 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -8,7 +8,7 @@ = search_field_tag :filter_projects, nil, placeholder: 'Search forks', class: 'projects-list-filter project-filter-form-field form-control input-short', spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' } - .dropdown + .dropdown.js-project-filter-dropdown-wrap %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.light sort: - if @sort.present? diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml new file mode 100644 index 00000000000..0b1525c5ba3 --- /dev/null +++ b/app/views/shared/groups/_empty_state.html.haml @@ -0,0 +1,7 @@ +.groups-empty-state + = custom_icon('icon_empty_groups') + + .text-content + %h4 A group is a collection of several projects. + %p If you organize your projects under a group, it works like a folder. + %p You can manage your group member’s permissions and access to each project in the group. diff --git a/app/views/shared/groups/_groups_tree.html.haml b/app/views/shared/groups/_groups_tree.html.haml new file mode 100644 index 00000000000..a2c73ccad36 --- /dev/null +++ b/app/views/shared/groups/_groups_tree.html.haml @@ -0,0 +1,12 @@ += webpack_bundle_tag 'common_vue' += webpack_bundle_tag 'groups' + +.groups-list-holder + #dashboard-group-app{ data: { endpoint: groups_endpoint, path: groups_path } } + .groups-list-loading + = icon('spinner spin', 'v-show' => 'isLoading') + %template{ 'v-if' => 'isGroupsListEmpty' } + %div{ 'v-cloak' => true } + = render 'shared/groups/empty_state' + %template{ 'v-else-if' => 'isGroupsListLoaded' } + %groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' } diff --git a/changelogs/unreleased/bvl-show-projects-in-group-tree.yml b/changelogs/unreleased/bvl-show-projects-in-group-tree.yml new file mode 100644 index 00000000000..b522daa2454 --- /dev/null +++ b/changelogs/unreleased/bvl-show-projects-in-group-tree.yml @@ -0,0 +1,5 @@ +--- +title: Show projects in collapsible lists of groups +merge_request: 13017 +author: +type: added diff --git a/spec/controllers/dashboard/groups_controller_spec.rb b/spec/controllers/dashboard/groups_controller_spec.rb new file mode 100644 index 00000000000..23bdaf6a789 --- /dev/null +++ b/spec/controllers/dashboard/groups_controller_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Dashboard::GroupsController do + let(:user) { create(:user) } + + before do + sign_in(user) + end + + describe 'GET #index' do + it_behaves_like 'project tree json', :index do + # No extra params required for this request + let(:group) { nil } + let(:request_params) { { id: group.to_param } } + + it 'does not include public groups that the user is not a member of' do + public_group = create(:group, :public) + + get :index, id: group.to_param, format: :json + + group_ids = json_response.map { |group_json| group_json['id'] } + + expect(group_ids).not_to include(public_group.id) + end + end + end +end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index c2ada8c8df7..597e5f51bb3 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -51,6 +51,10 @@ describe GroupsController do expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup) end end + + it_behaves_like 'project tree json', :subgroups do + let(:request_params) { { id: group.to_param } } + end end context 'as a guest' do diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb index 533df7a325c..ac88b8ee160 100644 --- a/spec/features/dashboard/groups_list_spec.rb +++ b/spec/features/dashboard/groups_list_spec.rb @@ -13,12 +13,17 @@ feature 'Dashboard Groups page', :js do sign_in(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) + expect(page).to have_content(group.name) + expect(page).not_to have_content(another_group.name) + + # Nested groups are hidden under their parent + find("#group-#{nested_group.parent_id} .fa-caret-right").trigger('click') + wait_for_requests + + expect(page).to have_content(nested_group.name) end - describe 'when filtering groups' do + describe 'when filtering groups', :nested_groups do before do group.add_owner(user) nested_group.add_owner(user) @@ -33,7 +38,6 @@ feature 'Dashboard Groups page', :js do 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 @@ -45,13 +49,19 @@ feature 'Dashboard Groups page', :js do 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 + expect(page.all('.groups-list-holder .content-list li').length).to eq 2 + end + + it 'shows the groups that have a matching subgroup' do + fill_in 'filter_groups', with: nested_group.parent.name + wait_for_requests + + expect(page).to have_content(nested_group.parent.name) end end - describe 'group with subgroups' do + describe 'group with subgroups', :nested_groups do let!(:subgroup) { create(:group, :public, parent: group) } before do @@ -63,15 +73,20 @@ feature 'Dashboard Groups page', :js do 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) + it 'Only shows the parent by default' do + expect(page).to have_selector('.groups-list-tree-container .group-list-tree', 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) + # Collapsed by default + expect(page).to have_selector("#group-#{group.id} .fa-caret-right") + + # Expand + find("#group-#{group.id}").trigger('click') + + expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right", 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}") # Collapse find("#group-#{group.id}").trigger('click') @@ -79,13 +94,6 @@ feature 'Dashboard Groups page', :js do 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 diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb index 303013e59d5..8502d902e2e 100644 --- a/spec/features/groups/show_spec.rb +++ b/spec/features/groups/show_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Group show page' do +feature 'Group show page', js: true do let(:group) { create(:group) } let(:path) { group_path(group) } @@ -11,17 +11,79 @@ feature 'Group show page' do before do sign_in(user) - visit path end - it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" do + before do + visit path + end + end + + context 'subgroups', :nested_groups do + let!(:subgroup) { create(:group, parent: group) } + let!(:subsub_group) { create(:group, parent: subgroup) } + let!(:project) { create(:project, namespace: subsub_group) } + + subject(:visit_subgroups) { visit subgroups_group_path(group) } + + it 'only shows the direct children by default' do + visit_subgroups + + expect(page).to have_selector('.groups-list-tree-container .group-list-tree', count: 1) + expect(page).to have_content(subgroup.name) + expect(page).not_to have_content(subsub_group.name) + end + + context 'nested projects' do + def expand_groups + visit_subgroups + + find("#group-#{subgroup.id}").trigger('click') + wait_for_requests + + find("#group-#{subsub_group.id}").trigger('click') + wait_for_requests + end + + it 'allows expanding to see a subgroups projects' do + expand_groups + + expect(page).to have_content(project.name) + end + + it 'shows a link to the group page if there are more than 10 projects' do + create_list(:project, 10, namespace: subsub_group) + + expand_groups + + expect(page).to have_link('1 more project') + end + end + + describe 'filtering subgroups' do + let!(:other_subgroup) { create(:group, parent: group) } + + it 'shows the parent of the group matching a search' do + visit_subgroups + + expect(page).to have_content(subgroup.name) + expect(page).to have_content(other_subgroup.name) + + fill_in 'filter_groups', with: subsub_group.name + wait_for_requests + + expect(page).to have_content(subgroup.name) + expect(page).not_to have_content(other_subgroup.name) + end + end + end end context 'when signed out' do - before do - visit path + it_behaves_like "an autodiscoverable RSS feed without an RSS token" do + before do + visit group_path(group) + end end - - it_behaves_like "an autodiscoverable RSS feed without an RSS token" end end diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb index abc470788e1..dbbdbbfe4ee 100644 --- a/spec/finders/groups_finder_spec.rb +++ b/spec/finders/groups_finder_spec.rb @@ -57,11 +57,43 @@ describe GroupsFinder do is_expected.to contain_exactly(parent_group, public_subgroup, internal_subgroup) end + context 'subgroups multiple levels deep' do + let(:subsub_group) { create(:group, parent: private_subgroup) } + + it 'can return subgroups multiple levels deep' do + is_expected.to include(subsub_group) + end + + context 'when a parent is given and children should be included' do + let(:other_parent) { create(:group, :public) } + + subject { described_class.new(user, parent: parent_group, all_children_for_parent: true).execute } + + it 'includes children multiple levels deep' do + is_expected.to include(subsub_group) + end + + it 'excludes groups with a different ancestor' do + is_expected.not_to include(other_parent) + end + end + end + context 'being member' do - it 'returns parent, public subgroups, internal subgroups, and private subgroups user is member of' do + let(:other_public_group) { create(:group, :public) } + + before do private_subgroup.add_guest(user) + end + + it 'returns parent, public subgroups, internal subgroups, and private subgroups user is member of' do + is_expected.to contain_exactly(other_public_group, parent_group, public_subgroup, internal_subgroup, private_subgroup) + end + + it 'allows excluding public groups' do + result = described_class.new(user, all_available: false).execute - is_expected.to contain_exactly(parent_group, public_subgroup, internal_subgroup, private_subgroup) + expect(result).to contain_exactly(parent_group, private_subgroup) end end diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js index 25e10552d95..78eeface95c 100644 --- a/spec/javascripts/groups/group_item_spec.js +++ b/spec/javascripts/groups/group_item_spec.js @@ -33,6 +33,7 @@ describe('Groups Component', () => { 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-subgroups').textContent).toContain(group.subGroupCount); 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(); @@ -43,6 +44,13 @@ describe('Groups Component', () => { expect(component.$el.querySelector('.edit-group')).toBeDefined(); expect(component.$el.querySelector('.leave-group')).toBeDefined(); }); + + it('should render tooltips on group item correctly', () => { + expect(component.$el.querySelector('.number-subgroups').dataset.originalTitle).toContain('Subgroups'); + expect(component.$el.querySelector('.number-projects').dataset.originalTitle).toContain('Projects'); + expect(component.$el.querySelector('.number-users').dataset.originalTitle).toContain('Members'); + expect(component.$el.querySelector('.group-visibility').dataset.originalTitle).toContain('Public'); + }); }); describe('group without description', () => { @@ -68,7 +76,7 @@ describe('Groups Component', () => { }); it('should render group item correctly', () => { - expect(component.$el.querySelector('.description').textContent).toBe(''); + expect(component.$el.querySelector('.description')).toBe(null); expect(component.$el.classList.contains('.no-description')).toBe(false); }); }); @@ -99,4 +107,33 @@ describe('Groups Component', () => { expect(component.$el.querySelector('.access-type')).toBeNull(); }); }); + + describe('group with projects', () => { + beforeEach((done) => { + GroupItemComponent = Vue.extend(groupItemComponent); + store = new GroupsStore(); + group1.permissions.human_group_access = null; + group = store.decorateGroup(group1); + group.isOpen = true; + + component = new GroupItemComponent({ + propsData: { + group, + }, + }).$mount(); + + Vue.nextTick(() => { + done(); + }); + }); + + afterEach(() => { + component.$destroy(); + }); + + it('should render projects list correctly', () => { + expect(component.$el.querySelector('.group-list-tree.project-list')).toBeDefined(); + expect(component.$el.querySelectorAll('.group-list-tree.project-list .project-row').length).toBe(1); + }); + }); }); diff --git a/spec/javascripts/groups/groups_spec.js b/spec/javascripts/groups/groups_spec.js index b14153dbbfa..6897c77e019 100644 --- a/spec/javascripts/groups/groups_spec.js +++ b/spec/javascripts/groups/groups_spec.js @@ -10,15 +10,13 @@ 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.setGroups(groupsData.groups); store.storePagination(groupsData.pagination); GroupsComponent = Vue.extend(groupsComponent); @@ -53,15 +51,13 @@ describe('Groups Component', () => { expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119'); }); - it('should render group and its subgroup', () => { + it('should render group collapsed by default', () => { const lists = component.$el.querySelectorAll('.group-list-tree'); - expect(lists.length).toBe(3); // one parent and two subgroups + expect(lists.length).toBe(1); // one parent collapsed by default - expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBe(true); + expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBeFalsy(); expect(lists[0].querySelector('#group-1119').classList.contains('has-subgroups')).toBe(true); - - expect(lists[2].querySelector('#group-1120').textContent).toContain(groups.id1119.subGroups.id1120.name); }); it('should render group identicon when group avatar is not present', () => { @@ -72,15 +68,11 @@ describe('Groups Component', () => { }); it('should render group avatar when group avatar is present', () => { - const avatar = component.$el.querySelector('#group-1120 .avatar-container .avatar'); + const avatar = component.$el.querySelector('#group-1119 .avatar-container .avatar'); expect(avatar.nodeName).toBe('IMG'); expect(avatar.classList.contains('identicon')).toBeFalsy(); }); - it('should remove prefix of parent group', () => { - expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4'); - }); - it('should remove the group after leaving the group', (done) => { spyOn(window, 'confirm').and.returnValue(true); diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js index 5bb84b591f4..fea0216cb3d 100644 --- a/spec/javascripts/groups/mock_data.js +++ b/spec/javascripts/groups/mock_data.js @@ -14,10 +14,29 @@ const group1 = { updated_at: '2017-05-15T19:01:23.670Z', number_projects_with_delimiter: '1', number_users_with_delimiter: '1', - has_subgroups: true, + subgroup_count: 1, permissions: { human_group_access: 'Master', }, + project_count: 1, + projects: [ + { + id: 17, + name: 'v4.4', + description: 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common / v4.4', + full_path: 'platform/hardware/bsp/kernel/common/v4.4', + web_url: 'http://localhost:3000/platform/hardware/bsp/kernel/common/v4.4', + created_at: '2017-04-09T18:43:51.578Z', + updated_at: '2017-04-09T18:46:45.081Z', + star_count: 0, + can_edit: false, + project_path: '/platform/hardware/bsp/kernel/common/v4.4', + edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit', + avatar_url: null, + }, + ], }; // This group has no direct parent, should be placed as subgroup of group1 @@ -49,7 +68,7 @@ const group2 = { path: 'devops', description: 'foo', visibility: 'public', - avatar_url: null, + avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png', web_url: 'http://localhost:3000/groups/devops', group_path: '/devops', full_name: 'devops', @@ -59,7 +78,7 @@ const group2 = { updated_at: '2017-05-11T19:35:09.635Z', number_projects_with_delimiter: '1', number_users_with_delimiter: '1', - has_subgroups: true, + subgroup_count: 1, permissions: { human_group_access: 'Master', }, @@ -81,7 +100,7 @@ const group21 = { updated_at: '2017-05-11T19:51:04.060Z', number_projects_with_delimiter: '1', number_users_with_delimiter: '1', - has_subgroups: true, + subgroup_count: 1, permissions: { human_group_access: 'Master', }, diff --git a/spec/javascripts/groups/project_folder_spec.js b/spec/javascripts/groups/project_folder_spec.js new file mode 100644 index 00000000000..71b5a303acd --- /dev/null +++ b/spec/javascripts/groups/project_folder_spec.js @@ -0,0 +1,77 @@ +import Vue from 'vue'; +import projectFolderComponent from '~/groups/components/project_folder.vue'; +import GroupsStore from '~/groups/stores/groups_store'; +import { group1 } from './mock_data'; + +const createComponent = (customGroup) => { + const Component = Vue.extend(projectFolderComponent); + const store = new GroupsStore(); + const group = store.decorateGroup(customGroup || group1); + const projects = group.projects; + + return new Component({ + propsData: { + projects, + projectCount: customGroup ? customGroup.project_count : group1.project_count, + hasMoreItems: false, + groupPath: group.fullPath, + hasSiblingGroups: false, + }, + }).$mount(); +}; + +describe('ProjectFolderComponent', () => { + let vm; + + describe('computed', () => { + beforeEach(() => { + const customGroup = Object.assign({}, group1); + const childProject = Object.assign({}, customGroup.projects[0]); + let projectId = childProject.id; + for (let i = 0; i < 10; i++) { // eslint-disable-line + childProject.id = ++projectId; // eslint-disable-line + customGroup.projects.push({ ...childProject }); + } + + customGroup.project_count = 11; + vm = createComponent(customGroup); + }); + + describe('hasMoreItems', () => { + it('should return boolean value representing if group has more than 10 projects', () => { + expect(vm.hasMoreItems).toBeTruthy(); + }); + }); + + describe('countOfMoreProjects', () => { + it('should return total count of projects minus 10', () => { + expect(vm.countOfMoreProjects).toBe(1); + }); + }); + + describe('moreProjectsLinkText', () => { + it('should return correctly pluralized text for more projects link', () => { + vm.projectCount = 11; + expect(vm.moreProjectsLinkText).toBe('1 more project'); + + vm.projectCount = 12; + expect(vm.moreProjectsLinkText).toBe('2 more projects'); + }); + }); + }); + + describe('template', () => { + beforeEach(() => { + vm = createComponent(); + }); + + it('should render list of projects', () => { + expect(vm.$el.querySelector('#project-17')).toBeDefined(); + }); + + it('should render has more link if projects count exceeds threshold', () => { + vm.hasMoreItems = true; + expect(vm.$el.querySelector('.has-more-items-link')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/groups/project_item_spec.js b/spec/javascripts/groups/project_item_spec.js new file mode 100644 index 00000000000..a55031355c6 --- /dev/null +++ b/spec/javascripts/groups/project_item_spec.js @@ -0,0 +1,71 @@ +import Vue from 'vue'; +import projectItemComponent from '~/groups/components/project_item.vue'; +import GroupsStore from '~/groups/stores/groups_store'; +import { group1 } from './mock_data'; + +const createComponent = () => { + const Component = Vue.extend(projectItemComponent); + const store = new GroupsStore(); + const group = store.decorateGroup(group1); + const project = group.projects[0]; + + return new Component({ + propsData: { + project, + }, + }).$mount(); +}; + +describe('ProjectItemComponent', () => { + const project = group1.projects[0]; + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + describe('computed', () => { + describe('projectDomId', () => { + it('should return ID string using Project ID', () => { + expect(vm.projectDomId).toBe(`project-${project.id}`); + }); + }); + + describe('rowClass', () => { + it('should return appropriate classes present in row element classes for project', () => { + expect(vm.rowClass['no-description']).toBeFalsy(); // Since group1.projects[0].description is defined + }); + }); + + describe('visibilityIcon', () => { + it('should return correct classes for different project visibility types', () => { + vm.project.visibility = 'public'; + expect(vm.visibilityIcon).toBe('fa-globe'); + + vm.project.visibility = 'internal'; + expect(vm.visibilityIcon).toBeTruthy('fa-shield'); + + vm.project.visibility = 'private'; + expect(vm.visibilityIcon).toBeTruthy('fa-lock'); + }); + }); + + describe('visibilityTooltip', () => { + it('should return capitalized visibility type in tooltip string', () => { + vm.project.visibility = 'public'; + expect(vm.visibilityTooltip).toContain('Public'); + }); + }); + }); + + describe('template', () => { + it('should render project row element correctly', () => { + expect(vm.$el.querySelector('#project-17')).toBeDefined(); + expect(vm.$el.querySelector('#project-17 .folder-toggle-wrap .folder-icon fa.fa-bookmark')).toBeDefined(); + expect(vm.$el.querySelector('#project-17 .metadata .title a').getAttribute('href')).toBe(project.project_path); + expect(vm.$el.querySelector('#project-17 .metadata .description').textContent.trim()).toBe(project.description); + expect(vm.$el.querySelector('#project-17 .stats .project-stars').textContent.trim()).toBe(`${project.star_count}`); + expect(vm.$el.querySelector('#project-17 .stats .project-visibility').dataset.originalTitle).toContain('Public'); + }); + }); +}); diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb index d3aefa2c9eb..c5d8e3f8a2f 100644 --- a/spec/serializers/deploy_key_entity_spec.rb +++ b/spec/serializers/deploy_key_entity_spec.rb @@ -12,10 +12,10 @@ describe DeployKeyEntity do let!(:deploy_key_private) { create(:deploy_keys_project, project: project_private, deploy_key: deploy_key) } let!(:deploy_key_pending_delete) { create(:deploy_keys_project, project: project_pending_delete, deploy_key: deploy_key) } - let(:entity) { described_class.new(deploy_key, user: user) } + let(:entity) { described_class.new(deploy_key, user: user, request: double) } describe 'returns deploy keys with projects a user can read' do - let(:expected_result) do + let(:expected_deploy_key_info) do { id: deploy_key.id, user_id: deploy_key.user_id, @@ -26,19 +26,26 @@ describe DeployKeyEntity do almost_orphaned: false, created_at: deploy_key.created_at, updated_at: deploy_key.updated_at, - can_edit: false, - projects: [ - { - id: project.id, - name: project.name, - full_path: project_path(project), - full_name: project.full_name - } - ] + can_edit: false } end - it { expect(entity.as_json).to eq(expected_result) } + let(:expected_project_info) do + { + id: project.id, + name: project.name, + project_path: project_path(project), + full_name: project.full_name + } + end + + it 'includes deploy key info' do + expect(entity.as_json).to match(a_hash_including(expected_deploy_key_info)) + end + + it 'returnd deploy key info' do + expect(entity.as_json[:projects].first).to match(a_hash_including(expected_project_info)) + end end describe 'returns can_edit true if user is a master of project' do diff --git a/spec/support/group_tree_shared_examples.rb b/spec/support/group_tree_shared_examples.rb new file mode 100644 index 00000000000..25788b6ddad --- /dev/null +++ b/spec/support/group_tree_shared_examples.rb @@ -0,0 +1,74 @@ +shared_examples 'project tree json' do |method| + let(:parent) { create(:group, parent: group) } + let!(:project) { create(:project, group: parent) } + let(:subgroup) { create(:group, parent: parent) } + + before do + parent.add_owner(user) + end + + def group_json + json_response.detect { |json| json['id'] == parent.id } + end + + it 'includes projects that are direct children' do + create(:project, group: subgroup) + + get method, { format: :json }.merge(request_params) + + project_ids = group_json['projects'].map { |project_json| project_json['id'] } + + expect(project_ids).to contain_exactly(project.id) + end + + it 'includes projects when a parent-id is given' do + sub_project = create(:project, group: subgroup) + + get method, { parent_id: parent.id, format: :json }.merge(request_params) + + subgroup_json = json_response.detect { |json| json['id'] == subgroup.id } + project_json = subgroup_json['projects'].first + + expect(project_json['id']).to eq(sub_project.id) + end + + context 'with multiple projects' do + before do + create_list(:project, 10, group: parent) + end + + it 'only includes the 10 first projects per group' do + get method, { format: :json }.merge(request_params) + + expect(group_json['projects'].size).to eq(10) + end + + it 'includes the total project count' do + get method, { format: :json }.merge(request_params) + + expect(group_json['project_count']).to eq(11) + end + end + + context 'searching' do + it 'includes matching groups' do + matching_group = create(:group, parent: parent, name: 'queryme') + + get method, { filter_groups: 'quer', format: :json }.merge(request_params) + + group_ids = json_response.map { |group_json| group_json['id'] } + + expect(group_ids).to include(matching_group.id) + end + + it 'includes a matching subgroup' do + matching_group = create(:group, parent: subgroup, name: 'queryme') + + get method, { filter_groups: 'quer', format: :json }.merge(request_params) + + group_ids = json_response.map { |group_json| group_json['id'] } + + expect(group_ids).to include(matching_group.id) + end + end +end -- cgit v1.2.1