summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorDouwe Maan <douwe@gitlab.com>2017-10-17 10:03:03 +0000
committerDouwe Maan <douwe@gitlab.com>2017-10-17 10:03:03 +0000
commit79e889122b9f1cb41eb75ee33e94e625a8c679e2 (patch)
treeeb8bba9933b3241b3ca80574bc47fc40d4f3950f /spec
parent3fa410c831dac1dd1a74a14260ed99a5920218f8 (diff)
parent893402d477436d36b48c2fd0244576a0d16e9425 (diff)
downloadgitlab-ce-79e889122b9f1cb41eb75ee33e94e625a8c679e2.tar.gz
Merge branch 'bvl-group-trees' into 'master'
Show collapsible tree on the project show page Closes #30343 See merge request gitlab-org/gitlab-ce!14055
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/concerns/group_tree_spec.rb89
-rw-r--r--spec/controllers/dashboard/groups_controller_spec.rb23
-rw-r--r--spec/controllers/explore/groups_controller_spec.rb23
-rw-r--r--spec/controllers/groups/children_controller_spec.rb286
-rw-r--r--spec/controllers/groups_controller_spec.rb108
-rw-r--r--spec/features/dashboard/groups_list_spec.rb81
-rw-r--r--spec/features/explore/groups_list_spec.rb13
-rw-r--r--spec/features/groups/show_spec.rb31
-rw-r--r--spec/features/groups_spec.rb23
-rw-r--r--spec/finders/group_descendants_finder_spec.rb166
-rw-r--r--spec/javascripts/groups/components/app_spec.js443
-rw-r--r--spec/javascripts/groups/components/group_folder_spec.js66
-rw-r--r--spec/javascripts/groups/components/group_item_spec.js177
-rw-r--r--spec/javascripts/groups/components/groups_spec.js70
-rw-r--r--spec/javascripts/groups/components/item_actions_spec.js110
-rw-r--r--spec/javascripts/groups/components/item_caret_spec.js40
-rw-r--r--spec/javascripts/groups/components/item_stats_spec.js159
-rw-r--r--spec/javascripts/groups/components/item_type_icon_spec.js54
-rw-r--r--spec/javascripts/groups/group_item_spec.js102
-rw-r--r--spec/javascripts/groups/groups_spec.js99
-rw-r--r--spec/javascripts/groups/mock_data.js470
-rw-r--r--spec/javascripts/groups/service/groups_service_spec.js42
-rw-r--r--spec/javascripts/groups/store/groups_store_spec.js110
-rw-r--r--spec/lib/gitlab/group_hierarchy_spec.rb28
-rw-r--r--spec/lib/gitlab/multi_collection_paginator_spec.rb46
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb14
-rw-r--r--spec/lib/gitlab/sql/union_spec.rb7
-rw-r--r--spec/lib/gitlab/utils/merge_hash_spec.rb33
-rw-r--r--spec/models/concerns/group_descendant_spec.rb166
-rw-r--r--spec/models/concerns/loaded_in_group_list_spec.rb49
-rw-r--r--spec/models/namespace_spec.rb14
-rw-r--r--spec/models/project_spec.rb21
-rw-r--r--spec/serializers/group_child_entity_spec.rb101
-rw-r--r--spec/serializers/group_child_serializer_spec.rb110
34 files changed, 2941 insertions, 433 deletions
diff --git a/spec/controllers/concerns/group_tree_spec.rb b/spec/controllers/concerns/group_tree_spec.rb
new file mode 100644
index 00000000000..ba84fbf8564
--- /dev/null
+++ b/spec/controllers/concerns/group_tree_spec.rb
@@ -0,0 +1,89 @@
+require 'spec_helper'
+
+describe GroupTree do
+ let(:group) { create(:group, :public) }
+ let(:user) { create(:user) }
+
+ controller(ApplicationController) do
+ # `described_class` is not available in this context
+ include GroupTree # rubocop:disable RSpec/DescribedClass
+
+ def index
+ render_group_tree GroupsFinder.new(current_user).execute
+ end
+ end
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ describe 'GET #index' do
+ it 'filters groups' do
+ other_group = create(:group, name: 'filter')
+ other_group.add_owner(user)
+
+ get :index, filter: 'filt', format: :json
+
+ expect(assigns(:groups)).to contain_exactly(other_group)
+ end
+
+ context 'for subgroups', :nested_groups do
+ it 'only renders root groups when no parent was given' do
+ create(:group, :public, parent: group)
+
+ get :index, format: :json
+
+ expect(assigns(:groups)).to contain_exactly(group)
+ end
+
+ it 'contains only the subgroup when a parent was given' do
+ subgroup = create(:group, :public, parent: group)
+
+ get :index, parent_id: group.id, format: :json
+
+ expect(assigns(:groups)).to contain_exactly(subgroup)
+ end
+
+ it 'allows filtering for subgroups and includes the parents for rendering' do
+ subgroup = create(:group, :public, parent: group, name: 'filter')
+
+ get :index, filter: 'filt', format: :json
+
+ expect(assigns(:groups)).to contain_exactly(group, subgroup)
+ end
+
+ it 'does not include groups the user does not have access to' do
+ parent = create(:group, :private)
+ subgroup = create(:group, :private, parent: parent, name: 'filter')
+ subgroup.add_developer(user)
+ _other_subgroup = create(:group, :private, parent: parent, name: 'filte')
+
+ get :index, filter: 'filt', format: :json
+
+ expect(assigns(:groups)).to contain_exactly(parent, subgroup)
+ end
+ end
+
+ context 'json content' do
+ it 'shows groups as json' do
+ get :index, format: :json
+
+ expect(json_response.first['id']).to eq(group.id)
+ end
+
+ context 'nested groups', :nested_groups do
+ it 'expands the tree when filtering' do
+ subgroup = create(:group, :public, parent: group, name: 'filter')
+
+ get :index, filter: 'filt', format: :json
+
+ children_response = json_response.first['children']
+
+ expect(json_response.first['id']).to eq(group.id)
+ expect(children_response.first['id']).to eq(subgroup.id)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/dashboard/groups_controller_spec.rb b/spec/controllers/dashboard/groups_controller_spec.rb
new file mode 100644
index 00000000000..fb9d3efbac0
--- /dev/null
+++ b/spec/controllers/dashboard/groups_controller_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe Dashboard::GroupsController do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'renders group trees' do
+ expect(described_class).to include(GroupTree)
+ end
+
+ it 'only includes projects the user is a member of' do
+ member_of_group = create(:group)
+ member_of_group.add_developer(user)
+ create(:group, :public)
+
+ get :index
+
+ expect(assigns(:groups)).to contain_exactly(member_of_group)
+ end
+end
diff --git a/spec/controllers/explore/groups_controller_spec.rb b/spec/controllers/explore/groups_controller_spec.rb
new file mode 100644
index 00000000000..9e0ad9ea86f
--- /dev/null
+++ b/spec/controllers/explore/groups_controller_spec.rb
@@ -0,0 +1,23 @@
+require 'spec_helper'
+
+describe Explore::GroupsController do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'renders group trees' do
+ expect(described_class).to include(GroupTree)
+ end
+
+ it 'includes public projects' do
+ member_of_group = create(:group)
+ member_of_group.add_developer(user)
+ public_group = create(:group, :public)
+
+ get :index
+
+ expect(assigns(:groups)).to contain_exactly(member_of_group, public_group)
+ end
+end
diff --git a/spec/controllers/groups/children_controller_spec.rb b/spec/controllers/groups/children_controller_spec.rb
new file mode 100644
index 00000000000..4262d474e59
--- /dev/null
+++ b/spec/controllers/groups/children_controller_spec.rb
@@ -0,0 +1,286 @@
+require 'spec_helper'
+
+describe Groups::ChildrenController do
+ let(:group) { create(:group, :public) }
+ let(:user) { create(:user) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+
+ describe 'GET #index' do
+ context 'for projects' do
+ let!(:public_project) { create(:project, :public, namespace: group) }
+ let!(:private_project) { create(:project, :private, namespace: group) }
+
+ context 'as a user' do
+ before do
+ sign_in(user)
+ end
+
+ it 'shows all children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_project, private_project)
+ end
+
+ context 'being member of private subgroup' do
+ it 'shows public and private children the user is member of' do
+ group_member.destroy!
+ private_project.add_guest(user)
+
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_project, private_project)
+ end
+ end
+ end
+
+ context 'as a guest' do
+ it 'shows the public children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_project)
+ end
+ end
+ end
+
+ context 'for subgroups', :nested_groups do
+ let!(:public_subgroup) { create(:group, :public, parent: group) }
+ let!(:private_subgroup) { create(:group, :private, parent: group) }
+ let!(:public_project) { create(:project, :public, namespace: group) }
+ let!(:private_project) { create(:project, :private, namespace: group) }
+
+ context 'as a user' do
+ before do
+ sign_in(user)
+ end
+
+ it 'shows all children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
+ end
+
+ context 'being member of private subgroup' do
+ it 'shows public and private children the user is member of' do
+ group_member.destroy!
+ private_subgroup.add_guest(user)
+ private_project.add_guest(user)
+
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project)
+ end
+ end
+ end
+
+ context 'as a guest' do
+ it 'shows the public children' do
+ get :index, group_id: group.to_param, format: :json
+
+ expect(assigns(:children)).to contain_exactly(public_subgroup, public_project)
+ end
+ end
+
+ context 'filtering children' do
+ it 'expands the tree for matching projects' do
+ project = create(:project, :public, namespace: public_subgroup, name: 'filterme')
+
+ get :index, group_id: group.to_param, filter: 'filter', format: :json
+
+ group_json = json_response.first
+ project_json = group_json['children'].first
+
+ expect(group_json['id']).to eq(public_subgroup.id)
+ expect(project_json['id']).to eq(project.id)
+ end
+
+ it 'expands the tree for matching subgroups' do
+ matched_group = create(:group, :public, parent: public_subgroup, name: 'filterme')
+
+ get :index, group_id: group.to_param, filter: 'filter', format: :json
+
+ group_json = json_response.first
+ matched_group_json = group_json['children'].first
+
+ expect(group_json['id']).to eq(public_subgroup.id)
+ expect(matched_group_json['id']).to eq(matched_group.id)
+ end
+
+ it 'merges the trees correctly' do
+ shared_subgroup = create(:group, :public, parent: group, path: 'hardware')
+ matched_project_1 = create(:project, :public, namespace: shared_subgroup, name: 'mobile-soc')
+
+ l2_subgroup = create(:group, :public, parent: shared_subgroup, path: 'broadcom')
+ l3_subgroup = create(:group, :public, parent: l2_subgroup, path: 'wifi-group')
+ matched_project_2 = create(:project, :public, namespace: l3_subgroup, name: 'mobile')
+
+ get :index, group_id: group.to_param, filter: 'mobile', format: :json
+
+ shared_group_json = json_response.first
+ expect(shared_group_json['id']).to eq(shared_subgroup.id)
+
+ matched_project_1_json = shared_group_json['children'].detect { |child| child['type'] == 'project' }
+ expect(matched_project_1_json['id']).to eq(matched_project_1.id)
+
+ l2_subgroup_json = shared_group_json['children'].detect { |child| child['type'] == 'group' }
+ expect(l2_subgroup_json['id']).to eq(l2_subgroup.id)
+
+ l3_subgroup_json = l2_subgroup_json['children'].first
+ expect(l3_subgroup_json['id']).to eq(l3_subgroup.id)
+
+ matched_project_2_json = l3_subgroup_json['children'].first
+ expect(matched_project_2_json['id']).to eq(matched_project_2.id)
+ end
+
+ it 'expands the tree upto a specified parent' do
+ subgroup = create(:group, :public, parent: group)
+ l2_subgroup = create(:group, :public, parent: subgroup)
+ create(:project, :public, namespace: l2_subgroup, name: 'test')
+
+ get :index, group_id: subgroup.to_param, filter: 'test', format: :json
+
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns an array with one element when only one result is matched' do
+ create(:project, :public, namespace: group, name: 'match')
+
+ get :index, group_id: group.to_param, filter: 'match', format: :json
+
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq(1)
+ end
+
+ it 'returns an empty array when there are no search results' do
+ subgroup = create(:group, :public, parent: group)
+ l2_subgroup = create(:group, :public, parent: subgroup)
+ create(:project, :public, namespace: l2_subgroup, name: 'no-match')
+
+ get :index, group_id: subgroup.to_param, filter: 'test', format: :json
+
+ expect(json_response).to eq([])
+ end
+
+ it 'includes pagination headers' do
+ 2.times { |i| create(:group, :public, parent: public_subgroup, name: "filterme#{i}") }
+
+ get :index, group_id: group.to_param, filter: 'filter', per_page: 1, format: :json
+
+ expect(response).to include_pagination_headers
+ end
+ end
+
+ context 'queries per rendered element', :request_store do
+ # We need to make sure the following counts are preloaded
+ # otherwise they will cause an extra query
+ # 1. Count of visible projects in the element
+ # 2. Count of visible subgroups in the element
+ # 3. Count of members of a group
+ let(:expected_queries_per_group) { 0 }
+ let(:expected_queries_per_project) { 0 }
+
+ def get_list
+ get :index, group_id: group.to_param, format: :json
+ end
+
+ it 'queries the expected amount for a group row' do
+ control = ActiveRecord::QueryRecorder.new { get_list }
+
+ _new_group = create(:group, :public, parent: group)
+
+ expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_group)
+ end
+
+ it 'queries the expected amount for a project row' do
+ control = ActiveRecord::QueryRecorder.new { get_list }
+ _new_project = create(:project, :public, namespace: group)
+
+ expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_project)
+ end
+
+ context 'when rendering hierarchies' do
+ # When loading hierarchies we load the all the ancestors for matched projects
+ # in 1 separate query
+ let(:extra_queries_for_hierarchies) { 1 }
+
+ def get_filtered_list
+ get :index, group_id: group.to_param, filter: 'filter', format: :json
+ end
+
+ it 'queries the expected amount when nested rows are increased for a group' do
+ matched_group = create(:group, :public, parent: group, name: 'filterme')
+
+ control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+ matched_group.update!(parent: public_subgroup)
+
+ expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+ end
+
+ it 'queries the expected amount when a new group match is added' do
+ create(:group, :public, parent: public_subgroup, name: 'filterme')
+
+ control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+ create(:group, :public, parent: public_subgroup, name: 'filterme2')
+ create(:group, :public, parent: public_subgroup, name: 'filterme3')
+
+ expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+ end
+
+ it 'queries the expected amount when nested rows are increased for a project' do
+ matched_project = create(:project, :public, namespace: group, name: 'filterme')
+
+ control = ActiveRecord::QueryRecorder.new { get_filtered_list }
+
+ matched_project.update!(namespace: public_subgroup)
+
+ expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
+ end
+ end
+ end
+ end
+
+ context 'pagination' do
+ let(:per_page) { 3 }
+
+ before do
+ allow(Kaminari.config).to receive(:default_per_page).and_return(per_page)
+ end
+
+ context 'with only projects' do
+ let!(:other_project) { create(:project, :public, namespace: group) }
+ let!(:first_page_projects) { create_list(:project, per_page, :public, namespace: group ) }
+
+ it 'has projects on the first page' do
+ get :index, group_id: group.to_param, sort: 'id_desc', format: :json
+
+ expect(assigns(:children)).to contain_exactly(*first_page_projects)
+ end
+
+ it 'has projects on the second page' do
+ get :index, group_id: group.to_param, sort: 'id_desc', page: 2, format: :json
+
+ expect(assigns(:children)).to contain_exactly(other_project)
+ end
+ end
+
+ context 'with subgroups and projects', :nested_groups do
+ let!(:first_page_subgroups) { create_list(:group, per_page, :public, parent: group) }
+ let!(:other_subgroup) { create(:group, :public, parent: group) }
+ let!(:next_page_projects) { create_list(:project, per_page, :public, namespace: group) }
+
+ it 'contains all subgroups' do
+ get :index, group_id: group.to_param, sort: 'id_asc', format: :json
+
+ expect(assigns(:children)).to contain_exactly(*first_page_subgroups)
+ end
+
+ it 'contains the project and group on the second page' do
+ get :index, group_id: group.to_param, sort: 'id_asc', page: 2, format: :json
+
+ expect(assigns(:children)).to contain_exactly(other_subgroup, *next_page_projects.take(per_page - 1))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index b0564e27a68..e7631d4d709 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -1,4 +1,4 @@
-require 'rails_helper'
+require 'spec_helper'
describe GroupsController do
let(:user) { create(:user) }
@@ -150,42 +150,6 @@ describe GroupsController do
end
end
- describe 'GET #subgroups', :nested_groups do
- let!(:public_subgroup) { create(:group, :public, parent: group) }
- let!(:private_subgroup) { create(:group, :private, parent: group) }
-
- context 'as a user' do
- before do
- sign_in(user)
- end
-
- it 'shows all subgroups' do
- get :subgroups, id: group.to_param
-
- expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
- end
-
- context 'being member of private subgroup' do
- it 'shows public and private subgroups the user is member of' do
- group_member.destroy!
- private_subgroup.add_guest(user)
-
- get :subgroups, id: group.to_param
-
- expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup)
- end
- end
- end
-
- context 'as a guest' do
- it 'shows the public subgroups' do
- get :subgroups, id: group.to_param
-
- expect(assigns(:nested_groups)).to contain_exactly(public_subgroup)
- end
- end
- end
-
describe 'GET #issues' do
let(:issue_1) { create(:issue, project: project) }
let(:issue_2) { create(:issue, project: project) }
@@ -425,62 +389,62 @@ describe GroupsController do
end
end
end
- end
- context 'for a POST request' do
- context 'when requesting the canonical path with different casing' do
- it 'does not 404' do
- post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+ context 'for a POST request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ post :update, id: group.to_param.upcase, group: { path: 'new_path' }
- expect(response).not_to have_http_status(404)
- end
+ expect(response).not_to have_http_status(404)
+ end
- it 'does not redirect to the correct casing' do
- post :update, id: group.to_param.upcase, group: { path: 'new_path' }
+ it 'does not redirect to the correct casing' do
+ post :update, id: group.to_param.upcase, group: { path: 'new_path' }
- expect(response).not_to have_http_status(301)
+ expect(response).not_to have_http_status(301)
+ end
end
- end
- context 'when requesting a redirected path' do
- let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
- it 'returns not found' do
- post :update, id: redirect_route.path, group: { path: 'new_path' }
+ it 'returns not found' do
+ post :update, id: redirect_route.path, group: { path: 'new_path' }
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(404)
+ end
end
end
- end
- context 'for a DELETE request' do
- context 'when requesting the canonical path with different casing' do
- it 'does not 404' do
- delete :destroy, id: group.to_param.upcase
+ context 'for a DELETE request' do
+ context 'when requesting the canonical path with different casing' do
+ it 'does not 404' do
+ delete :destroy, id: group.to_param.upcase
- expect(response).not_to have_http_status(404)
- end
+ expect(response).not_to have_http_status(404)
+ end
- it 'does not redirect to the correct casing' do
- delete :destroy, id: group.to_param.upcase
+ it 'does not redirect to the correct casing' do
+ delete :destroy, id: group.to_param.upcase
- expect(response).not_to have_http_status(301)
+ expect(response).not_to have_http_status(301)
+ end
end
- end
- context 'when requesting a redirected path' do
- let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
+ context 'when requesting a redirected path' do
+ let(:redirect_route) { group.redirect_routes.create(path: 'old-path') }
- it 'returns not found' do
- delete :destroy, id: redirect_route.path
+ it 'returns not found' do
+ delete :destroy, id: redirect_route.path
- expect(response).to have_http_status(404)
+ expect(response).to have_http_status(404)
+ end
end
end
end
- end
- def group_moved_message(redirect_route, group)
- "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
+ def group_moved_message(redirect_route, group)
+ "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
+ end
end
end
diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb
index a6329b5c78d..c6873d1923c 100644
--- a/spec/features/dashboard/groups_list_spec.rb
+++ b/spec/features/dashboard/groups_list_spec.rb
@@ -6,6 +6,13 @@ feature 'Dashboard Groups page', :js do
let(:nested_group) { create(:group, :nested) }
let(:another_group) { create(:group) }
+ def click_group_caret(group)
+ within("#group-#{group.id}") do
+ first('.folder-caret').click
+ end
+ wait_for_requests
+ end
+
it 'shows groups user is member of' do
group.add_owner(user)
nested_group.add_owner(user)
@@ -13,13 +20,27 @@ feature 'Dashboard Groups page', :js do
sign_in(user)
visit dashboard_groups_path
+ wait_for_requests
+
+ expect(page).to have_content(group.name)
+
+ expect(page).not_to have_content(another_group.name)
+ end
+
+ it 'shows subgroups the user is member of', :nested_groups do
+ group.add_owner(user)
+ nested_group.add_owner(user)
+
+ sign_in(user)
+ visit dashboard_groups_path
+ 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).to have_content(nested_group.parent.name)
+ click_group_caret(nested_group.parent)
+ 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)
@@ -30,25 +51,26 @@ feature 'Dashboard Groups page', :js do
visit dashboard_groups_path
end
- it 'filters groups' do
- fill_in 'filter_groups', with: group.name
+ it 'expands when filtering groups' do
+ fill_in 'filter', with: nested_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)
+ expect(page).not_to have_content(group.name)
+ expect(page).to have_content(nested_group.parent.name)
+ expect(page).to have_content(nested_group.name)
+ expect(page).not_to have_content(another_group.name)
end
it 'resets search when user cleans the input' do
- fill_in 'filter_groups', with: group.name
+ fill_in 'filter', with: group.name
wait_for_requests
- fill_in 'filter_groups', with: ''
+ fill_in 'filter', 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).to have_content(group.name)
+ expect(page).to have_content(nested_group.parent.name)
+ expect(page).not_to have_content(another_group.name)
expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2
end
end
@@ -66,28 +88,29 @@ feature 'Dashboard Groups page', :js do
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)
+ expect(page).to have_selector("#group-#{group.id}")
+ click_group_caret(group)
+ expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
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")
+ # Collapsed by default
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
- # Collapse
- find("#group-#{group.id}").trigger('click')
+ # expand
+ click_group_caret(group)
- 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}")
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-down")
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right", count: 1)
+ expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}")
- # Expand
- find("#group-#{group.id}").trigger('click')
+ # collapse
+ click_group_caret(group)
- 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}")
+ expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1)
+ expect(page).to have_selector("#group-#{group.id} .fa-caret-right")
+ expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}")
end
end
diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb
index b5325301968..801a33979ff 100644
--- a/spec/features/explore/groups_list_spec.rb
+++ b/spec/features/explore/groups_list_spec.rb
@@ -13,6 +13,7 @@ describe 'Explore Groups page', :js do
sign_in(user)
visit explore_groups_path
+ wait_for_requests
end
it 'shows groups user is member of' do
@@ -22,7 +23,7 @@ describe 'Explore Groups page', :js do
end
it 'filters groups' do
- fill_in 'filter_groups', with: group.name
+ fill_in 'filter', with: group.name
wait_for_requests
expect(page).to have_content(group.full_name)
@@ -31,10 +32,10 @@ describe 'Explore Groups page', :js do
end
it 'resets search when user cleans the input' do
- fill_in 'filter_groups', with: group.name
+ fill_in 'filter', with: group.name
wait_for_requests
- fill_in 'filter_groups', with: ""
+ fill_in 'filter', with: ""
wait_for_requests
expect(page).to have_content(group.full_name)
@@ -45,21 +46,21 @@ describe 'Explore Groups page', :js do
it 'shows non-archived projects count' do
# Initially project is not archived
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
# Archive project
empty_project.archive!
visit explore_groups_path
# Check project count
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("0")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("0")
# Unarchive project
empty_project.unarchive!
visit explore_groups_path
# Check project count
- expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1")
+ expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1")
end
describe 'landing component' do
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index 303013e59d5..7fc2b383749 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -24,4 +24,35 @@ feature 'Group show page' do
it_behaves_like "an autodiscoverable RSS feed without an RSS token"
end
+
+ context 'subgroup support' do
+ let(:user) { create(:user) }
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ context 'when subgroups are supported', :js, :nested_groups do
+ before do
+ allow(Group).to receive(:supports_nested_groups?) { true }
+ visit path
+ end
+
+ it 'allows creating subgroups' do
+ expect(page).to have_css("li[data-text='New subgroup']", visible: false)
+ end
+ end
+
+ context 'when subgroups are not supported' do
+ before do
+ allow(Group).to receive(:supports_nested_groups?) { false }
+ visit path
+ end
+
+ it 'allows creating subgroups' do
+ expect(page).not_to have_selector("li[data-text='New subgroup']", visible: false)
+ end
+ end
+ end
end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 862823d862e..cc8906fa969 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -90,8 +90,7 @@ feature 'Group' do
context 'as admin' do
before do
- visit subgroups_group_path(group)
- click_link 'New Subgroup'
+ visit new_group_path(group, parent_id: group.id)
end
it 'creates a nested group' do
@@ -111,8 +110,8 @@ feature 'Group' do
sign_out(:user)
sign_in(user)
- visit subgroups_group_path(group)
- click_link 'New Subgroup'
+ visit new_group_path(group, parent_id: group.id)
+
fill_in 'Group path', with: 'bar'
click_button 'Create group'
@@ -120,16 +119,6 @@ feature 'Group' do
expect(page).to have_content("Group 'bar' was successfully created.")
end
end
-
- context 'when nested group feature is disabled' do
- it 'renders 404' do
- allow(Group).to receive(:supports_nested_groups?).and_return(false)
-
- visit subgroups_group_path(group)
-
- expect(page.status_code).to eq(404)
- end
- end
end
it 'checks permissions to avoid exposing groups by parent_id' do
@@ -210,13 +199,15 @@ feature 'Group' do
describe 'group page with nested groups', :nested_groups, :js do
let!(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
+ let!(:project) { create(:project, namespace: group) }
let!(:path) { group_path(group) }
- it 'has nested groups tab with nested groups inside' do
+ it 'it renders projects and groups on the page' do
visit path
- click_link 'Subgroups'
+ wait_for_requests
expect(page).to have_content(nested_group.name)
+ expect(page).to have_content(project.name)
end
end
diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb
new file mode 100644
index 00000000000..074914420a1
--- /dev/null
+++ b/spec/finders/group_descendants_finder_spec.rb
@@ -0,0 +1,166 @@
+require 'spec_helper'
+
+describe GroupDescendantsFinder do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:params) { {} }
+ subject(:finder) do
+ described_class.new(current_user: user, parent_group: group, params: params)
+ end
+
+ before do
+ group.add_owner(user)
+ end
+
+ describe '#has_children?' do
+ it 'is true when there are projects' do
+ create(:project, namespace: group)
+
+ expect(finder.has_children?).to be_truthy
+ end
+
+ context 'when there are subgroups', :nested_groups do
+ it 'is true when there are projects' do
+ create(:group, parent: group)
+
+ expect(finder.has_children?).to be_truthy
+ end
+ end
+ end
+
+ describe '#execute' do
+ it 'includes projects' do
+ project = create(:project, namespace: group)
+
+ expect(finder.execute).to contain_exactly(project)
+ end
+
+ context 'when archived is `true`' do
+ let(:params) { { archived: 'true' } }
+
+ it 'includes archived projects' do
+ archived_project = create(:project, namespace: group, archived: true)
+ project = create(:project, namespace: group)
+
+ expect(finder.execute).to contain_exactly(archived_project, project)
+ end
+ end
+
+ context 'when archived is `only`' do
+ let(:params) { { archived: 'only' } }
+
+ it 'includes only archived projects' do
+ archived_project = create(:project, namespace: group, archived: true)
+ _project = create(:project, namespace: group)
+
+ expect(finder.execute).to contain_exactly(archived_project)
+ end
+ end
+
+ it 'does not include archived projects' do
+ _archived_project = create(:project, :archived, namespace: group)
+
+ expect(finder.execute).to be_empty
+ end
+
+ context 'with a filter' do
+ let(:params) { { filter: 'test' } }
+
+ it 'includes only projects matching the filter' do
+ _other_project = create(:project, namespace: group)
+ matching_project = create(:project, namespace: group, name: 'testproject')
+
+ expect(finder.execute).to contain_exactly(matching_project)
+ end
+ end
+ end
+
+ context 'with nested groups', :nested_groups do
+ let!(:project) { create(:project, namespace: group) }
+ let!(:subgroup) { create(:group, :private, parent: group) }
+
+ describe '#execute' do
+ it 'contains projects and subgroups' do
+ expect(finder.execute).to contain_exactly(subgroup, project)
+ end
+
+ it 'does not include subgroups the user does not have access to' do
+ subgroup.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+
+ public_subgroup = create(:group, :public, parent: group, path: 'public-group')
+ other_subgroup = create(:group, :private, parent: group, path: 'visible-private-group')
+ other_user = create(:user)
+ other_subgroup.add_developer(other_user)
+
+ finder = described_class.new(current_user: other_user, parent_group: group)
+
+ expect(finder.execute).to contain_exactly(public_subgroup, other_subgroup)
+ end
+
+ it 'only includes public groups when no user is given' do
+ public_subgroup = create(:group, :public, parent: group)
+ _private_subgroup = create(:group, :private, parent: group)
+
+ finder = described_class.new(current_user: nil, parent_group: group)
+
+ expect(finder.execute).to contain_exactly(public_subgroup)
+ end
+
+ context 'when archived is `true`' do
+ let(:params) { { archived: 'true' } }
+
+ it 'includes archived projects in the count of subgroups' do
+ create(:project, namespace: subgroup, archived: true)
+
+ expect(finder.execute.first.preloaded_project_count).to eq(1)
+ end
+ end
+
+ context 'with a filter' do
+ let(:params) { { filter: 'test' } }
+
+ it 'contains only matching projects and subgroups' do
+ matching_project = create(:project, namespace: group, name: 'Testproject')
+ matching_subgroup = create(:group, name: 'testgroup', parent: group)
+
+ expect(finder.execute).to contain_exactly(matching_subgroup, matching_project)
+ end
+
+ it 'does not include subgroups the user does not have access to' do
+ _invisible_subgroup = create(:group, :private, parent: group, name: 'test1')
+ other_subgroup = create(:group, :private, parent: group, name: 'test2')
+ public_subgroup = create(:group, :public, parent: group, name: 'test3')
+ other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4')
+ other_user = create(:user)
+ other_subgroup.add_developer(other_user)
+
+ finder = described_class.new(current_user: other_user,
+ parent_group: group,
+ params: params)
+
+ expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup)
+ end
+
+ context 'with matching children' do
+ it 'includes a group that has a subgroup matching the query and its parent' do
+ matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup)
+
+ expect(finder.execute).to contain_exactly(subgroup, matching_subgroup)
+ end
+
+ it 'includes the parent of a matching project' do
+ matching_project = create(:project, namespace: subgroup, name: 'Testproject')
+
+ expect(finder.execute).to contain_exactly(subgroup, matching_project)
+ end
+
+ it 'does not include the parent itself' do
+ group.update!(name: 'test')
+
+ expect(finder.execute).not_to include(group)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js
new file mode 100644
index 00000000000..cd19a0fae1e
--- /dev/null
+++ b/spec/javascripts/groups/components/app_spec.js
@@ -0,0 +1,443 @@
+import Vue from 'vue';
+
+import appComponent from '~/groups/components/app.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+
+import eventHub from '~/groups/event_hub';
+import GroupsStore from '~/groups/store/groups_store';
+import GroupsService from '~/groups/service/groups_service';
+
+import {
+ mockEndpoint, mockGroups, mockSearchedGroups,
+ mockRawPageInfo, mockParentGroupItem, mockRawChildren,
+ mockChildren, mockPageInfo,
+} from '../mock_data';
+
+const createComponent = (hideProjects = false) => {
+ const Component = Vue.extend(appComponent);
+ const store = new GroupsStore(false);
+ const service = new GroupsService(mockEndpoint);
+
+ return new Component({
+ propsData: {
+ store,
+ service,
+ hideProjects,
+ },
+ });
+};
+
+const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
+ if (failed) {
+ reject(data);
+ } else {
+ resolve({
+ json() {
+ return data;
+ },
+ });
+ }
+});
+
+describe('AppComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ describe('computed', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('groups', () => {
+ it('should return list of groups from store', () => {
+ spyOn(vm.store, 'getGroups');
+
+ const groups = vm.groups;
+ expect(vm.store.getGroups).toHaveBeenCalled();
+ expect(groups).not.toBeDefined();
+ });
+ });
+
+ describe('pageInfo', () => {
+ it('should return pagination info from store', () => {
+ spyOn(vm.store, 'getPaginationInfo');
+
+ const pageInfo = vm.pageInfo;
+ expect(vm.store.getPaginationInfo).toHaveBeenCalled();
+ expect(pageInfo).not.toBeDefined();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('fetchGroups', () => {
+ it('should call `getGroups` with all the params provided', (done) => {
+ spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups));
+
+ vm.fetchGroups({
+ parentId: 1,
+ page: 2,
+ filterGroupsBy: 'git',
+ sortBy: 'created_desc',
+ archived: true,
+ });
+ setTimeout(() => {
+ expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true);
+ done();
+ }, 0);
+ });
+
+ it('should set headers to store for building pagination info when called with `updatePagination`', (done) => {
+ spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise({ headers: mockRawPageInfo }));
+ spyOn(vm, 'updatePagination');
+
+ vm.fetchGroups({ updatePagination: true });
+ setTimeout(() => {
+ expect(vm.service.getGroups).toHaveBeenCalled();
+ expect(vm.updatePagination).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should show flash error when request fails', (done) => {
+ spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true));
+ spyOn($, 'scrollTo');
+ spyOn(window, 'Flash');
+
+ vm.fetchGroups({});
+ setTimeout(() => {
+ expect(vm.isLoading).toBeFalsy();
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.');
+ done();
+ }, 0);
+ });
+ });
+
+ describe('fetchAllGroups', () => {
+ it('should fetch default set of groups', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
+ spyOn(vm, 'updatePagination').and.callThrough();
+ spyOn(vm, 'updateGroups').and.callThrough();
+
+ vm.fetchAllGroups();
+ expect(vm.isLoading).toBeTruthy();
+ expect(vm.fetchGroups).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(vm.isLoading).toBeFalsy();
+ expect(vm.updateGroups).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should fetch matching set of groups when app is loaded with search query', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups));
+ spyOn(vm, 'updateGroups').and.callThrough();
+
+ vm.fetchAllGroups();
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: null,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: null,
+ });
+ setTimeout(() => {
+ expect(vm.updateGroups).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('fetchPage', () => {
+ it('should fetch groups for provided page details and update window state', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
+ spyOn(vm, 'updateGroups').and.callThrough();
+ spyOn(gl.utils, 'mergeUrlParams').and.callThrough();
+ spyOn(window.history, 'replaceState');
+ spyOn($, 'scrollTo');
+
+ vm.fetchPage(2, null, null, true);
+ expect(vm.isLoading).toBeTruthy();
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ page: 2,
+ filterGroupsBy: null,
+ sortBy: null,
+ updatePagination: true,
+ archived: true,
+ });
+ setTimeout(() => {
+ expect(vm.isLoading).toBeFalsy();
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(gl.utils.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
+ expect(window.history.replaceState).toHaveBeenCalledWith({
+ page: jasmine.any(String),
+ }, jasmine.any(String), jasmine.any(String));
+ expect(vm.updateGroups).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('toggleChildren', () => {
+ let groupItem;
+
+ beforeEach(() => {
+ groupItem = Object.assign({}, mockParentGroupItem);
+ groupItem.isOpen = false;
+ groupItem.isChildrenLoading = false;
+ });
+
+ it('should fetch children of given group and expand it if group is collapsed and children are not loaded', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren));
+ spyOn(vm.store, 'setGroupChildren');
+
+ vm.toggleChildren(groupItem);
+ expect(groupItem.isChildrenLoading).toBeTruthy();
+ expect(vm.fetchGroups).toHaveBeenCalledWith({
+ parentId: groupItem.id,
+ });
+ setTimeout(() => {
+ expect(vm.store.setGroupChildren).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should skip network request while expanding group if children are already loaded', () => {
+ spyOn(vm, 'fetchGroups');
+ groupItem.children = mockRawChildren;
+
+ vm.toggleChildren(groupItem);
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBeTruthy();
+ });
+
+ it('should collapse group if it is already expanded', () => {
+ spyOn(vm, 'fetchGroups');
+ groupItem.isOpen = true;
+
+ vm.toggleChildren(groupItem);
+ expect(vm.fetchGroups).not.toHaveBeenCalled();
+ expect(groupItem.isOpen).toBeFalsy();
+ });
+
+ it('should set `isChildrenLoading` back to `false` if load request fails', (done) => {
+ spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
+
+ vm.toggleChildren(groupItem);
+ expect(groupItem.isChildrenLoading).toBeTruthy();
+ setTimeout(() => {
+ expect(groupItem.isChildrenLoading).toBeFalsy();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('leaveGroup', () => {
+ let groupItem;
+ let childGroupItem;
+
+ beforeEach(() => {
+ groupItem = Object.assign({}, mockParentGroupItem);
+ groupItem.children = mockChildren;
+ childGroupItem = groupItem.children[0];
+ groupItem.isChildrenLoading = false;
+ });
+
+ it('should leave group and remove group item from tree', (done) => {
+ const notice = `You left the "${childGroupItem.fullName}" group.`;
+ spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice }));
+ spyOn(vm.store, 'removeGroup').and.callThrough();
+ spyOn(window, 'Flash');
+ spyOn($, 'scrollTo');
+
+ vm.leaveGroup(childGroupItem, groupItem);
+ expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ setTimeout(() => {
+ expect($.scrollTo).toHaveBeenCalledWith(0);
+ expect(vm.store.removeGroup).toHaveBeenCalledWith(childGroupItem, groupItem);
+ expect(window.Flash).toHaveBeenCalledWith(notice, 'notice');
+ done();
+ }, 0);
+ });
+
+ it('should show error flash message if request failed to leave group', (done) => {
+ const message = 'An error occurred. Please try again.';
+ spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 500 }, true));
+ spyOn(vm.store, 'removeGroup').and.callThrough();
+ spyOn(window, 'Flash');
+
+ vm.leaveGroup(childGroupItem, groupItem);
+ expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ setTimeout(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(childGroupItem.isBeingRemoved).toBeFalsy();
+ done();
+ }, 0);
+ });
+
+ it('should show appropriate error flash message if request forbids to leave group', (done) => {
+ const message = 'Failed to leave the group. Please make sure you are not the only owner.';
+ spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 403 }, true));
+ spyOn(vm.store, 'removeGroup').and.callThrough();
+ spyOn(window, 'Flash');
+
+ vm.leaveGroup(childGroupItem, groupItem);
+ expect(childGroupItem.isBeingRemoved).toBeTruthy();
+ expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath);
+ setTimeout(() => {
+ expect(vm.store.removeGroup).not.toHaveBeenCalled();
+ expect(window.Flash).toHaveBeenCalledWith(message);
+ expect(childGroupItem.isBeingRemoved).toBeFalsy();
+ done();
+ }, 0);
+ });
+ });
+
+ describe('updatePagination', () => {
+ it('should set pagination info to store from provided headers', () => {
+ spyOn(vm.store, 'setPaginationInfo');
+
+ vm.updatePagination(mockRawPageInfo);
+ expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo);
+ });
+ });
+
+ describe('updateGroups', () => {
+ it('should call setGroups on store if method was called directly', () => {
+ spyOn(vm.store, 'setGroups');
+
+ vm.updateGroups(mockGroups);
+ expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should call setSearchedGroups on store if method was called with fromSearch param', () => {
+ spyOn(vm.store, 'setSearchedGroups');
+
+ vm.updateGroups(mockGroups, true);
+ expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups);
+ });
+
+ it('should set `isSearchEmpty` prop based on groups count', () => {
+ vm.updateGroups(mockGroups);
+ expect(vm.isSearchEmpty).toBeFalsy();
+
+ vm.updateGroups([]);
+ expect(vm.isSearchEmpty).toBeTruthy();
+ });
+ });
+ });
+
+ describe('created', () => {
+ it('should bind event listeners on eventHub', (done) => {
+ spyOn(eventHub, '$on');
+
+ const newVm = createComponent();
+ newVm.$mount();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
+ expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
+ newVm.$destroy();
+ done();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (done) => {
+ const newVm = createComponent();
+ newVm.$mount();
+ Vue.nextTick(() => {
+ expect(newVm.searchEmptyMessage).toBe('Sorry, no groups or projects matched your search');
+ newVm.$destroy();
+ done();
+ });
+ });
+
+ it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (done) => {
+ const newVm = createComponent(true);
+ newVm.$mount();
+ Vue.nextTick(() => {
+ expect(newVm.searchEmptyMessage).toBe('Sorry, no groups matched your search');
+ newVm.$destroy();
+ done();
+ });
+ });
+ });
+
+ describe('beforeDestroy', () => {
+ it('should unbind event listeners on eventHub', (done) => {
+ spyOn(eventHub, '$off');
+
+ const newVm = createComponent();
+ newVm.$mount();
+ newVm.$destroy();
+
+ Vue.nextTick(() => {
+ expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function));
+ expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function));
+ done();
+ });
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render loading icon', (done) => {
+ vm.isLoading = true;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
+ expect(vm.$el.querySelector('i.fa').getAttribute('aria-label')).toBe('Loading groups');
+ done();
+ });
+ });
+
+ it('should render groups tree', (done) => {
+ vm.groups = [mockParentGroupItem];
+ vm.isLoading = false;
+ vm.pageInfo = mockPageInfo;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/group_folder_spec.js b/spec/javascripts/groups/components/group_folder_spec.js
new file mode 100644
index 00000000000..4eb198595fb
--- /dev/null
+++ b/spec/javascripts/groups/components/group_folder_spec.js
@@ -0,0 +1,66 @@
+import Vue from 'vue';
+
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import { mockGroups, mockParentGroupItem } from '../mock_data';
+
+const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
+ const Component = Vue.extend(groupFolderComponent);
+
+ return new Component({
+ propsData: {
+ groups,
+ parentGroup,
+ },
+ });
+};
+
+describe('GroupFolderComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+ vm.$mount();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('hasMoreChildren', () => {
+ it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
+ expect(vm.hasMoreChildren).toBeFalsy();
+ });
+ });
+
+ describe('moreChildrenStats', () => {
+ it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
+ expect(vm.moreChildrenStats).toBe('3 more items');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
+ expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
+ });
+
+ it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
+ const parentGroup = Object.assign({}, mockParentGroupItem);
+ parentGroup.childrenCount = 21;
+
+ const newVm = createComponent(mockGroups, parentGroup);
+ newVm.$mount();
+ expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
+ newVm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js
new file mode 100644
index 00000000000..0f4fbdae445
--- /dev/null
+++ b/spec/javascripts/groups/components/group_item_spec.js
@@ -0,0 +1,177 @@
+import Vue from 'vue';
+
+import groupItemComponent from '~/groups/components/group_item.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(groupItemComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('GroupItemComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+
+ vm = createComponent();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('groupDomId', () => {
+ it('should return ID string suffixed with group ID', () => {
+ expect(vm.groupDomId).toBe('group-55');
+ });
+ });
+
+ describe('rowClass', () => {
+ it('should return map of classes based on group details', () => {
+ const classes = ['is-open', 'has-children', 'has-description', 'being-removed'];
+ const rowClass = vm.rowClass;
+
+ expect(Object.keys(rowClass).length).toBe(classes.length);
+ Object.keys(rowClass).forEach((className) => {
+ expect(classes.indexOf(className) > -1).toBeTruthy();
+ });
+ });
+ });
+
+ describe('hasChildren', () => {
+ it('should return boolean value representing if group has any children present', () => {
+ let newVm;
+ const group = Object.assign({}, mockParentGroupItem);
+
+ group.childrenCount = 5;
+ newVm = createComponent(group);
+ expect(newVm.hasChildren).toBeTruthy();
+ newVm.$destroy();
+
+ group.childrenCount = 0;
+ newVm = createComponent(group);
+ expect(newVm.hasChildren).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('hasAvatar', () => {
+ it('should return boolean value representing if group has any avatar present', () => {
+ let newVm;
+ const group = Object.assign({}, mockParentGroupItem);
+
+ group.avatarUrl = null;
+ newVm = createComponent(group);
+ expect(newVm.hasAvatar).toBeFalsy();
+ newVm.$destroy();
+
+ group.avatarUrl = '/uploads/group_avatar.png';
+ newVm = createComponent(group);
+ expect(newVm.hasAvatar).toBeTruthy();
+ newVm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing if group item is of type `group` or not', () => {
+ let newVm;
+ const group = Object.assign({}, mockParentGroupItem);
+
+ group.type = 'group';
+ newVm = createComponent(group);
+ expect(newVm.isGroup).toBeTruthy();
+ newVm.$destroy();
+
+ group.type = 'project';
+ newVm = createComponent(group);
+ expect(newVm.isGroup).toBeFalsy();
+ newVm.$destroy();
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('onClickRowGroup', () => {
+ let event;
+
+ beforeEach(() => {
+ const classList = {
+ contains() {
+ return false;
+ },
+ };
+
+ event = {
+ target: {
+ classList,
+ parentElement: {
+ classList,
+ },
+ },
+ };
+ });
+
+ it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => {
+ spyOn(eventHub, '$emit');
+
+ vm.onClickRowGroup(event);
+ expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group);
+ });
+
+ it('should navigate page to group homepage if group does not have any children present', (done) => {
+ const group = Object.assign({}, mockParentGroupItem);
+ group.childrenCount = 0;
+ const newVm = createComponent(group);
+ spyOn(gl.utils, 'visitUrl').and.stub();
+ spyOn(eventHub, '$emit');
+
+ newVm.onClickRowGroup(event);
+ setTimeout(() => {
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ expect(gl.utils.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath);
+ done();
+ }, 0);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.getAttribute('id')).toBe('group-55');
+ expect(vm.$el.classList.contains('group-row')).toBeTruthy();
+
+ expect(vm.$el.querySelector('.group-row-contents')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined();
+ expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined();
+
+ expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined();
+ expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined();
+
+ expect(vm.$el.querySelector('.avatar-container')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined();
+ expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined();
+
+ expect(vm.$el.querySelector('.title')).toBeDefined();
+ expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
+ expect(vm.$el.querySelector('.access-type')).toBeDefined();
+ expect(vm.$el.querySelector('.description')).toBeDefined();
+
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js
new file mode 100644
index 00000000000..90e818c1545
--- /dev/null
+++ b/spec/javascripts/groups/components/groups_spec.js
@@ -0,0 +1,70 @@
+import Vue from 'vue';
+
+import groupsComponent from '~/groups/components/groups.vue';
+import groupFolderComponent from '~/groups/components/group_folder.vue';
+import groupItemComponent from '~/groups/components/group_item.vue';
+import eventHub from '~/groups/event_hub';
+import { mockGroups, mockPageInfo } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (searchEmpty = false) => {
+ const Component = Vue.extend(groupsComponent);
+
+ return mountComponent(Component, {
+ groups: mockGroups,
+ pageInfo: mockPageInfo,
+ searchEmptyMessage: 'No matching results',
+ searchEmpty,
+ });
+};
+
+describe('GroupsComponent', () => {
+ let vm;
+
+ beforeEach((done) => {
+ Vue.component('group-folder', groupFolderComponent);
+ Vue.component('group-item', groupItemComponent);
+
+ vm = createComponent();
+
+ Vue.nextTick(() => {
+ done();
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('change', () => {
+ it('should emit `fetchPage` event when page is changed via pagination', () => {
+ spyOn(eventHub, '$emit').and.stub();
+
+ vm.change(2);
+ expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', 2, jasmine.any(Object), jasmine.any(Object), jasmine.any(Object));
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', (done) => {
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
+ expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
+ expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
+ expect(vm.$el.querySelectorAll('.has-no-search-results').length === 0).toBeTruthy();
+ done();
+ });
+ });
+
+ it('should render empty search message when `searchEmpty` is `true`', (done) => {
+ vm.searchEmpty = true;
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js
new file mode 100644
index 00000000000..2ce1a749a96
--- /dev/null
+++ b/spec/javascripts/groups/components/item_actions_spec.js
@@ -0,0 +1,110 @@
+import Vue from 'vue';
+
+import itemActionsComponent from '~/groups/components/item_actions.vue';
+import eventHub from '~/groups/event_hub';
+import { mockParentGroupItem, mockChildren } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
+ const Component = Vue.extend(itemActionsComponent);
+
+ return mountComponent(Component, {
+ group,
+ parentGroup,
+ });
+};
+
+describe('ItemActionsComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('leaveConfirmationMessage', () => {
+ it('should return appropriate string for leave group confirmation', () => {
+ expect(vm.leaveConfirmationMessage).toBe('Are you sure you want to leave the "platform / hardware" group?');
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('onLeaveGroup', () => {
+ it('should change `dialogStatus` prop to `true` which shows confirmation dialog', () => {
+ expect(vm.dialogStatus).toBeFalsy();
+ vm.onLeaveGroup();
+ expect(vm.dialogStatus).toBeTruthy();
+ });
+ });
+
+ describe('leaveGroup', () => {
+ it('should change `dialogStatus` prop to `false` and emit `leaveGroup` event with required params when called with `leaveConfirmed` as `true`', () => {
+ spyOn(eventHub, '$emit');
+ vm.dialogStatus = true;
+ vm.leaveGroup(true);
+ expect(vm.dialogStatus).toBeFalsy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('leaveGroup', vm.group, vm.parentGroup);
+ });
+
+ it('should change `dialogStatus` prop to `false` and should NOT emit `leaveGroup` event when called with `leaveConfirmed` as `false`', () => {
+ spyOn(eventHub, '$emit');
+ vm.dialogStatus = true;
+ vm.leaveGroup(false);
+ expect(vm.dialogStatus).toBeFalsy();
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ expect(vm.$el.classList.contains('controls')).toBeTruthy();
+ });
+
+ it('should render Edit Group button with correct attribute values', () => {
+ const group = Object.assign({}, mockParentGroupItem);
+ group.canEdit = true;
+ const newVm = createComponent(group);
+
+ const editBtn = newVm.$el.querySelector('a.edit-group');
+ expect(editBtn).toBeDefined();
+ expect(editBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(editBtn.getAttribute('href')).toBe(group.editPath);
+ expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
+ expect(editBtn.dataset.originalTitle).toBe('Edit group');
+ expect(editBtn.querySelector('i.fa.fa-cogs')).toBeDefined();
+
+ newVm.$destroy();
+ });
+
+ it('should render Leave Group button with correct attribute values', () => {
+ const group = Object.assign({}, mockParentGroupItem);
+ group.canLeave = true;
+ const newVm = createComponent(group);
+
+ const leaveBtn = newVm.$el.querySelector('a.leave-group');
+ expect(leaveBtn).toBeDefined();
+ expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
+ expect(leaveBtn.getAttribute('href')).toBe(group.leavePath);
+ expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group');
+ expect(leaveBtn.dataset.originalTitle).toBe('Leave this group');
+ expect(leaveBtn.querySelector('i.fa.fa-sign-out')).toBeDefined();
+
+ newVm.$destroy();
+ });
+
+ it('should show modal dialog when `dialogStatus` is set to `true`', () => {
+ vm.dialogStatus = true;
+ const modalDialogEl = vm.$el.querySelector('.modal.popup-dialog');
+ expect(modalDialogEl).toBeDefined();
+ expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?');
+ expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave');
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js
new file mode 100644
index 00000000000..4310a07e6e6
--- /dev/null
+++ b/spec/javascripts/groups/components/item_caret_spec.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+
+import itemCaretComponent from '~/groups/components/item_caret.vue';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (isGroupOpen = false) => {
+ const Component = Vue.extend(itemCaretComponent);
+
+ return mountComponent(Component, {
+ isGroupOpen,
+ });
+};
+
+describe('ItemCaretComponent', () => {
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+ vm.$mount();
+ expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
+ const vm = createComponent(true);
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(1);
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(0);
+ vm.$destroy();
+ });
+
+ it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
+ const vm = createComponent();
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(0);
+ expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(1);
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js
new file mode 100644
index 00000000000..e200f9f08bd
--- /dev/null
+++ b/spec/javascripts/groups/components/item_stats_spec.js
@@ -0,0 +1,159 @@
+import Vue from 'vue';
+
+import itemStatsComponent from '~/groups/components/item_stats.vue';
+import {
+ mockParentGroupItem,
+ ITEM_TYPE,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
+} from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (item = mockParentGroupItem) => {
+ const Component = Vue.extend(itemStatsComponent);
+
+ return mountComponent(Component, {
+ item,
+ });
+};
+
+describe('ItemStatsComponent', () => {
+ describe('computed', () => {
+ describe('visibilityIcon', () => {
+ it('should return icon class based on `item.visibility` value', () => {
+ Object.keys(VISIBILITY_TYPE_ICON).forEach((visibility) => {
+ const item = Object.assign({}, mockParentGroupItem, { visibility });
+ const vm = createComponent(item);
+ vm.$mount();
+ expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('visibilityTooltip', () => {
+ it('should return tooltip string for Group based on `item.visibility` value', () => {
+ Object.keys(GROUP_VISIBILITY_TYPE).forEach((visibility) => {
+ const item = Object.assign({}, mockParentGroupItem, {
+ visibility,
+ type: ITEM_TYPE.GROUP,
+ });
+ const vm = createComponent(item);
+ vm.$mount();
+ expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+
+ it('should return tooltip string for Project based on `item.visibility` value', () => {
+ Object.keys(PROJECT_VISIBILITY_TYPE).forEach((visibility) => {
+ const item = Object.assign({}, mockParentGroupItem, {
+ visibility,
+ type: ITEM_TYPE.PROJECT,
+ });
+ const vm = createComponent(item);
+ vm.$mount();
+ expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('isProject', () => {
+ it('should return boolean value representing whether `item.type` is Project or not', () => {
+ let item;
+ let vm;
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isProject).toBeTruthy();
+ vm.$destroy();
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isProject).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+
+ describe('isGroup', () => {
+ it('should return boolean value representing whether `item.type` is Group or not', () => {
+ let item;
+ let vm;
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isGroup).toBeTruthy();
+ vm.$destroy();
+
+ item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT });
+ vm = createComponent(item);
+ vm.$mount();
+ expect(vm.isGroup).toBeFalsy();
+ vm.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+ vm.$mount();
+
+ const visibilityIconEl = vm.$el.querySelector('.item-visibility');
+ expect(vm.$el.classList.contains('.stats')).toBeDefined();
+ expect(visibilityIconEl).toBeDefined();
+ expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip);
+ expect(visibilityIconEl.querySelector('i.fa')).toBeDefined();
+
+ vm.$destroy();
+ });
+
+ it('should render stat icons if `item.type` is Group', () => {
+ const item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP });
+ const vm = createComponent(item);
+ vm.$mount();
+
+ const subgroupIconEl = vm.$el.querySelector('span.number-subgroups');
+ expect(subgroupIconEl).toBeDefined();
+ expect(subgroupIconEl.dataset.originalTitle).toBe('Subgroups');
+ expect(subgroupIconEl.querySelector('i.fa.fa-folder')).toBeDefined();
+ expect(subgroupIconEl.innerText.trim()).toBe(`${vm.item.subgroupCount}`);
+
+ const projectsIconEl = vm.$el.querySelector('span.number-projects');
+ expect(projectsIconEl).toBeDefined();
+ expect(projectsIconEl.dataset.originalTitle).toBe('Projects');
+ expect(projectsIconEl.querySelector('i.fa.fa-bookmark')).toBeDefined();
+ expect(projectsIconEl.innerText.trim()).toBe(`${vm.item.projectCount}`);
+
+ const membersIconEl = vm.$el.querySelector('span.number-users');
+ expect(membersIconEl).toBeDefined();
+ expect(membersIconEl.dataset.originalTitle).toBe('Members');
+ expect(membersIconEl.querySelector('i.fa.fa-users')).toBeDefined();
+ expect(membersIconEl.innerText.trim()).toBe(`${vm.item.memberCount}`);
+
+ vm.$destroy();
+ });
+
+ it('should render stat icons if `item.type` is Project', () => {
+ const item = Object.assign({}, mockParentGroupItem, {
+ type: ITEM_TYPE.PROJECT,
+ starCount: 4,
+ });
+ const vm = createComponent(item);
+ vm.$mount();
+
+ const projectStarIconEl = vm.$el.querySelector('.project-stars');
+ expect(projectStarIconEl).toBeDefined();
+ expect(projectStarIconEl.querySelector('i.fa.fa-star')).toBeDefined();
+ expect(projectStarIconEl.innerText.trim()).toBe(`${vm.item.starCount}`);
+
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js
new file mode 100644
index 00000000000..528e6ed1b4c
--- /dev/null
+++ b/spec/javascripts/groups/components/item_type_icon_spec.js
@@ -0,0 +1,54 @@
+import Vue from 'vue';
+
+import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import { ITEM_TYPE } from '../mock_data';
+
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
+ const Component = Vue.extend(itemTypeIconComponent);
+
+ return mountComponent(Component, {
+ itemType,
+ isGroupOpen,
+ });
+};
+
+describe('ItemTypeIconComponent', () => {
+ describe('template', () => {
+ it('should render component template correctly', () => {
+ const vm = createComponent();
+ vm.$mount();
+ expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render folder open or close icon based `isGroupOpen` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.GROUP, true);
+ vm.$mount();
+ expect(vm.$el.querySelector('i.fa.fa-folder-open')).toBeDefined();
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+ vm.$mount();
+ expect(vm.$el.querySelector('i.fa.fa-folder')).toBeDefined();
+ vm.$destroy();
+ });
+
+ it('should render bookmark icon based on `isProject` prop value', () => {
+ let vm;
+
+ vm = createComponent(ITEM_TYPE.PROJECT);
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(1);
+ vm.$destroy();
+
+ vm = createComponent(ITEM_TYPE.GROUP);
+ vm.$mount();
+ expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(0);
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js
deleted file mode 100644
index 25e10552d95..00000000000
--- a/spec/javascripts/groups/group_item_spec.js
+++ /dev/null
@@ -1,102 +0,0 @@
-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
deleted file mode 100644
index b14153dbbfa..00000000000
--- a/spec/javascripts/groups/groups_spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import Vue from 'vue';
-import eventHub from '~/groups/event_hub';
-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 respect the order of groups', () => {
- const wrap = component.$el.querySelector('.groups-list-tree-container > .group-list-tree');
- expect(wrap.querySelector('.group-row:nth-child(1)').id).toBe('group-12');
- expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119');
- });
-
- 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.id1119.subGroups.id1120.name);
- });
-
- it('should render group identicon when group avatar is not present', () => {
- const avatar = component.$el.querySelector('#group-12 .avatar-container .avatar');
- expect(avatar.nodeName).toBe('DIV');
- expect(avatar.classList.contains('identicon')).toBeTruthy();
- expect(avatar.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
- });
-
- it('should render group avatar when group avatar is present', () => {
- const avatar = component.$el.querySelector('#group-1120 .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);
-
- eventHub.$on('leaveGroup', (group, collection) => {
- store.removeGroup(group, collection);
- });
-
- component.$el.querySelector('#group-12 .leave-group').click();
-
- Vue.nextTick(() => {
- expect(component.$el.querySelector('#group-12')).toBeNull();
- done();
- });
- });
- });
-});
diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js
index 5bb84b591f4..6184d671790 100644
--- a/spec/javascripts/groups/mock_data.js
+++ b/spec/javascripts/groups/mock_data.js
@@ -1,114 +1,380 @@
-const group1 = {
- id: 12,
- name: 'level1',
- path: 'level1',
- description: 'foo',
- visibility: 'public',
- avatar_url: null,
- web_url: 'http://localhost:3000/groups/level1',
- group_path: '/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',
- },
+export const mockEndpoint = '/dashboard/groups.json';
+
+export const ITEM_TYPE = {
+ PROJECT: 'project',
+ GROUP: 'group',
};
-// 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',
- group_path: '/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',
- },
+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.',
};
-const group2 = {
- id: 1119,
- name: 'devops',
- path: 'devops',
- description: 'foo',
- visibility: 'public',
- avatar_url: null,
- web_url: 'http://localhost:3000/groups/devops',
- group_path: '/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',
- },
+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',
};
-const group21 = {
- id: 1120,
- name: 'chef',
- path: 'chef',
- description: 'foo',
+export const mockParentGroupItem = {
+ id: 55,
+ name: 'hardware',
+ description: '',
visibility: 'public',
- avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
- web_url: 'http://localhost:3000/groups/devops/chef',
- group_path: '/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',
- },
+ fullName: 'platform / hardware',
+ relativePath: '/platform/hardware',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/edit',
+ childrenCount: 3,
+ leavePath: '/groups/platform/hardware/group_members/leave',
+ parentId: 54,
+ memberCount: '1',
+ projectCount: 1,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
};
-const groupsData = {
- groups: [group1, group14, group2, group21],
- pagination: {
- Date: 'Mon, 22 May 2017 22:31:52 GMT',
- 'X-Prev-Page': '1',
- 'X-Content-Type-Options': 'nosniff',
- 'X-Total': '31',
- 'Transfer-Encoding': 'chunked',
- 'X-Runtime': '0.611144',
- 'X-Xss-Protection': '1; mode=block',
- 'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09',
- 'X-Ua-Compatible': 'IE=edge',
- 'X-Per-Page': '20',
- Link: '<http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="prev", <http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="first", <http://localhost:3000/dashboard/groups.json?page=2&per_page=20>; rel="last"',
- 'X-Next-Page': '',
- Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"',
- 'X-Frame-Options': 'DENY',
- 'Content-Type': 'application/json; charset=utf-8',
- 'Cache-Control': 'max-age=0, private, must-revalidate',
- 'X-Total-Pages': '2',
- 'X-Page': '2',
+export const mockRawChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [],
+ },
+];
+
+export const mockChildren = [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ fullName: 'platform / hardware / bsp',
+ relativePath: '/platform/hardware/bsp',
+ canEdit: true,
+ type: 'group',
+ avatarUrl: null,
+ permission: 'Owner',
+ editPath: '/groups/platform/hardware/bsp/edit',
+ childrenCount: 6,
+ leavePath: '/groups/platform/hardware/bsp/group_members/leave',
+ parentId: 55,
+ memberCount: '1',
+ projectCount: 4,
+ subgroupCount: 2,
+ canLeave: false,
+ children: [],
+ isOpen: true,
+ isChildrenLoading: false,
+ isBeingRemoved: false,
},
+];
+
+export const mockGroups = [
+ {
+ id: 75,
+ name: 'test-group',
+ description: '',
+ visibility: 'public',
+ full_name: 'test-group',
+ relative_path: '/test-group',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/test-group/edit',
+ children_count: 2,
+ leave_path: '/groups/test-group/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 67,
+ name: 'open-source',
+ description: '',
+ visibility: 'private',
+ full_name: 'open-source',
+ relative_path: '/open-source',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/open-source/edit',
+ children_count: 0,
+ leave_path: '/groups/open-source/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 54,
+ name: 'platform',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform',
+ relative_path: '/platform',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ },
+ {
+ id: 5,
+ name: 'H5bp',
+ description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.',
+ visibility: 'public',
+ full_name: 'H5bp',
+ relative_path: '/h5bp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/h5bp/edit',
+ children_count: 1,
+ leave_path: '/groups/h5bp/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 4,
+ name: 'Twitter',
+ description: 'Deserunt hic nostrum placeat veniam.',
+ visibility: 'public',
+ full_name: 'Twitter',
+ relative_path: '/twitter',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/twitter/edit',
+ children_count: 2,
+ leave_path: '/groups/twitter/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 3,
+ name: 'Documentcloud',
+ description: 'Consequatur saepe totam ea pariatur maxime.',
+ visibility: 'public',
+ full_name: 'Documentcloud',
+ relative_path: '/documentcloud',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/documentcloud/edit',
+ children_count: 1,
+ leave_path: '/groups/documentcloud/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 1,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+ {
+ id: 2,
+ name: 'Gitlab Org',
+ description: 'Debitis ea quas aperiam velit doloremque ab.',
+ visibility: 'public',
+ full_name: 'Gitlab Org',
+ relative_path: '/gitlab-org',
+ can_edit: true,
+ type: 'group',
+ avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png',
+ permission: 'Owner',
+ edit_path: '/groups/gitlab-org/edit',
+ children_count: 4,
+ leave_path: '/groups/gitlab-org/group_members/leave',
+ parent_id: null,
+ number_users_with_delimiter: '5',
+ project_count: 4,
+ subgroup_count: 0,
+ can_leave: false,
+ },
+];
+
+export const mockSearchedGroups = [
+ {
+ id: 55,
+ name: 'hardware',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware',
+ relative_path: '/platform/hardware',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/edit',
+ children_count: 3,
+ leave_path: '/groups/platform/hardware/group_members/leave',
+ parent_id: 54,
+ number_users_with_delimiter: '1',
+ project_count: 1,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [
+ {
+ id: 57,
+ name: 'bsp',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp',
+ relative_path: '/platform/hardware/bsp',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/edit',
+ children_count: 6,
+ leave_path: '/groups/platform/hardware/bsp/group_members/leave',
+ parent_id: 55,
+ number_users_with_delimiter: '1',
+ project_count: 4,
+ subgroup_count: 2,
+ can_leave: false,
+ children: [
+ {
+ id: 60,
+ name: 'kernel',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel',
+ relative_path: '/platform/hardware/bsp/kernel',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/edit',
+ children_count: 1,
+ leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave',
+ parent_id: 57,
+ number_users_with_delimiter: '1',
+ project_count: 0,
+ subgroup_count: 1,
+ can_leave: false,
+ children: [
+ {
+ id: 61,
+ name: 'common',
+ description: '',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common',
+ relative_path: '/platform/hardware/bsp/kernel/common',
+ can_edit: true,
+ type: 'group',
+ avatar_url: null,
+ permission: 'Owner',
+ edit_path: '/groups/platform/hardware/bsp/kernel/common/edit',
+ children_count: 2,
+ leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave',
+ parent_id: 60,
+ number_users_with_delimiter: '1',
+ project_count: 2,
+ subgroup_count: 0,
+ can_leave: false,
+ children: [
+ {
+ 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',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.4',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit',
+ star_count: 0,
+ },
+ {
+ id: 16,
+ name: 'v4.1',
+ description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.',
+ visibility: 'public',
+ full_name: 'platform / hardware / bsp / kernel / common / v4.1',
+ relative_path: '/platform/hardware/bsp/kernel/common/v4.1',
+ can_edit: true,
+ type: 'project',
+ avatar_url: null,
+ permission: null,
+ edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit',
+ star_count: 0,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export const mockRawPageInfo = {
+ 'x-per-page': 10,
+ 'x-page': 10,
+ 'x-total': 10,
+ 'x-total-pages': 10,
+ 'x-next-page': 10,
+ 'x-prev-page': 10,
};
-export { groupsData, group1 };
+export const mockPageInfo = {
+ perPage: 10,
+ page: 10,
+ total: 10,
+ totalPages: 10,
+ nextPage: 10,
+ prevPage: 10,
+};
diff --git a/spec/javascripts/groups/service/groups_service_spec.js b/spec/javascripts/groups/service/groups_service_spec.js
new file mode 100644
index 00000000000..20bb63687f7
--- /dev/null
+++ b/spec/javascripts/groups/service/groups_service_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+import GroupsService from '~/groups/service/groups_service';
+import { mockEndpoint, mockParentGroupItem } from '../mock_data';
+
+Vue.use(VueResource);
+
+describe('GroupsService', () => {
+ let service;
+
+ beforeEach(() => {
+ service = new GroupsService(mockEndpoint);
+ });
+
+ describe('getGroups', () => {
+ it('should return promise for `GET` request on provided endpoint', () => {
+ spyOn(service.groups, 'get').and.stub();
+ const queryParams = {
+ page: 2,
+ filter: 'git',
+ sort: 'created_asc',
+ archived: true,
+ };
+
+ service.getGroups(55, 2, 'git', 'created_asc', true);
+ expect(service.groups.get).toHaveBeenCalledWith({ parent_id: 55 });
+
+ service.getGroups(null, 2, 'git', 'created_asc', true);
+ expect(service.groups.get).toHaveBeenCalledWith(queryParams);
+ });
+ });
+
+ describe('leaveGroup', () => {
+ it('should return promise for `DELETE` request on provided endpoint', () => {
+ spyOn(Vue.http, 'delete').and.stub();
+
+ service.leaveGroup(mockParentGroupItem.leavePath);
+ expect(Vue.http.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath);
+ });
+ });
+});
diff --git a/spec/javascripts/groups/store/groups_store_spec.js b/spec/javascripts/groups/store/groups_store_spec.js
new file mode 100644
index 00000000000..d74f38f476e
--- /dev/null
+++ b/spec/javascripts/groups/store/groups_store_spec.js
@@ -0,0 +1,110 @@
+import GroupsStore from '~/groups/store/groups_store';
+import {
+ mockGroups, mockSearchedGroups,
+ mockParentGroupItem, mockRawChildren,
+ mockRawPageInfo,
+} from '../mock_data';
+
+describe('ProjectsStore', () => {
+ describe('constructor', () => {
+ it('should initialize default state', () => {
+ let store;
+
+ store = new GroupsStore();
+ expect(Object.keys(store.state).length).toBe(2);
+ expect(Array.isArray(store.state.groups)).toBeTruthy();
+ expect(Object.keys(store.state.pageInfo).length).toBe(0);
+ expect(store.hideProjects).not.toBeDefined();
+
+ store = new GroupsStore(true);
+ expect(store.hideProjects).toBeTruthy();
+ });
+ });
+
+ describe('setGroups', () => {
+ it('should set groups to state', () => {
+ const store = new GroupsStore();
+ spyOn(store, 'formatGroupItem').and.callThrough();
+
+ store.setGroups(mockGroups);
+ expect(store.state.groups.length).toBe(mockGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
+ });
+ });
+
+ describe('setSearchedGroups', () => {
+ it('should set searched groups to state', () => {
+ const store = new GroupsStore();
+ spyOn(store, 'formatGroupItem').and.callThrough();
+
+ store.setSearchedGroups(mockSearchedGroups);
+ expect(store.state.groups.length).toBe(mockSearchedGroups.length);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy();
+ expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName') > -1).toBeTruthy();
+ });
+ });
+
+ describe('setGroupChildren', () => {
+ it('should set children to group item in state', () => {
+ const store = new GroupsStore();
+ spyOn(store, 'formatGroupItem').and.callThrough();
+
+ store.setGroupChildren(mockParentGroupItem, mockRawChildren);
+ expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object));
+ expect(mockParentGroupItem.children.length).toBe(1);
+ expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName') > -1).toBeTruthy();
+ expect(mockParentGroupItem.isOpen).toBeTruthy();
+ expect(mockParentGroupItem.isChildrenLoading).toBeFalsy();
+ });
+ });
+
+ describe('setPaginationInfo', () => {
+ it('should parse and set pagination info in state', () => {
+ const store = new GroupsStore();
+
+ store.setPaginationInfo(mockRawPageInfo);
+ expect(store.state.pageInfo.perPage).toBe(10);
+ expect(store.state.pageInfo.page).toBe(10);
+ expect(store.state.pageInfo.total).toBe(10);
+ expect(store.state.pageInfo.totalPages).toBe(10);
+ expect(store.state.pageInfo.nextPage).toBe(10);
+ expect(store.state.pageInfo.previousPage).toBe(10);
+ });
+ });
+
+ describe('formatGroupItem', () => {
+ it('should parse group item object and return updated object', () => {
+ let store;
+ let updatedGroupItem;
+
+ store = new GroupsStore();
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+ expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
+ expect(updatedGroupItem.isChildrenLoading).toBe(false);
+ expect(updatedGroupItem.isBeingRemoved).toBe(false);
+
+ store = new GroupsStore(true);
+ updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
+ expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy();
+ expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
+ });
+ });
+
+ describe('removeGroup', () => {
+ it('should remove children from group item in state', () => {
+ const store = new GroupsStore();
+ const rawParentGroup = Object.assign({}, mockGroups[0]);
+ const rawChildGroup = Object.assign({}, mockGroups[1]);
+
+ store.setGroups([rawParentGroup]);
+ store.setGroupChildren(store.state.groups[0], [rawChildGroup]);
+ const childItem = store.state.groups[0].children[0];
+
+ store.removeGroup(childItem, store.state.groups[0]);
+ expect(store.state.groups[0].children.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/lib/gitlab/group_hierarchy_spec.rb b/spec/lib/gitlab/group_hierarchy_spec.rb
index 8dc83a6db7f..30686634af4 100644
--- a/spec/lib/gitlab/group_hierarchy_spec.rb
+++ b/spec/lib/gitlab/group_hierarchy_spec.rb
@@ -18,6 +18,12 @@ describe Gitlab::GroupHierarchy, :postgresql do
expect(relation).to include(parent, child1)
end
+ it 'can find ancestors upto a certain level' do
+ relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1)
+
+ expect(relation).to contain_exactly(child2)
+ end
+
it 'uses ancestors_base #initialize argument' do
relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors
@@ -55,6 +61,28 @@ describe Gitlab::GroupHierarchy, :postgresql do
end
end
+ describe '#descendants' do
+ it 'includes only the descendants' do
+ relation = described_class.new(Group.where(id: parent)).descendants
+
+ expect(relation).to contain_exactly(child1, child2)
+ end
+ end
+
+ describe '#ancestors' do
+ it 'includes only the ancestors' do
+ relation = described_class.new(Group.where(id: child2)).ancestors
+
+ expect(relation).to contain_exactly(child1, parent)
+ end
+
+ it 'can find ancestors upto a certain level' do
+ relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1)
+
+ expect(relation).to be_empty
+ end
+ end
+
describe '#all_groups' do
let(:relation) do
described_class.new(Group.where(id: child1.id)).all_groups
diff --git a/spec/lib/gitlab/multi_collection_paginator_spec.rb b/spec/lib/gitlab/multi_collection_paginator_spec.rb
new file mode 100644
index 00000000000..68bd4f93159
--- /dev/null
+++ b/spec/lib/gitlab/multi_collection_paginator_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe Gitlab::MultiCollectionPaginator do
+ subject(:paginator) { described_class.new(Project.all.order(:id), Group.all.order(:id), per_page: 3) }
+
+ it 'combines both collections' do
+ project = create(:project)
+ group = create(:group)
+
+ expect(paginator.paginate(1)).to eq([project, group])
+ end
+
+ it 'includes elements second collection if first collection is empty' do
+ group = create(:group)
+
+ expect(paginator.paginate(1)).to eq([group])
+ end
+
+ context 'with a full first page' do
+ let!(:all_groups) { create_list(:group, 4) }
+ let!(:all_projects) { create_list(:project, 4) }
+
+ it 'knows the total count of the collection' do
+ expect(paginator.total_count).to eq(8)
+ end
+
+ it 'fills the first page with elements of the first collection' do
+ expect(paginator.paginate(1)).to eq(all_projects.take(3))
+ end
+
+ it 'fils the second page with a mixture of of the first & second collection' do
+ first_collection_element = all_projects.last
+ second_collection_elements = all_groups.take(2)
+
+ expected_collection = [first_collection_element] + second_collection_elements
+
+ expect(paginator.paginate(2)).to eq(expected_collection)
+ end
+
+ it 'fils the last page with elements from the second collection' do
+ expected_collection = all_groups[-2..-1]
+
+ expect(paginator.paginate(3)).to eq(expected_collection)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index 1f1c48ee9b5..f1f188cbfb5 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -213,7 +213,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/')
expect(subject).to match('group_members/')
- expect(subject).to match('subgroups/')
+ expect(subject).to match('labels/')
end
it 'is not case sensitive' do
@@ -246,7 +246,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/')
expect(subject).to match('group_members/')
- expect(subject).to match('subgroups/')
+ expect(subject).to match('labels/')
end
end
@@ -268,7 +268,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/more/')
expect(subject).to match('group_members/more/')
- expect(subject).to match('subgroups/more/')
+ expect(subject).to match('labels/more/')
end
end
end
@@ -292,7 +292,7 @@ describe Gitlab::PathRegex do
it 'rejects group routes' do
expect(subject).not_to match('root/activity/')
expect(subject).not_to match('root/group_members/')
- expect(subject).not_to match('root/subgroups/')
+ expect(subject).not_to match('root/labels/')
end
end
@@ -314,7 +314,7 @@ describe Gitlab::PathRegex do
it 'rejects group routes' do
expect(subject).not_to match('root/activity/more/')
expect(subject).not_to match('root/group_members/more/')
- expect(subject).not_to match('root/subgroups/more/')
+ expect(subject).not_to match('root/labels/more/')
end
end
end
@@ -349,7 +349,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('activity/')
expect(subject).to match('group_members/')
- expect(subject).to match('subgroups/')
+ expect(subject).to match('labels/')
end
it 'is not case sensitive' do
@@ -382,7 +382,7 @@ describe Gitlab::PathRegex do
it 'accepts group routes' do
expect(subject).to match('root/activity/')
expect(subject).to match('root/group_members/')
- expect(subject).to match('root/subgroups/')
+ expect(subject).to match('root/labels/')
end
it 'is not case sensitive' do
diff --git a/spec/lib/gitlab/sql/union_spec.rb b/spec/lib/gitlab/sql/union_spec.rb
index 8026fba9f0a..fe6422c32b6 100644
--- a/spec/lib/gitlab/sql/union_spec.rb
+++ b/spec/lib/gitlab/sql/union_spec.rb
@@ -29,5 +29,12 @@ describe Gitlab::SQL::Union do
expect(union.to_sql).to include('UNION ALL')
end
+
+ it 'returns `NULL` if all relations are empty' do
+ empty_relation = User.none
+ union = described_class.new([empty_relation, empty_relation])
+
+ expect(union.to_sql).to eq('NULL')
+ end
end
end
diff --git a/spec/lib/gitlab/utils/merge_hash_spec.rb b/spec/lib/gitlab/utils/merge_hash_spec.rb
new file mode 100644
index 00000000000..4fa7bb31301
--- /dev/null
+++ b/spec/lib/gitlab/utils/merge_hash_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+describe Gitlab::Utils::MergeHash do
+ describe '.crush' do
+ it 'can flatten a hash to each element' do
+ input = { hello: "world", this: { crushes: ["an entire", "hash"] } }
+ expected_result = [:hello, "world", :this, :crushes, "an entire", "hash"]
+
+ expect(described_class.crush(input)).to eq(expected_result)
+ end
+ end
+
+ describe '.elements' do
+ it 'deep merges an array of elements' do
+ input = [{ hello: ["world"] },
+ { hello: "Everyone" },
+ { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] } },
+ "Goodbye", "Hallo"]
+ expected_output = [
+ {
+ hello:
+ [
+ "world",
+ "Everyone",
+ { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] }
+ ]
+ },
+ "Goodbye"
+ ]
+
+ expect(described_class.merge(input)).to eq(expected_output)
+ end
+ end
+end
diff --git a/spec/models/concerns/group_descendant_spec.rb b/spec/models/concerns/group_descendant_spec.rb
new file mode 100644
index 00000000000..c163fb01a81
--- /dev/null
+++ b/spec/models/concerns/group_descendant_spec.rb
@@ -0,0 +1,166 @@
+require 'spec_helper'
+
+describe GroupDescendant, :nested_groups do
+ let(:parent) { create(:group) }
+ let(:subgroup) { create(:group, parent: parent) }
+ let(:subsub_group) { create(:group, parent: subgroup) }
+
+ def all_preloaded_groups(*groups)
+ groups + [parent, subgroup, subsub_group]
+ end
+
+ context 'for a group' do
+ describe '#hierarchy' do
+ it 'only queries once for the ancestors' do
+ # make sure the subsub_group does not have anything cached
+ test_group = create(:group, parent: subsub_group).reload
+
+ query_count = ActiveRecord::QueryRecorder.new { test_group.hierarchy }.count
+
+ expect(query_count).to eq(1)
+ end
+
+ it 'only queries once for the ancestors when a top is given' do
+ test_group = create(:group, parent: subsub_group).reload
+
+ recorder = ActiveRecord::QueryRecorder.new { test_group.hierarchy(subgroup) }
+ expect(recorder.count).to eq(1)
+ end
+
+ it 'builds a hierarchy for a group' do
+ expected_hierarchy = { parent => { subgroup => subsub_group } }
+
+ expect(subsub_group.hierarchy).to eq(expected_hierarchy)
+ end
+
+ it 'builds a hierarchy upto a specified parent' do
+ expected_hierarchy = { subgroup => subsub_group }
+
+ expect(subsub_group.hierarchy(parent)).to eq(expected_hierarchy)
+ end
+
+ it 'raises an error if specifying a base that is not part of the tree' do
+ expect { subsub_group.hierarchy(build_stubbed(:group)) }
+ .to raise_error('specified top is not part of the tree')
+ end
+ end
+
+ describe '.build_hierarchy' do
+ it 'combines hierarchies until the top' do
+ other_subgroup = create(:group, parent: parent)
+ other_subsub_group = create(:group, parent: subgroup)
+
+ groups = all_preloaded_groups(other_subgroup, subsub_group, other_subsub_group)
+
+ expected_hierarchy = { parent => [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }] }
+
+ expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
+ end
+
+ it 'combines upto a given parent' do
+ other_subgroup = create(:group, parent: parent)
+ other_subsub_group = create(:group, parent: subgroup)
+
+ groups = [other_subgroup, subsub_group, other_subsub_group]
+ groups << subgroup # Add the parent as if it was preloaded
+
+ expected_hierarchy = [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }]
+ expect(described_class.build_hierarchy(groups, parent)).to eq(expected_hierarchy)
+ end
+
+ it 'handles building a tree out of order' do
+ other_subgroup = create(:group, parent: parent)
+ other_subgroup2 = create(:group, parent: parent)
+ other_subsub_group = create(:group, parent: other_subgroup)
+
+ groups = all_preloaded_groups(subsub_group, other_subgroup2, other_subsub_group, other_subgroup)
+ expected_hierarchy = { parent => [{ subgroup => subsub_group }, other_subgroup2, { other_subgroup => other_subsub_group }] }
+
+ expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
+ end
+
+ it 'raises an error if not all elements were preloaded' do
+ expect { described_class.build_hierarchy([subsub_group]) }
+ .to raise_error('parent was not preloaded')
+ end
+ end
+ end
+
+ context 'for a project' do
+ let(:project) { create(:project, namespace: subsub_group) }
+
+ describe '#hierarchy' do
+ it 'builds a hierarchy for a project' do
+ expected_hierarchy = { parent => { subgroup => { subsub_group => project } } }
+
+ expect(project.hierarchy).to eq(expected_hierarchy)
+ end
+
+ it 'builds a hierarchy upto a specified parent' do
+ expected_hierarchy = { subsub_group => project }
+
+ expect(project.hierarchy(subgroup)).to eq(expected_hierarchy)
+ end
+ end
+
+ describe '.build_hierarchy' do
+ it 'combines hierarchies until the top' do
+ other_project = create(:project, namespace: parent)
+ other_subgroup_project = create(:project, namespace: subgroup)
+
+ elements = all_preloaded_groups(other_project, subsub_group, other_subgroup_project)
+
+ expected_hierarchy = { parent => [other_project, { subgroup => [subsub_group, other_subgroup_project] }] }
+
+ expect(described_class.build_hierarchy(elements)).to eq(expected_hierarchy)
+ end
+
+ it 'combines upto a given parent' do
+ other_project = create(:project, namespace: parent)
+ other_subgroup_project = create(:project, namespace: subgroup)
+
+ elements = [other_project, subsub_group, other_subgroup_project]
+ elements << subgroup # Added as if it was preloaded
+
+ expected_hierarchy = [other_project, { subgroup => [subsub_group, other_subgroup_project] }]
+
+ expect(described_class.build_hierarchy(elements, parent)).to eq(expected_hierarchy)
+ end
+
+ it 'merges to elements in the same hierarchy' do
+ expected_hierarchy = { parent => subgroup }
+
+ expect(described_class.build_hierarchy([parent, subgroup])).to eq(expected_hierarchy)
+ end
+
+ it 'merges complex hierarchies' do
+ project = create(:project, namespace: parent)
+ sub_project = create(:project, namespace: subgroup)
+ subsubsub_group = create(:group, parent: subsub_group)
+ subsub_project = create(:project, namespace: subsub_group)
+ subsubsub_project = create(:project, namespace: subsubsub_group)
+ other_subgroup = create(:group, parent: parent)
+ other_subproject = create(:project, namespace: other_subgroup)
+
+ elements = [project, subsubsub_project, sub_project, other_subproject, subsub_project]
+ # Add parent groups as if they were preloaded
+ elements += [other_subgroup, subsubsub_group, subsub_group, subgroup]
+
+ expected_hierarchy = [
+ project,
+ {
+ subgroup => [
+ { subsub_group => [{ subsubsub_group => subsubsub_project }, subsub_project] },
+ sub_project
+ ]
+ },
+ { other_subgroup => other_subproject }
+ ]
+
+ actual_hierarchy = described_class.build_hierarchy(elements, parent)
+
+ expect(actual_hierarchy).to eq(expected_hierarchy)
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/loaded_in_group_list_spec.rb b/spec/models/concerns/loaded_in_group_list_spec.rb
new file mode 100644
index 00000000000..7a279547a3a
--- /dev/null
+++ b/spec/models/concerns/loaded_in_group_list_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe LoadedInGroupList do
+ let(:parent) { create(:group) }
+ subject(:found_group) { Group.with_selects_for_list.find_by(id: parent.id) }
+
+ describe '.with_selects_for_list' do
+ it 'includes the preloaded counts for groups' do
+ create(:group, parent: parent)
+ create(:project, namespace: parent)
+ parent.add_developer(create(:user))
+
+ found_group = Group.with_selects_for_list.find_by(id: parent.id)
+
+ expect(found_group.preloaded_project_count).to eq(1)
+ expect(found_group.preloaded_subgroup_count).to eq(1)
+ expect(found_group.preloaded_member_count).to eq(1)
+ end
+
+ context 'with archived projects' do
+ it 'counts including archived projects when `true` is passed' do
+ create(:project, namespace: parent, archived: true)
+ create(:project, namespace: parent)
+
+ found_group = Group.with_selects_for_list(archived: 'true').find_by(id: parent.id)
+
+ expect(found_group.preloaded_project_count).to eq(2)
+ end
+
+ it 'counts only archived projects when `only` is passed' do
+ create_list(:project, 2, namespace: parent, archived: true)
+ create(:project, namespace: parent)
+
+ found_group = Group.with_selects_for_list(archived: 'only').find_by(id: parent.id)
+
+ expect(found_group.preloaded_project_count).to eq(2)
+ end
+ end
+ end
+
+ describe '#children_count' do
+ it 'counts groups and projects' do
+ create(:group, parent: parent)
+ create(:project, namespace: parent)
+
+ expect(found_group.children_count).to eq(2)
+ end
+ end
+end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 2ebf6acd42a..1bd8e8a5415 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -153,6 +153,20 @@ describe Namespace do
end
end
+ describe '#ancestors_upto', :nested_groups do
+ let(:parent) { create(:group) }
+ let(:child) { create(:group, parent: parent) }
+ let(:child2) { create(:group, parent: child) }
+
+ it 'returns all ancestors when no namespace is given' do
+ expect(child2.ancestors_upto).to contain_exactly(child, parent)
+ end
+
+ it 'includes ancestors upto but excluding the given ancestor' do
+ expect(child2.ancestors_upto(parent)).to contain_exactly(child)
+ end
+ end
+
describe '#move_dir' do
let(:namespace) { create(:namespace) }
let!(:project) { create(:project_empty_repo, namespace: namespace) }
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index cf26dbfea49..74eba7e33f6 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1761,6 +1761,21 @@ describe Project do
it { expect(project.gitea_import?).to be true }
end
+ describe '#ancestors_upto', :nested_groups do
+ let(:parent) { create(:group) }
+ let(:child) { create(:group, parent: parent) }
+ let(:child2) { create(:group, parent: child) }
+ let(:project) { create(:project, namespace: child2) }
+
+ it 'returns all ancestors when no namespace is given' do
+ expect(project.ancestors_upto).to contain_exactly(child2, child, parent)
+ end
+
+ it 'includes ancestors upto but excluding the given ancestor' do
+ expect(project.ancestors_upto(parent)).to contain_exactly(child2, child)
+ end
+ end
+
describe '#lfs_enabled?' do
let(:project) { create(:project) }
@@ -2178,6 +2193,12 @@ describe Project do
it { expect(project.parent).to eq(project.namespace) }
end
+ describe '#parent_id' do
+ let(:project) { create(:project) }
+
+ it { expect(project.parent_id).to eq(project.namespace_id) }
+ end
+
describe '#parent_changed?' do
let(:project) { create(:project) }
diff --git a/spec/serializers/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb
new file mode 100644
index 00000000000..452754d7a79
--- /dev/null
+++ b/spec/serializers/group_child_entity_spec.rb
@@ -0,0 +1,101 @@
+require 'spec_helper'
+
+describe GroupChildEntity do
+ include Gitlab::Routing.url_helpers
+
+ let(:user) { create(:user) }
+ let(:request) { double('request') }
+ let(:entity) { described_class.new(object, request: request) }
+ subject(:json) { entity.as_json }
+
+ before do
+ allow(request).to receive(:current_user).and_return(user)
+ end
+
+ shared_examples 'group child json' do
+ it 'renders json' do
+ is_expected.not_to be_nil
+ end
+
+ %w[id
+ full_name
+ avatar_url
+ name
+ description
+ visibility
+ type
+ can_edit
+ visibility
+ permission
+ relative_path].each do |attribute|
+ it "includes #{attribute}" do
+ expect(json[attribute.to_sym]).to be_present
+ end
+ end
+ end
+
+ describe 'for a project' do
+ let(:object) do
+ create(:project, :with_avatar,
+ description: 'Awesomeness')
+ end
+
+ before do
+ object.add_master(user)
+ end
+
+ it 'has the correct type' do
+ expect(json[:type]).to eq('project')
+ end
+
+ it 'includes the star count' do
+ expect(json[:star_count]).to be_present
+ end
+
+ it 'has the correct edit path' do
+ expect(json[:edit_path]).to eq(edit_project_path(object))
+ end
+
+ it_behaves_like 'group child json'
+ end
+
+ describe 'for a group', :nested_groups do
+ let(:object) do
+ create(:group, :nested, :with_avatar,
+ description: 'Awesomeness')
+ end
+
+ before do
+ object.add_owner(user)
+ end
+
+ it 'has the correct type' do
+ expect(json[:type]).to eq('group')
+ end
+
+ it 'counts projects and subgroups as children' do
+ create(:project, namespace: object)
+ create(:group, parent: object)
+
+ expect(json[:children_count]).to eq(2)
+ end
+
+ %w[children_count leave_path parent_id number_projects_with_delimiter number_users_with_delimiter project_count subgroup_count].each do |attribute|
+ it "includes #{attribute}" do
+ expect(json[attribute.to_sym]).to be_present
+ end
+ end
+
+ it 'allows an owner to leave when there is another one' do
+ object.add_owner(create(:user))
+
+ expect(json[:can_leave]).to be_truthy
+ end
+
+ it 'has the correct edit path' do
+ expect(json[:edit_path]).to eq(edit_group_path(object))
+ end
+
+ it_behaves_like 'group child json'
+ end
+end
diff --git a/spec/serializers/group_child_serializer_spec.rb b/spec/serializers/group_child_serializer_spec.rb
new file mode 100644
index 00000000000..5541ada3750
--- /dev/null
+++ b/spec/serializers/group_child_serializer_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+describe GroupChildSerializer do
+ let(:request) { double('request') }
+ let(:user) { create(:user) }
+ subject(:serializer) { described_class.new(current_user: user) }
+
+ describe '#represent' do
+ context 'for groups' do
+ it 'can render a single group' do
+ expect(serializer.represent(build(:group))).to be_kind_of(Hash)
+ end
+
+ it 'can render a collection of groups' do
+ expect(serializer.represent(build_list(:group, 2))).to be_kind_of(Array)
+ end
+ end
+
+ context 'with a hierarchy', :nested_groups do
+ let(:parent) { create(:group) }
+
+ subject(:serializer) do
+ described_class.new(current_user: user).expand_hierarchy(parent)
+ end
+
+ it 'expands the subgroups' do
+ subgroup = create(:group, parent: parent)
+ subsub_group = create(:group, parent: subgroup)
+
+ json = serializer.represent([subgroup, subsub_group]).first
+ subsub_group_json = json[:children].first
+
+ expect(json[:id]).to eq(subgroup.id)
+ expect(subsub_group_json).not_to be_nil
+ expect(subsub_group_json[:id]).to eq(subsub_group.id)
+ end
+
+ it 'can render a nested tree' do
+ subgroup1 = create(:group, parent: parent)
+ subsub_group1 = create(:group, parent: subgroup1)
+ subgroup2 = create(:group, parent: parent)
+
+ json = serializer.represent([subgroup1, subsub_group1, subgroup1, subgroup2])
+ subgroup1_json = json.first
+ subsub_group1_json = subgroup1_json[:children].first
+
+ expect(json.size).to eq(2)
+ expect(subgroup1_json[:id]).to eq(subgroup1.id)
+ expect(subsub_group1_json[:id]).to eq(subsub_group1.id)
+ end
+
+ context 'without a specified parent' do
+ subject(:serializer) do
+ described_class.new(current_user: user).expand_hierarchy
+ end
+
+ it 'can render a tree' do
+ subgroup = create(:group, parent: parent)
+
+ json = serializer.represent([parent, subgroup])
+ parent_json = json.first
+
+ expect(parent_json[:id]).to eq(parent.id)
+ expect(parent_json[:children].first[:id]).to eq(subgroup.id)
+ end
+ end
+ end
+
+ context 'for projects' do
+ it 'can render a single project' do
+ expect(serializer.represent(build(:project))).to be_kind_of(Hash)
+ end
+
+ it 'can render a collection of projects' do
+ expect(serializer.represent(build_list(:project, 2))).to be_kind_of(Array)
+ end
+
+ context 'with a hierarchy', :nested_groups do
+ let(:parent) { create(:group) }
+
+ subject(:serializer) do
+ described_class.new(current_user: user).expand_hierarchy(parent)
+ end
+
+ it 'can render a nested tree' do
+ subgroup1 = create(:group, parent: parent)
+ project1 = create(:project, namespace: subgroup1)
+ subgroup2 = create(:group, parent: parent)
+ project2 = create(:project, namespace: subgroup2)
+
+ json = serializer.represent([project1, project2, subgroup1, subgroup2])
+ project1_json, project2_json = json.map { |group_json| group_json[:children].first }
+
+ expect(json.size).to eq(2)
+ expect(project1_json[:id]).to eq(project1.id)
+ expect(project2_json[:id]).to eq(project2.id)
+ end
+
+ it 'returns an array when an array of a single instance was given' do
+ project = create(:project, namespace: parent)
+
+ json = serializer.represent([project])
+
+ expect(json).to be_kind_of(Array)
+ expect(json.size).to eq(1)
+ end
+ end
+ end
+ end
+end