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