summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/groups/components/app.vue78
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue7
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue14
-rw-r--r--app/assets/javascripts/groups/components/groups.vue68
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue7
-rw-r--r--app/assets/javascripts/groups/constants.js24
-rw-r--r--app/assets/javascripts/groups/groups_filterable_list.js64
-rw-r--r--app/assets/javascripts/groups/index.js31
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js4
-rw-r--r--app/assets/javascripts/pages/dashboard/groups/index/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/show/group_tabs.js136
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js12
-rw-r--r--app/assets/stylesheets/pages/groups.scss54
-rw-r--r--app/controllers/groups_controller.rb8
-rw-r--r--app/finders/group_descendants_finder.rb2
-rw-r--r--app/views/groups/_archived_projects.html.haml8
-rw-r--r--app/views/groups/_children.html.haml4
-rw-r--r--app/views/groups/_shared_projects.html.haml8
-rw-r--r--app/views/groups/_subgroups_and_projects.html.haml8
-rw-r--r--app/views/groups/show.html.haml37
-rw-r--r--app/views/projects/project_members/_new_project_group.html.haml (renamed from app/views/projects/project_members/_new_shared_group.html.haml)12
-rw-r--r--app/views/projects/project_members/index.html.haml16
-rw-r--r--app/views/shared/groups/_empty_state.html.haml7
-rw-r--r--app/views/shared/groups/_search_form.html.haml4
-rw-r--r--changelogs/unreleased/44005-improve-handling-of-projects-shared-with-a-group.yml5
-rw-r--r--config/routes/group.rb3
-rw-r--r--locale/gitlab.pot43
-rw-r--r--qa/qa/page/component/groups_filter.rb4
-rw-r--r--qa/qa/page/dashboard/groups.rb5
-rw-r--r--qa/qa/page/group/show.rb4
-rw-r--r--spec/controllers/groups_controller_spec.rb8
-rw-r--r--spec/features/projects/members/invite_group_spec.rb (renamed from spec/features/projects/members/share_with_group_spec.rb)38
-rw-r--r--spec/features/projects/settings/user_manages_group_links_spec.rb6
-rw-r--r--spec/finders/group_descendants_finder_spec.rb9
-rw-r--r--spec/javascripts/groups/components/app_spec.js102
35 files changed, 594 insertions, 250 deletions
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index b0765747a36..69f192ac75e 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -2,14 +2,15 @@
/* global Flash */
import $ from 'jquery';
-import { s__ } from '~/locale';
+import { s__, sprintf } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import { HIDDEN_CLASS } from '~/lib/utils/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
-import { COMMON_STR } from '../constants';
+import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants';
import groupsComponent from './groups.vue';
export default {
@@ -19,6 +20,16 @@ export default {
groupsComponent,
},
props: {
+ action: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ containerId: {
+ type: String,
+ required: false,
+ default: '',
+ },
store: {
type: Object,
required: true,
@@ -56,31 +67,28 @@ export default {
? COMMON_STR.GROUP_SEARCH_EMPTY
: COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
- eventHub.$on('fetchPage', this.fetchPage);
- eventHub.$on('toggleChildren', this.toggleChildren);
- eventHub.$on('showLeaveGroupModal', this.showLeaveGroupModal);
- eventHub.$on('updatePagination', this.updatePagination);
- eventHub.$on('updateGroups', this.updateGroups);
+ eventHub.$on(`${this.action}fetchPage`, this.fetchPage);
+ eventHub.$on(`${this.action}toggleChildren`, this.toggleChildren);
+ eventHub.$on(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal);
+ eventHub.$on(`${this.action}updatePagination`, this.updatePagination);
+ eventHub.$on(`${this.action}updateGroups`, this.updateGroups);
},
mounted() {
this.fetchAllGroups();
+
+ if (this.containerId) {
+ this.containerEl = document.getElementById(this.containerId);
+ }
},
beforeDestroy() {
- eventHub.$off('fetchPage', this.fetchPage);
- eventHub.$off('toggleChildren', this.toggleChildren);
- eventHub.$off('showLeaveGroupModal', this.showLeaveGroupModal);
- eventHub.$off('updatePagination', this.updatePagination);
- eventHub.$off('updateGroups', this.updateGroups);
+ eventHub.$off(`${this.action}fetchPage`, this.fetchPage);
+ eventHub.$off(`${this.action}toggleChildren`, this.toggleChildren);
+ eventHub.$off(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal);
+ eventHub.$off(`${this.action}updatePagination`, this.updatePagination);
+ eventHub.$off(`${this.action}updateGroups`, this.updateGroups);
},
methods: {
- fetchGroups({
- parentId,
- page,
- filterGroupsBy,
- sortBy,
- archived,
- updatePagination,
- }) {
+ fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
return this.service
.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
.then(res => {
@@ -165,13 +173,13 @@ export default {
}
},
showLeaveGroupModal(group, parentGroup) {
+ const { fullName } = group;
this.targetGroup = group;
this.targetParentGroup = parentGroup;
this.showModal = true;
- this.groupLeaveConfirmationMessage = s__(
- `GroupsTree|Are you sure you want to leave the "${
- group.fullName
- }" group?`,
+ this.groupLeaveConfirmationMessage = sprintf(
+ s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'),
+ { fullName },
);
},
hideLeaveGroupModal() {
@@ -197,16 +205,35 @@ export default {
this.targetGroup.isBeingRemoved = false;
});
},
+ showEmptyState() {
+ const { containerEl } = this;
+ const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS);
+ const emptyStateEl = containerEl.querySelector('.empty-state');
+
+ if (contentListEl) {
+ contentListEl.remove();
+ }
+
+ if (emptyStateEl) {
+ emptyStateEl.classList.remove(HIDDEN_CLASS);
+ }
+ },
updatePagination(headers) {
this.store.setPaginationInfo(headers);
},
updateGroups(groups, fromSearch) {
- this.isSearchEmpty = groups ? groups.length === 0 : false;
+ const hasGroups = groups && groups.length > 0;
+ this.isSearchEmpty = !hasGroups;
+
if (fromSearch) {
this.store.setSearchedGroups(groups);
} else {
this.store.setGroups(groups);
}
+
+ if (this.action && !hasGroups && !fromSearch) {
+ this.showEmptyState();
+ }
},
},
};
@@ -226,6 +253,7 @@ export default {
:search-empty="isSearchEmpty"
:search-empty-message="searchEmptyMessage"
:page-info="pageInfo"
+ :action="action"
/>
<deprecated-modal
v-show="showModal"
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 647c9d0046d..bcc7a638346 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -11,8 +11,12 @@ export default {
},
groups: {
type: Array,
+ required: true,
+ },
+ action: {
+ type: String,
required: false,
- default: () => ([]),
+ default: '',
},
},
computed: {
@@ -37,6 +41,7 @@ export default {
:key="index"
:group="group"
:parent-group="parentGroup"
+ :action="action"
/>
<li
v-if="hasMoreChildren"
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 2b9e2a929fc..154ad2ea607 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -30,6 +30,11 @@ export default {
type: Object,
required: true,
},
+ action: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
groupDomId() {
@@ -56,10 +61,12 @@ export default {
methods: {
onClickRowGroup(e) {
const NO_EXPAND_CLS = 'no-expand';
- if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
- e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
+ const targetClasses = e.target.classList;
+ const parentElClasses = e.target.parentElement.classList;
+
+ if (!(targetClasses.contains(NO_EXPAND_CLS) || parentElClasses.contains(NO_EXPAND_CLS))) {
if (this.hasChildren) {
- eventHub.$emit('toggleChildren', this.group);
+ eventHub.$emit(`${this.action}toggleChildren`, this.group);
} else {
visitUrl(this.group.relativePath);
}
@@ -158,6 +165,7 @@ export default {
v-if="group.isOpen && hasChildren"
:parent-group="group"
:groups="group.children"
+ :action="action"
/>
</li>
</template>
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 73ae928b0d9..a1beb222950 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -1,39 +1,44 @@
<script>
- import tablePagination from '~/vue_shared/components/table_pagination.vue';
- import eventHub from '../event_hub';
- import { getParameterByName } from '../../lib/utils/common_utils';
+import tablePagination from '~/vue_shared/components/table_pagination.vue';
+import eventHub from '../event_hub';
+import { getParameterByName } from '../../lib/utils/common_utils';
- export default {
- components: {
- tablePagination,
+export default {
+ components: {
+ tablePagination,
+ },
+ props: {
+ groups: {
+ type: Array,
+ required: true,
},
- props: {
- groups: {
- type: Array,
- required: true,
- },
- pageInfo: {
- type: Object,
- required: true,
- },
- searchEmpty: {
- type: Boolean,
- required: true,
- },
- searchEmptyMessage: {
- type: String,
- required: true,
- },
+ pageInfo: {
+ type: Object,
+ required: true,
},
- methods: {
- change(page) {
- const filterGroupsParam = getParameterByName('filter_groups');
- const sortParam = getParameterByName('sort');
- const archivedParam = getParameterByName('archived');
- eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
- },
+ searchEmpty: {
+ type: Boolean,
+ required: true,
},
- };
+ searchEmptyMessage: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ change(page) {
+ const filterGroupsParam = getParameterByName('filter_groups');
+ const sortParam = getParameterByName('sort');
+ const archivedParam = getParameterByName('archived');
+ eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam);
+ },
+ },
+};
</script>
<template>
@@ -47,6 +52,7 @@
<group-folder
v-if="!searchEmpty"
:groups="groups"
+ :action="action"
/>
<table-pagination
v-if="!searchEmpty"
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index 24eec4901ec..6e700b8bf8a 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -21,6 +21,11 @@ export default {
type: Object,
required: true,
},
+ action: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
leaveBtnTitle() {
@@ -32,7 +37,7 @@ export default {
},
methods: {
onLeaveGroup() {
- eventHub.$emit('showLeaveGroupModal', this.group, this.parentGroup);
+ eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup);
},
},
};
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index b8baed682f5..9c246cf3ba6 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -2,13 +2,23 @@ import { __, s__ } from '../locale';
export const MAX_CHILDREN_COUNT = 20;
+export const ACTIVE_TAB_SUBGROUPS_AND_PROJECTS = 'subgroups_and_projects';
+export const ACTIVE_TAB_SHARED = 'shared';
+export const ACTIVE_TAB_ARCHIVED = 'archived';
+
+export const GROUPS_LIST_HOLDER_CLASS = '.js-groups-list-holder';
+export const GROUPS_FILTER_FORM_CLASS = '.js-group-filter-form';
+export const CONTENT_LIST_CLASS = '.content-list';
+
export const COMMON_STR = {
FAILURE: __('An error occurred. Please try again.'),
- LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'),
+ LEAVE_FORBIDDEN: s__(
+ 'GroupsTree|Failed to leave the group. Please make sure you are not the only owner.',
+ ),
LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
- GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'),
- GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'),
+ GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'),
+ GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'),
};
export const ITEM_TYPE = {
@@ -17,8 +27,12 @@ export const ITEM_TYPE = {
};
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.'),
+ 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.'),
};
diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js
index e6db1746487..693519729ac 100644
--- a/app/assets/javascripts/groups/groups_filterable_list.js
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -4,13 +4,23 @@ import eventHub from './event_hub';
import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList {
- constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
+ constructor({
+ form,
+ filter,
+ holder,
+ filterEndpoint,
+ pagePath,
+ dropdownSel,
+ filterInputField,
+ action,
+ }) {
super(form, filter, holder, filterInputField);
this.form = form;
this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath;
this.filterInputField = filterInputField;
this.$dropdown = $(dropdownSel);
+ this.action = action;
}
getFilterEndpoint() {
@@ -20,15 +30,16 @@ export default class GroupFilterableList extends FilterableList {
getPagePath(queryData) {
const params = queryData ? $.param(queryData) : '';
const queryString = params ? `?${params}` : '';
- return `${this.pagePath}${queryString}`;
+ const path = this.pagePath || window.location.pathname;
+ return `${path}${queryString}`;
}
bindEvents() {
super.bindEvents();
- this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
+ this.onFilterOptionClickWrapper = this.onOptionClick.bind(this);
- this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
+ this.$dropdown.on('click', 'a', this.onFilterOptionClickWrapper);
}
onFilterInput() {
@@ -53,7 +64,12 @@ export default class GroupFilterableList extends FilterableList {
}
setDefaultFilterOption() {
- const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text());
+ const defaultOption = $.trim(
+ this.$dropdown
+ .find('.dropdown-menu li.js-filter-sort-order a')
+ .first()
+ .text(),
+ );
this.$dropdown.find('.dropdown-label').text(defaultOption);
}
@@ -65,11 +81,19 @@ export default class GroupFilterableList extends FilterableList {
// Get type of option selected from dropdown
const currentTargetClassList = e.currentTarget.parentElement.classList;
const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order');
- const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects');
+ const isOptionFilterByArchivedProjects = currentTargetClassList.contains(
+ 'js-filter-archived-projects',
+ );
// Get option query param, also preserve currently applied query param
- const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href);
- const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href);
+ const sortParam = getParameterByName(
+ 'sort',
+ isOptionFilterBySort ? e.currentTarget.href : window.location.href,
+ );
+ const archivedParam = getParameterByName(
+ 'archived',
+ isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href,
+ );
if (sortParam) {
queryData.sort = sortParam;
@@ -86,7 +110,9 @@ export default class GroupFilterableList extends FilterableList {
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active');
} else if (isOptionFilterByArchivedProjects) {
- this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects a').removeClass('is-active');
+ this.$dropdown
+ .find('.dropdown-menu li.js-filter-archived-projects a')
+ .removeClass('is-active');
}
$(e.target).addClass('is-active');
@@ -98,11 +124,19 @@ export default class GroupFilterableList extends FilterableList {
onFilterSuccess(res, queryData) {
const currentPath = this.getPagePath(queryData);
- window.history.replaceState({
- page: currentPath,
- }, document.title, currentPath);
-
- eventHub.$emit('updateGroups', res.data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
- eventHub.$emit('updatePagination', normalizeHeaders(res.headers));
+ window.history.replaceState(
+ {
+ page: currentPath,
+ },
+ document.title,
+ currentPath,
+ );
+
+ eventHub.$emit(
+ `${this.action}updateGroups`,
+ res.data,
+ Object.prototype.hasOwnProperty.call(queryData, this.filterInputField),
+ );
+ eventHub.$emit(`${this.action}updatePagination`, normalizeHeaders(res.headers));
}
}
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index 83a9008a94b..0f68f05b523 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -7,18 +7,26 @@ import GroupsService from './service/groups_service';
import groupsApp from './components/app.vue';
import groupFolderComponent from './components/group_folder.vue';
import groupItemComponent from './components/group_item.vue';
+import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants';
Vue.use(Translate);
-export default () => {
- const el = document.getElementById('js-groups-tree');
+export default (containerId = 'js-groups-tree', endpoint, action = '') => {
+ const containerEl = document.getElementById(containerId);
+ let dataEl;
// Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL
- if (!el) {
+ if (!containerEl) {
return;
}
+ const el = action ? containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS) : containerEl;
+
+ if (action) {
+ dataEl = containerEl.querySelector(CONTENT_LIST_CLASS);
+ }
+
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
@@ -29,20 +37,26 @@ export default () => {
groupsApp,
},
data() {
- const { dataset } = this.$options.el;
+ const { dataset } = dataEl || this.$options.el;
const hideProjects = dataset.hideProjects === 'true';
+ const service = new GroupsService(endpoint || dataset.endpoint);
const store = new GroupsStore(hideProjects);
- const service = new GroupsService(dataset.endpoint);
return {
+ action,
store,
service,
hideProjects,
loading: true,
+ containerId,
};
},
beforeMount() {
- const { dataset } = this.$options.el;
+ if (this.action) {
+ return;
+ }
+
+ const { dataset } = dataEl || this.$options.el;
let groupFilterList = null;
const form = document.querySelector(dataset.formSel);
const filter = document.querySelector(dataset.filterSel);
@@ -52,10 +66,11 @@ export default () => {
form,
filter,
holder,
- filterEndpoint: dataset.endpoint,
+ filterEndpoint: endpoint || dataset.endpoint,
pagePath: dataset.path,
dropdownSel: dataset.dropdownSel,
filterInputField: 'filter',
+ action: this.action,
};
groupFilterList = new GroupFilterableList(opts);
@@ -64,9 +79,11 @@ export default () => {
render(createElement) {
return createElement('groups-app', {
props: {
+ action: this.action,
store: this.store,
service: this.service,
hideProjects: this.hideProjects,
+ containerId: this.containerId,
},
});
},
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 72b72f4247d..a282c2df441 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -47,9 +47,9 @@ export function removeParamQueryString(url, param) {
return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&');
}
-export function removeParams(params) {
+export function removeParams(params, source = window.location.href) {
const url = document.createElement('a');
- url.href = window.location.href;
+ url.href = source;
params.forEach(param => {
url.search = removeParamQueryString(url.search, param);
diff --git a/app/assets/javascripts/pages/dashboard/groups/index/index.js b/app/assets/javascripts/pages/dashboard/groups/index/index.js
index 79987642796..b9277106a71 100644
--- a/app/assets/javascripts/pages/dashboard/groups/index/index.js
+++ b/app/assets/javascripts/pages/dashboard/groups/index/index.js
@@ -1,3 +1,5 @@
import initGroupsList from '~/groups';
-document.addEventListener('DOMContentLoaded', initGroupsList);
+document.addEventListener('DOMContentLoaded', () => {
+ initGroupsList();
+});
diff --git a/app/assets/javascripts/pages/groups/show/group_tabs.js b/app/assets/javascripts/pages/groups/show/group_tabs.js
new file mode 100644
index 00000000000..c6fe61d2bd9
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/show/group_tabs.js
@@ -0,0 +1,136 @@
+import $ from 'jquery';
+import { removeParams } from '~/lib/utils/url_utility';
+import createGroupTree from '~/groups';
+import {
+ ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
+ ACTIVE_TAB_SHARED,
+ ACTIVE_TAB_ARCHIVED,
+ CONTENT_LIST_CLASS,
+ GROUPS_LIST_HOLDER_CLASS,
+ GROUPS_FILTER_FORM_CLASS,
+} from '~/groups/constants';
+import UserTabs from '~/pages/users/user_tabs';
+import GroupFilterableList from '~/groups/groups_filterable_list';
+
+export default class GroupTabs extends UserTabs {
+ constructor({ defaultAction = 'subgroups_and_projects', action, parentEl }) {
+ super({ defaultAction, action, parentEl });
+ }
+
+ bindEvents() {
+ this.$parentEl
+ .off('shown.bs.tab', '.nav-links a[data-toggle="tab"]')
+ .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', event => this.tabShown(event));
+ }
+
+ tabShown(event) {
+ const $target = $(event.target);
+ const action = $target.data('action') || $target.data('targetSection');
+ const source = $target.attr('href') || $target.data('targetPath');
+
+ document.querySelector(GROUPS_FILTER_FORM_CLASS).action = source;
+
+ this.setTab(action);
+ return this.setCurrentAction(source);
+ }
+
+ setTab(action) {
+ const loadableActions = [
+ ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
+ ACTIVE_TAB_SHARED,
+ ACTIVE_TAB_ARCHIVED,
+ ];
+ this.enableSearchBar(action);
+ this.action = action;
+
+ if (this.loaded[action]) {
+ return;
+ }
+
+ if (loadableActions.includes(action)) {
+ this.cleanFilterState();
+ this.loadTab(action);
+ }
+ }
+
+ loadTab(action) {
+ const elId = `js-groups-${action}-tree`;
+ const endpoint = this.getEndpoint(action);
+
+ this.toggleLoading(true);
+
+ createGroupTree(elId, endpoint, action);
+ this.loaded[action] = true;
+
+ this.toggleLoading(false);
+ }
+
+ getEndpoint(action) {
+ const { endpointsDefault, endpointsShared } = this.$parentEl.data();
+ let endpoint;
+
+ switch (action) {
+ case ACTIVE_TAB_ARCHIVED:
+ endpoint = `${endpointsDefault}?archived=only`;
+ break;
+ case ACTIVE_TAB_SHARED:
+ endpoint = endpointsShared;
+ break;
+ default:
+ // ACTIVE_TAB_SUBGROUPS_AND_PROJECTS
+ endpoint = endpointsDefault;
+ break;
+ }
+
+ return endpoint;
+ }
+
+ enableSearchBar(action) {
+ const containerEl = document.getElementById(action);
+ const form = document.querySelector(GROUPS_FILTER_FORM_CLASS);
+ const filter = form.querySelector('.js-groups-list-filter');
+ const holder = containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS);
+ const dataEl = containerEl.querySelector(CONTENT_LIST_CLASS);
+ const endpoint = this.getEndpoint(action);
+
+ if (!dataEl) {
+ return;
+ }
+
+ const { dataset } = dataEl;
+ const opts = {
+ form,
+ filter,
+ holder,
+ filterEndpoint: endpoint || dataset.endpoint,
+ pagePath: null,
+ dropdownSel: '.js-group-filter-dropdown-wrap',
+ filterInputField: 'filter',
+ action,
+ };
+
+ if (!this.loaded[action]) {
+ const filterableList = new GroupFilterableList(opts);
+ filterableList.initSearch();
+ }
+ }
+
+ cleanFilterState() {
+ const values = Object.values(this.loaded);
+ const loadedTabs = values.filter(e => e === true);
+
+ if (!loadedTabs.length) {
+ return;
+ }
+
+ const newState = removeParams(['page'], window.location.search);
+
+ window.history.replaceState(
+ {
+ url: newState,
+ },
+ document.title,
+ newState,
+ );
+ }
+}
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index d7b35d2b26b..5b8c2ae7e81 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,14 +1,22 @@
/* eslint-disable no-new */
+import { getPagePath } from '~/lib/utils/common_utils';
+import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
import NewGroupChild from '~/groups/new_group_child';
import notificationsDropdown from '~/notifications_dropdown';
import NotificationsForm from '~/notifications_form';
import ProjectsList from '~/projects_list';
import ShortcutsNavigation from '~/shortcuts_navigation';
-import initGroupsList from '~/groups';
+import GroupTabs from './group_tabs';
document.addEventListener('DOMContentLoaded', () => {
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
+ const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
+ const paths = window.location.pathname.split('/');
+ const subpath = paths[paths.length - 1];
+ const action = loadableActions.includes(subpath) ? subpath : getPagePath(1);
+
+ new GroupTabs({ parentEl: '.groups-listing', action });
new ShortcutsNavigation();
new NotificationsForm();
notificationsDropdown();
@@ -17,6 +25,4 @@ document.addEventListener('DOMContentLoaded', () => {
if (newGroupChildWrapper) {
new NewGroupChild(newGroupChildWrapper);
}
-
- initGroupsList();
});
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 60b4d39bb1a..9ff62e58681 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -3,7 +3,6 @@
}
.dashboard .side .card .card-header .input-group {
-
.form-control {
height: 42px;
}
@@ -30,14 +29,15 @@
}
}
+.group-nav-container .group-search,
.group-nav-container .nav-controls {
display: flex;
align-items: flex-start;
- padding: $gl-padding-top 0;
- border-bottom: 1px solid $border-color;
+ padding: $gl-padding-top 0 0;
.group-filter-form {
- flex: 1;
+ flex: 1 1 auto;
+ margin-right: $gl-padding-8;
}
.dropdown-menu-right {
@@ -136,6 +136,10 @@
flex: 1;
}
+ .dropdown-toggle {
+ width: auto;
+ }
+
.dropdown-menu {
width: 100%;
max-width: inherit;
@@ -145,38 +149,14 @@
}
}
-.groups-empty-state {
- padding: 50px 100px;
- overflow: hidden;
-
- @include media-breakpoint-down(sm) {
- padding: 50px 0;
- }
-
- svg {
- float: right;
-
- @include media-breakpoint-down(sm) {
- float: none;
- display: block;
- width: 250px;
- position: relative;
- left: 50%;
- margin-left: -125px;
- }
- }
-
- .text-content {
- float: left;
- width: 460px;
- margin-top: 120px;
+.group-nav-container .group-search {
+ padding: $gl-padding 0;
+ border-bottom: 1px solid $border-color;
+}
- @include media-breakpoint-down(sm) {
- float: none;
- margin-top: 60px;
- width: auto;
- text-align: center;
- }
+.groups-listing {
+ .group-list-tree .group-row:first-child {
+ border-top: 0;
}
}
@@ -278,7 +258,7 @@
}
&::after {
- content: "";
+ content: '';
position: absolute;
height: 100%;
width: 100%;
@@ -346,7 +326,7 @@
position: relative;
&::before {
- content: "";
+ content: '';
display: block;
width: 10px;
height: 0;
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index e57b9ff23a7..1f48c3417d0 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -17,7 +17,7 @@ class GroupsController < Groups::ApplicationController
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
before_action :event_filter, only: [:activity]
- before_action :user_actions, only: [:show, :subgroups]
+ before_action :user_actions, only: [:show]
skip_cross_project_access_check :index, :new, :create, :edit, :update,
:destroy, :projects
@@ -53,11 +53,7 @@ class GroupsController < Groups::ApplicationController
def show
respond_to do |format|
- format.html do
- @has_children = GroupDescendantsFinder.new(current_user: current_user,
- parent_group: @group,
- params: params).has_children?
- end
+ format.html
format.atom do
load_events
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index 051ea108e06..2300b7fd114 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -134,7 +134,7 @@ class GroupDescendantsFinder
end
def direct_child_projects
- GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
+ GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params, options: { only_owned: true })
.execute
end
diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml
new file mode 100644
index 00000000000..ed79f5790f0
--- /dev/null
+++ b/app/views/groups/_archived_projects.html.haml
@@ -0,0 +1,8 @@
+#js-groups-archived-tree
+ .empty-state.text-center.hidden
+ %p= _("There are no archived projects yet")
+
+ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
+ .js-groups-list-holder
+ .loading-container.text-center
+ = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/groups/_children.html.haml b/app/views/groups/_children.html.haml
deleted file mode 100644
index 742b40784d3..00000000000
--- a/app/views/groups/_children.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-.js-groups-list-holder
- #js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml
new file mode 100644
index 00000000000..4eb8367f633
--- /dev/null
+++ b/app/views/groups/_shared_projects.html.haml
@@ -0,0 +1,8 @@
+#js-groups-shared-tree
+ .empty-state.text-center.hidden
+ %p= _("There are no projects shared with this group yet")
+
+ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
+ .js-groups-list-holder
+ .loading-container.text-center
+ = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml
new file mode 100644
index 00000000000..d53c8026df8
--- /dev/null
+++ b/app/views/groups/_subgroups_and_projects.html.haml
@@ -0,0 +1,8 @@
+#js-groups-subgroups_and_projects-tree
+ .empty-state.hidden
+ = render "shared/groups/empty_state"
+
+ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
+ .js-groups-list-holder
+ .loading-container.text-center
+ = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 5a88619f769..f1bd817f17a 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -7,11 +7,10 @@
= render 'groups/home_panel'
-.groups-header{ class: container_class }
- .group-nav-container
- .nav-controls.clearfix
+.groups-listing{ class: container_class, data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
+ .top-area.group-nav-container
+ .group-search
= render "shared/groups/search_form"
- = render "shared/groups/dropdown", show_archive_options: true
- if can? current_user, :create_projects, @group
- new_project_label = _("New project")
- new_subgroup_label = _("New subgroup")
@@ -39,7 +38,29 @@
- else
= link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
- - if params[:filter].blank? && !@has_children
- = render "shared/groups/empty_state"
- - else
- = render "children", children: @children, group: @group
+ .scrolling-tabs-container.inner-page-scroll-tabs
+ .fade-left= icon('angle-left')
+ .fade-right= icon('angle-right')
+ %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs
+ %li.js-subgroups_and_projects-tab
+ = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do
+ = _("Subgroups and projects")
+ %li.js-shared-tab
+ = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do
+ = _("Shared projects")
+ %li.js-archived-tab
+ = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do
+ = _("Archived projects")
+
+ .nav-controls
+ = render "shared/groups/dropdown"
+
+ .tab-content
+ #subgroups_and_projects.tab-pane
+ = render "subgroups_and_projects", group: @group
+
+ #shared.tab-pane
+ = render "shared_projects", group: @group
+
+ #archived.tab-pane
+ = render "archived_projects", group: @group
diff --git a/app/views/projects/project_members/_new_shared_group.html.haml b/app/views/projects/project_members/_new_project_group.html.haml
index d7227c32833..7de24ec5ad2 100644
--- a/app/views/projects/project_members/_new_shared_group.html.haml
+++ b/app/views/projects/project_members/_new_project_group.html.haml
@@ -2,19 +2,19 @@
.col-sm-12
= form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do
.form-group
- = label_tag :link_group_id, "Select a group to share with", class: "label-bold"
+ = label_tag :link_group_id, _("Select a group to invite"), class: "label-bold"
= groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp", required: true)
.form-group
- = label_tag :link_group_access, "Max access level", class: "label-bold"
+ = label_tag :link_group_access, _("Max access level"), class: "label-bold"
.select-wrapper
= select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
= icon('chevron-down')
.form-text.text-muted.append-bottom-10
- = link_to "Read more", help_page_path("user/permissions"), class: "vlink"
+ = link_to _("Read more"), help_page_path("user/permissions"), class: "vlink"
about role permissions
.form-group
- = label_tag :expires_at, 'Access expiration date', class: 'label-bold'
+ = label_tag :expires_at, _('Access expiration date'), class: 'label-bold'
.clearable-input
- = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: 'Expiration date', id: 'expires_at_groups'
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: _('Expiration date'), id: 'expires_at_groups'
%i.clear-icon.js-clear-input
- = submit_tag "Share", class: "btn btn-create"
+ = submit_tag _("Invite"), class: "btn btn-create"
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 9716322f8a1..14ed3345765 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -6,9 +6,9 @@
Project members
- if can?(current_user, :admin_project_member, @project)
%p
- You can add a new member to
+ You can invite a new member to
%strong= @project.name
- or share it with another group.
+ or invite another group.
- else
%p
Members can be added by project
@@ -19,16 +19,16 @@
- if can?(current_user, :admin_project_member, @project)
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
- %a.nav-link.active{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member
+ %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Invite member
- if @project.allowed_to_share_with_group?
%li.nav-tab{ role: 'presentation' }
- %a.nav-link{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group
+ %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' } Invite group
.tab-content.gitlab-tab-content
- .tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' }
- = render 'projects/project_members/new_project_member', tab_title: 'Add member'
- .tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' }
- = render 'projects/project_members/new_shared_group', tab_title: 'Share with group'
+ .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_project_member', tab_title: 'Invite member'
+ .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_project_group', tab_title: 'Invite group'
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix
diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml
index a9c78547eae..c35f6f5a3c1 100644
--- a/app/views/shared/groups/_empty_state.html.haml
+++ b/app/views/shared/groups/_empty_state.html.haml
@@ -1,7 +1,8 @@
-.groups-empty-state.qa-groups-empty-state
- = custom_icon("icon_empty_groups")
+.group-empty-state.row.align-items-center.justify-content-center.qa-groups-empty-state
+ .icon.text-center.order-md-2
+ = custom_icon("icon_empty_groups")
- .text-content
+ .text-content.m-0.order-md-1
%h4= s_("GroupsEmptyState|A group is a collection of several projects.")
%p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
%p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.")
diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml
index 3f91263089a..67e1cd0d67b 100644
--- a/app/views/shared/groups/_search_form.html.haml
+++ b/app/views/shared/groups/_search_form.html.haml
@@ -1,2 +1,2 @@
-= form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f|
- = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Filter by name...'), class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
+= form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f|
+ = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
diff --git a/changelogs/unreleased/44005-improve-handling-of-projects-shared-with-a-group.yml b/changelogs/unreleased/44005-improve-handling-of-projects-shared-with-a-group.yml
new file mode 100644
index 00000000000..a828ee36eb4
--- /dev/null
+++ b/changelogs/unreleased/44005-improve-handling-of-projects-shared-with-a-group.yml
@@ -0,0 +1,5 @@
+---
+title: Overhaul listing of projects in the group overview page
+merge_request: 20262
+author:
+type: added
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 343865cc50c..893ec8a4e58 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -14,6 +14,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
get :projects, as: :projects_group
get :activity, as: :activity_group
put :transfer, as: :transfer_group
+ # TODO: Remove as part of refactor in https://gitlab.com/gitlab-org/gitlab-ce/issues/49693
+ get 'shared', action: :show, as: :group_shared
+ get 'archived', action: :show, as: :group_archived
end
get '/', action: :show, as: :group_canonical
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 9ce84cd53c7..0e89c19066e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -295,6 +295,9 @@ msgstr ""
msgid "Access denied! Please verify you can add deploy keys to this repository."
msgstr ""
+msgid "Access expiration date"
+msgstr ""
+
msgid "Access to failing storages has been temporarily disabled to allow the mount to recover. Reset storage information after the issue has been resolved to allow access again."
msgstr ""
@@ -604,6 +607,9 @@ msgstr ""
msgid "Archived project! Repository and other project resources are read-only"
msgstr ""
+msgid "Archived projects"
+msgstr ""
+
msgid "Are you sure you want to delete this pipeline schedule?"
msgstr ""
@@ -2622,6 +2628,9 @@ msgstr ""
msgid "Expand sidebar"
msgstr ""
+msgid "Expiration date"
+msgstr ""
+
msgid "Explore"
msgstr ""
@@ -2985,6 +2994,9 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr ""
+msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?"
+msgstr ""
+
msgid "GroupsTree|Create a project in this group."
msgstr ""
@@ -2997,19 +3009,19 @@ msgstr ""
msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
msgstr ""
-msgid "GroupsTree|Filter by name..."
-msgstr ""
-
msgid "GroupsTree|Leave this group"
msgstr ""
msgid "GroupsTree|Loading groups"
msgstr ""
-msgid "GroupsTree|Sorry, no groups matched your search"
+msgid "GroupsTree|No groups matched your search"
msgstr ""
-msgid "GroupsTree|Sorry, no groups or projects matched your search"
+msgid "GroupsTree|No groups or projects matched your search"
+msgstr ""
+
+msgid "GroupsTree|Search by name"
msgstr ""
msgid "Health Check"
@@ -3245,6 +3257,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
+msgid "Invite"
+msgstr ""
+
msgid "Issue Boards"
msgstr ""
@@ -3582,6 +3597,9 @@ msgstr ""
msgid "Markdown enabled"
msgstr ""
+msgid "Max access level"
+msgstr ""
+
msgid "Maximum git storage failures"
msgstr ""
@@ -5072,6 +5090,9 @@ msgstr ""
msgid "Select Archive Format"
msgstr ""
+msgid "Select a group to invite"
+msgstr ""
+
msgid "Select a namespace to fork the project"
msgstr ""
@@ -5171,6 +5192,9 @@ msgstr ""
msgid "Shared Runners"
msgstr ""
+msgid "Shared projects"
+msgstr ""
+
msgid "Sherlock Transactions"
msgstr ""
@@ -5449,6 +5473,9 @@ msgstr ""
msgid "Subgroups"
msgstr ""
+msgid "Subgroups and projects"
+msgstr ""
+
msgid "Submit as spam"
msgstr ""
@@ -5691,6 +5718,9 @@ msgstr ""
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr ""
+msgid "There are no archived projects yet"
+msgstr ""
+
msgid "There are no issues to show"
msgstr ""
@@ -5700,6 +5730,9 @@ msgstr ""
msgid "There are no merge requests to show"
msgstr ""
+msgid "There are no projects shared with this group yet"
+msgstr ""
+
msgid "There are problems accessing Git storage: "
msgstr ""
diff --git a/qa/qa/page/component/groups_filter.rb b/qa/qa/page/component/groups_filter.rb
index 69d465e8ac7..e647d368f0f 100644
--- a/qa/qa/page/component/groups_filter.rb
+++ b/qa/qa/page/component/groups_filter.rb
@@ -7,7 +7,7 @@ module QA
def self.included(base)
base.view 'app/views/shared/groups/_search_form.html.haml' do
element :groups_filter, 'search_field_tag :filter'
- element :groups_filter_placeholder, 'Filter by name...'
+ element :groups_filter_placeholder, 'Search by name'
end
base.view 'app/views/shared/groups/_empty_state.html.haml' do
@@ -27,7 +27,7 @@ module QA
page.has_css?(element_selector_css(:groups_list_tree_container))
end
- fill_in 'Filter by name...', with: name
+ fill_in 'Search by name', with: name
end
end
end
diff --git a/qa/qa/page/dashboard/groups.rb b/qa/qa/page/dashboard/groups.rb
index 5654cc01e09..70c5f996ff8 100644
--- a/qa/qa/page/dashboard/groups.rb
+++ b/qa/qa/page/dashboard/groups.rb
@@ -4,6 +4,11 @@ module QA
class Groups < Page::Base
include Page::Component::GroupsFilter
+ view 'app/views/shared/groups/_search_form.html.haml' do
+ element :groups_filter, 'search_field_tag :filter'
+ element :groups_filter_placeholder, 'Search by name'
+ end
+
view 'app/views/dashboard/_groups_head.html.haml' do
element :new_group_button, 'link_to _("New group")'
end
diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb
index ac85f16d8af..6747f7f10b6 100644
--- a/qa/qa/page/group/show.rb
+++ b/qa/qa/page/group/show.rb
@@ -16,7 +16,7 @@ module QA
end
view 'app/assets/javascripts/groups/constants.js' do
- element :no_result_text, 'Sorry, no groups or projects matched your search'
+ element :no_result_text, 'No groups or projects matched your search'
end
def go_to_subgroup(name)
@@ -30,7 +30,7 @@ module QA
def has_subgroup?(name)
filter_by_name(name)
- page.has_text?(/#{name}|Sorry, no groups or projects matched your search/, wait: 60)
+ page.has_text?(/#{name}|No groups or projects matched your search/, wait: 60)
page.has_text?(name, wait: 0)
end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index ae49490f31c..65d6cd1a295 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -38,14 +38,6 @@ describe GroupsController do
project
end
- context 'as html' do
- it 'assigns whether or not a group has children' do
- get :show, id: group.to_param
-
- expect(assigns(:has_children)).to be_truthy
- end
- end
-
context 'as atom' do
it 'assigns events for all the projects in the group' do
create(:event, project: project)
diff --git a/spec/features/projects/members/share_with_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb
index c6d85e5d22f..1ea4934cff1 100644
--- a/spec/features/projects/members/share_with_group_spec.rb
+++ b/spec/features/projects/members/invite_group_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Project > Members > Share with Group', :js do
+describe 'Project > Members > Invite group', :js do
include Select2Helper
include ActionView::Helpers::DateHelper
@@ -8,17 +8,17 @@ describe 'Project > Members > Share with Group', :js do
describe 'Share with group lock' do
shared_examples 'the project can be shared with groups' do
- it 'the "Share with group" tab exists' do
+ it 'the "Invite group" tab exists' do
visit project_settings_members_path(project)
- expect(page).to have_selector('#share-with-group-tab')
+ expect(page).to have_selector('#invite-group-tab')
end
end
shared_examples 'the project cannot be shared with groups' do
- it 'the "Share with group" tab does not exist' do
+ it 'the "Invite group" tab does not exist' do
visit project_settings_members_path(project)
- expect(page).to have_selector('#add-member-tab')
- expect(page).not_to have_selector('#share-with-group-tab')
+ expect(page).to have_selector('#invite-member-tab')
+ expect(page).not_to have_selector('#invite-group-tab')
end
end
@@ -31,13 +31,13 @@ describe 'Project > Members > Share with Group', :js do
sign_in(maintainer)
end
- context 'when the group has "Share with group lock" disabled' do
+ context 'when the group has "Invite group lock" disabled' do
it_behaves_like 'the project can be shared with groups'
it 'the project can be shared with another group' do
visit project_settings_members_path(project)
- click_on 'share-with-group-tab'
+ click_on 'invite-group-tab'
select2 group_to_share_with.id, from: '#link_group_id'
page.find('body').click
@@ -49,7 +49,7 @@ describe 'Project > Members > Share with Group', :js do
end
end
- context 'when the group has "Share with group lock" enabled' do
+ context 'when the group has "Invite group lock" enabled' do
before do
project.namespace.update_column(:share_with_group_lock, true)
end
@@ -69,12 +69,12 @@ describe 'Project > Members > Share with Group', :js do
sign_in(maintainer)
end
- context 'when the root_group has "Share with group lock" disabled' do
- context 'when the subgroup has "Share with group lock" disabled' do
+ context 'when the root_group has "Invite group lock" disabled' do
+ context 'when the subgroup has "Invite group lock" disabled' do
it_behaves_like 'the project can be shared with groups'
end
- context 'when the subgroup has "Share with group lock" enabled' do
+ context 'when the subgroup has "Invite group lock" enabled' do
before do
subgroup.update_column(:share_with_group_lock, true)
end
@@ -83,16 +83,16 @@ describe 'Project > Members > Share with Group', :js do
end
end
- context 'when the root_group has "Share with group lock" enabled' do
+ context 'when the root_group has "Invite group lock" enabled' do
before do
root_group.update_column(:share_with_group_lock, true)
end
- context 'when the subgroup has "Share with group lock" disabled (parent overridden)' do
+ context 'when the subgroup has "Invite group lock" disabled (parent overridden)' do
it_behaves_like 'the project can be shared with groups'
end
- context 'when the subgroup has "Share with group lock" enabled' do
+ context 'when the subgroup has "Invite group lock" enabled' do
before do
subgroup.update_column(:share_with_group_lock, true)
end
@@ -117,12 +117,12 @@ describe 'Project > Members > Share with Group', :js do
visit project_settings_members_path(project)
- click_on 'share-with-group-tab'
+ click_on 'invite-group-tab'
select2 group.id, from: '#link_group_id'
fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d')
- click_on 'share-with-group-tab'
+ click_on 'invite-group-tab'
find('.btn-create').click
end
@@ -150,7 +150,7 @@ describe 'Project > Members > Share with Group', :js do
visit project_settings_members_path(project)
- click_link 'Share with group'
+ click_link 'Invite group'
find('.ajax-groups-select.select2-container')
@@ -183,7 +183,7 @@ describe 'Project > Members > Share with Group', :js do
it 'the groups dropdown does not show ancestors', :nested_groups do
visit project_settings_members_path(project)
- click_on 'share-with-group-tab'
+ click_on 'invite-group-tab'
click_link 'Search for a group'
page.within '.select2-drop' do
diff --git a/spec/features/projects/settings/user_manages_group_links_spec.rb b/spec/features/projects/settings/user_manages_group_links_spec.rb
index 2f1824d7849..676659b90c3 100644
--- a/spec/features/projects/settings/user_manages_group_links_spec.rb
+++ b/spec/features/projects/settings/user_manages_group_links_spec.rb
@@ -26,13 +26,13 @@ describe 'Projects > Settings > User manages group links' do
end
end
- it 'shares a project with a group', :js do
- click_link('Share with group')
+ it 'invites a group to a project', :js do
+ click_link('Invite group')
select2(group_market.id, from: '#link_group_id')
select('Maintainer', from: 'link_group_access')
- click_button('Share')
+ click_button('Invite')
page.within('.project-members-groups') do
expect(page).to have_content('Market')
diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb
index 796d40cb625..c64abdc3619 100644
--- a/spec/finders/group_descendants_finder_spec.rb
+++ b/spec/finders/group_descendants_finder_spec.rb
@@ -108,6 +108,15 @@ describe GroupDescendantsFinder do
end
end
end
+
+ it 'does not include projects shared with the group' do
+ project = create(:project, namespace: group)
+ other_project = create(:project)
+ other_project.project_group_links.create(group: group,
+ group_access: ProjectGroupLink::MASTER)
+
+ expect(finder.execute).to contain_exactly(project)
+ end
end
context 'with nested groups', :nested_groups do
diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js
index 03d4b472b87..76933cf337b 100644
--- a/spec/javascripts/groups/components/app_spec.js
+++ b/spec/javascripts/groups/components/app_spec.js
@@ -9,9 +9,14 @@ import GroupsStore from '~/groups/store/groups_store';
import GroupsService from '~/groups/service/groups_service';
import {
- mockEndpoint, mockGroups, mockSearchedGroups,
- mockRawPageInfo, mockParentGroupItem, mockRawChildren,
- mockChildren, mockPageInfo,
+ mockEndpoint,
+ mockGroups,
+ mockSearchedGroups,
+ mockRawPageInfo,
+ mockParentGroupItem,
+ mockRawChildren,
+ mockChildren,
+ mockPageInfo,
} from '../mock_data';
const createComponent = (hideProjects = false) => {
@@ -28,22 +33,23 @@ const createComponent = (hideProjects = false) => {
});
};
-const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
- if (failed) {
- reject(data);
- } else {
- resolve({
- json() {
- return data;
- },
- });
- }
-});
+const returnServicePromise = (data, failed) =>
+ new Promise((resolve, reject) => {
+ if (failed) {
+ reject(data);
+ } else {
+ resolve({
+ json() {
+ return data;
+ },
+ });
+ }
+ });
describe('AppComponent', () => {
let vm;
- beforeEach((done) => {
+ beforeEach(done => {
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
@@ -94,7 +100,7 @@ describe('AppComponent', () => {
});
describe('fetchGroups', () => {
- it('should call `getGroups` with all the params provided', (done) => {
+ it('should call `getGroups` with all the params provided', done => {
spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups));
vm.fetchGroups({
@@ -110,8 +116,10 @@ describe('AppComponent', () => {
}, 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 }));
+ 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 });
@@ -122,7 +130,7 @@ describe('AppComponent', () => {
}, 0);
});
- it('should show flash error when request fails', (done) => {
+ it('should show flash error when request fails', done => {
spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true));
spyOn($, 'scrollTo');
spyOn(window, 'Flash');
@@ -138,7 +146,7 @@ describe('AppComponent', () => {
});
describe('fetchAllGroups', () => {
- it('should fetch default set of groups', (done) => {
+ 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();
@@ -153,7 +161,7 @@ describe('AppComponent', () => {
}, 0);
});
- it('should fetch matching set of groups when app is loaded with search query', (done) => {
+ 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();
@@ -173,7 +181,7 @@ describe('AppComponent', () => {
});
describe('fetchPage', () => {
- it('should fetch groups for provided page details and update window state', (done) => {
+ 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();
const mergeUrlParams = spyOnDependency(appComponent, 'mergeUrlParams').and.callThrough();
@@ -193,9 +201,13 @@ describe('AppComponent', () => {
expect(vm.isLoading).toBe(false);
expect($.scrollTo).toHaveBeenCalledWith(0);
expect(mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
- expect(window.history.replaceState).toHaveBeenCalledWith({
- page: jasmine.any(String),
- }, jasmine.any(String), jasmine.any(String));
+ expect(window.history.replaceState).toHaveBeenCalledWith(
+ {
+ page: jasmine.any(String),
+ },
+ jasmine.any(String),
+ jasmine.any(String),
+ );
expect(vm.updateGroups).toHaveBeenCalled();
done();
}, 0);
@@ -211,7 +223,7 @@ describe('AppComponent', () => {
groupItem.isChildrenLoading = false;
});
- it('should fetch children of given group and expand it if group is collapsed and children are not loaded', (done) => {
+ 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');
@@ -244,7 +256,7 @@ describe('AppComponent', () => {
expect(groupItem.isOpen).toBe(false);
});
- it('should set `isChildrenLoading` back to `false` if load request fails', (done) => {
+ it('should set `isChildrenLoading` back to `false` if load request fails', done => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
vm.toggleChildren(groupItem);
@@ -272,7 +284,9 @@ describe('AppComponent', () => {
expect(vm.groupLeaveConfirmationMessage).toBe('');
vm.showLeaveGroupModal(group, mockParentGroupItem);
expect(vm.showModal).toBe(true);
- expect(vm.groupLeaveConfirmationMessage).toBe(`Are you sure you want to leave the "${group.fullName}" group?`);
+ expect(vm.groupLeaveConfirmationMessage).toBe(
+ `Are you sure you want to leave the "${group.fullName}" group?`,
+ );
});
});
@@ -299,7 +313,7 @@ describe('AppComponent', () => {
vm.targetParentGroup = groupItem;
});
- it('hides modal confirmation leave group and remove group item from tree', (done) => {
+ it('hides modal confirmation 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();
@@ -318,9 +332,11 @@ describe('AppComponent', () => {
}, 0);
});
- it('should show error flash message if request failed to leave group', (done) => {
+ 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.service, 'leaveGroup').and.returnValue(
+ returnServicePromise({ status: 500 }, true),
+ );
spyOn(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash');
@@ -335,9 +351,11 @@ describe('AppComponent', () => {
}, 0);
});
- it('should show appropriate error flash message if request forbids to leave group', (done) => {
+ 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.service, 'leaveGroup').and.returnValue(
+ returnServicePromise({ status: 403 }, true),
+ );
spyOn(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash');
@@ -388,7 +406,7 @@ describe('AppComponent', () => {
});
describe('created', () => {
- it('should bind event listeners on eventHub', (done) => {
+ it('should bind event listeners on eventHub', done => {
spyOn(eventHub, '$on');
const newVm = createComponent();
@@ -405,21 +423,21 @@ describe('AppComponent', () => {
});
});
- it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (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');
+ expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search');
newVm.$destroy();
done();
});
});
- it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (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');
+ expect(newVm.searchEmptyMessage).toBe('No groups matched your search');
newVm.$destroy();
done();
});
@@ -427,7 +445,7 @@ describe('AppComponent', () => {
});
describe('beforeDestroy', () => {
- it('should unbind event listeners on eventHub', (done) => {
+ it('should unbind event listeners on eventHub', done => {
spyOn(eventHub, '$off');
const newVm = createComponent();
@@ -454,7 +472,7 @@ describe('AppComponent', () => {
vm.$destroy();
});
- it('should render loading icon', (done) => {
+ it('should render loading icon', done => {
vm.isLoading = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
@@ -463,7 +481,7 @@ describe('AppComponent', () => {
});
});
- it('should render groups tree', (done) => {
+ it('should render groups tree', done => {
vm.store.state.groups = [mockParentGroupItem];
vm.isLoading = false;
vm.store.state.pageInfo = mockPageInfo;
@@ -473,7 +491,7 @@ describe('AppComponent', () => {
});
});
- it('renders modal confirmation dialog', (done) => {
+ it('renders modal confirmation dialog', done => {
vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?';
vm.showModal = true;
Vue.nextTick(() => {